feat(staff): Restrict staff permissions and add schedule view
- Backend: Restrict staff from accessing resources, customers, services, and tasks APIs - Frontend: Hide management sidebar links from staff members - Add StaffSchedule page with vertical timeline view of appointments - Add StaffHelp page with staff-specific documentation - Return linked_resource_id and can_edit_schedule in user profile for staff 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
VITE_DEV_MODE=true
|
VITE_DEV_MODE=true
|
||||||
VITE_API_URL=http://api.lvh.me:8000
|
VITE_API_URL=http://api.lvh.me:8000
|
||||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
|
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
|
||||||
|
VITE_GOOGLE_MAPS_API_KEY=
|
||||||
|
|||||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@react-google-maps/api": "^2.20.7",
|
||||||
"@stripe/connect-js": "^3.3.31",
|
"@stripe/connect-js": "^3.3.31",
|
||||||
"@stripe/react-connect-js": "^3.3.31",
|
"@stripe/react-connect-js": "^3.3.31",
|
||||||
"@stripe/react-stripe-js": "^5.4.1",
|
"@stripe/react-stripe-js": "^5.4.1",
|
||||||
@@ -984,6 +985,22 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@googlemaps/js-api-loader": {
|
||||||
|
"version": "1.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz",
|
||||||
|
"integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@googlemaps/markerclusterer": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -1098,6 +1115,36 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-google-maps/api": {
|
||||||
|
"version": "2.20.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.7.tgz",
|
||||||
|
"integrity": "sha512-ys7uri3V6gjhYZUI43srHzSKDC6/jiKTwHNlwXFTvjeaJE3M3OaYBt9FZKvJs8qnOhL6i6nD1BKJoi1KrnkCkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@googlemaps/js-api-loader": "1.16.8",
|
||||||
|
"@googlemaps/markerclusterer": "2.5.3",
|
||||||
|
"@react-google-maps/infobox": "2.20.0",
|
||||||
|
"@react-google-maps/marker-clusterer": "2.20.0",
|
||||||
|
"@types/google.maps": "3.58.1",
|
||||||
|
"invariant": "2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-google-maps/infobox": {
|
||||||
|
"version": "2.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz",
|
||||||
|
"integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-google-maps/marker-clusterer": {
|
||||||
|
"version": "2.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz",
|
||||||
|
"integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz",
|
||||||
@@ -1912,6 +1959,12 @@
|
|||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/google.maps": {
|
||||||
|
"version": "3.58.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
|
||||||
|
"integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/hast": {
|
"node_modules/@types/hast": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||||
@@ -3480,6 +3533,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/invariant": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -3623,6 +3685,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -4797,6 +4865,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@react-google-maps/api": "^2.20.7",
|
||||||
"@stripe/connect-js": "^3.3.31",
|
"@stripe/connect-js": "^3.3.31",
|
||||||
"@stripe/react-connect-js": "^3.3.31",
|
"@stripe/react-connect-js": "^3.3.31",
|
||||||
"@stripe/react-stripe-js": "^5.4.1",
|
"@stripe/react-stripe-js": "^5.4.1",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
|||||||
|
|
||||||
// Import pages
|
// Import pages
|
||||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||||
|
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
||||||
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||||
const Settings = React.lazy(() => import('./pages/Settings'));
|
const Settings = React.lazy(() => import('./pages/Settings'));
|
||||||
@@ -97,6 +98,7 @@ const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'
|
|||||||
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
|
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
|
||||||
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
|
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
|
||||||
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
||||||
|
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
|
||||||
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||||
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
||||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||||
@@ -667,9 +669,29 @@ const AppContent: React.FC = () => {
|
|||||||
path="/"
|
path="/"
|
||||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||||
/>
|
/>
|
||||||
|
{/* Staff Schedule - vertical timeline view */}
|
||||||
|
<Route
|
||||||
|
path="/my-schedule"
|
||||||
|
element={
|
||||||
|
hasAccess(['staff']) ? (
|
||||||
|
<StaffSchedule user={user} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/scheduler" element={<Scheduler />} />
|
<Route path="/scheduler" element={<Scheduler />} />
|
||||||
<Route path="/tickets" element={<Tickets />} />
|
<Route path="/tickets" element={<Tickets />} />
|
||||||
<Route path="/help" element={<HelpComprehensive />} />
|
<Route
|
||||||
|
path="/help"
|
||||||
|
element={
|
||||||
|
user.role === 'staff' ? (
|
||||||
|
<StaffHelp user={user} />
|
||||||
|
) : (
|
||||||
|
<HelpComprehensive />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/help/guide" element={<HelpGuide />} />
|
<Route path="/help/guide" element={<HelpGuide />} />
|
||||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||||
@@ -752,7 +774,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/customers"
|
path="/customers"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/" />
|
||||||
@@ -762,7 +784,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/services"
|
path="/services"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Services />
|
<Services />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/" />
|
||||||
@@ -772,7 +794,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/resources"
|
path="/resources"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/" />
|
||||||
|
|||||||
325
frontend/src/components/ResourceDetailModal.tsx
Normal file
325
frontend/src/components/ResourceDetailModal.tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* Resource Detail Modal
|
||||||
|
*
|
||||||
|
* Shows resource details including a map of the staff member's
|
||||||
|
* current location when they are en route or in progress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api';
|
||||||
|
import { Resource } from '../types';
|
||||||
|
import { useResourceLocation, useLiveResourceLocation } from '../hooks/useResourceLocation';
|
||||||
|
import Portal from './Portal';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
MapPin,
|
||||||
|
Navigation,
|
||||||
|
Clock,
|
||||||
|
User as UserIcon,
|
||||||
|
Activity,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ResourceDetailModalProps {
|
||||||
|
resource: Resource;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapContainerStyle = {
|
||||||
|
width: '100%',
|
||||||
|
height: '300px',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCenter = {
|
||||||
|
lat: 39.8283, // Center of US
|
||||||
|
lng: -98.5795,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResourceDetailModal: React.FC<ResourceDetailModalProps> = ({ resource, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
|
||||||
|
const hasApiKey = googleMapsApiKey.length > 0;
|
||||||
|
|
||||||
|
// Fetch location data
|
||||||
|
const { data: location, isLoading, error } = useResourceLocation(resource.id);
|
||||||
|
|
||||||
|
// Connect to live updates when tracking is active
|
||||||
|
useLiveResourceLocation(resource.id, {
|
||||||
|
enabled: location?.isTracking === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load Google Maps API only if we have a key
|
||||||
|
// When no API key, we skip the hook entirely to avoid warnings
|
||||||
|
const shouldLoadMaps = hasApiKey;
|
||||||
|
const { isLoaded: mapsLoaded, loadError: mapsLoadError } = useJsApiLoader({
|
||||||
|
googleMapsApiKey: shouldLoadMaps ? googleMapsApiKey : 'SKIP_LOADING',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Treat missing API key as if maps failed to load
|
||||||
|
const effectiveMapsLoaded = shouldLoadMaps && mapsLoaded;
|
||||||
|
const effectiveMapsError = !shouldLoadMaps || mapsLoadError;
|
||||||
|
|
||||||
|
// Map center based on location
|
||||||
|
const mapCenter = useMemo(() => {
|
||||||
|
if (location?.hasLocation && location.latitude && location.longitude) {
|
||||||
|
return { lat: location.latitude, lng: location.longitude };
|
||||||
|
}
|
||||||
|
return defaultCenter;
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const formattedTimestamp = useMemo(() => {
|
||||||
|
if (!location?.timestamp) return null;
|
||||||
|
const date = new Date(location.timestamp);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}, [location?.timestamp]);
|
||||||
|
|
||||||
|
// Status color based on job status
|
||||||
|
const statusColor = useMemo(() => {
|
||||||
|
if (!location?.activeJob) return 'gray';
|
||||||
|
switch (location.activeJob.status) {
|
||||||
|
case 'EN_ROUTE':
|
||||||
|
return 'yellow';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'blue';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}, [location?.activeJob]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<UserIcon size={20} className="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{resource.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('resources.staffMember', 'Staff Member')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="sr-only">{t('common.close')}</span>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Active Job Status */}
|
||||||
|
{location?.activeJob && (
|
||||||
|
<div className={`p-4 rounded-lg border ${
|
||||||
|
statusColor === 'yellow'
|
||||||
|
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||||
|
: statusColor === 'blue'
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity size={20} className={
|
||||||
|
statusColor === 'yellow'
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: statusColor === 'blue'
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
|
} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{location.activeJob.statusDisplay}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{location.activeJob.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{location.isTracking && (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||||
|
{t('resources.liveTracking', 'Live')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map Section */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<MapPin size={16} />
|
||||||
|
{t('resources.currentLocation', 'Current Location')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||||
|
<Loader2 size={32} className="text-gray-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-[300px] bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertCircle size={32} className="text-red-400 mx-auto mb-2" />
|
||||||
|
<p className="text-red-600 dark:text-red-400">{t('resources.locationError', 'Failed to load location')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !location?.hasLocation ? (
|
||||||
|
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<MapPin size={32} className="text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{location?.message || t('resources.noLocationData', 'No location data available')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
{t('resources.locationHint', 'Location will appear when staff is en route')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : effectiveMapsError ? (
|
||||||
|
// Fallback when Google Maps isn't available - show coordinates
|
||||||
|
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg p-6">
|
||||||
|
<div className="h-full flex flex-col items-center justify-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
|
||||||
|
<Navigation size={32} className="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
{t('resources.gpsCoordinates', 'GPS Coordinates')}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
{location.latitude?.toFixed(6)}, {location.longitude?.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
{location.speed !== undefined && location.speed !== null && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('resources.speed', 'Speed')}: {(location.speed * 2.237).toFixed(1)} mph
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{location.heading !== undefined && location.heading !== null && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('resources.heading', 'Heading')}: {location.heading.toFixed(0)}°
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps?q=${location.latitude},${location.longitude}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<MapPin size={16} />
|
||||||
|
{t('resources.openInMaps', 'Open in Google Maps')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : effectiveMapsLoaded ? (
|
||||||
|
<GoogleMap
|
||||||
|
mapContainerStyle={mapContainerStyle}
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={15}
|
||||||
|
options={{
|
||||||
|
disableDefaultUI: false,
|
||||||
|
zoomControl: true,
|
||||||
|
mapTypeControl: false,
|
||||||
|
streetViewControl: false,
|
||||||
|
fullscreenControl: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{location.latitude && location.longitude && (
|
||||||
|
<Marker
|
||||||
|
position={{ lat: location.latitude, lng: location.longitude }}
|
||||||
|
title={resource.name}
|
||||||
|
icon={{
|
||||||
|
path: google.maps.SymbolPath.CIRCLE,
|
||||||
|
scale: 10,
|
||||||
|
fillColor: location.isTracking ? '#22c55e' : '#3b82f6',
|
||||||
|
fillOpacity: 1,
|
||||||
|
strokeColor: '#ffffff',
|
||||||
|
strokeWeight: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</GoogleMap>
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||||
|
<Loader2 size={32} className="text-gray-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Details */}
|
||||||
|
{location?.hasLocation && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
{t('resources.lastUpdate', 'Last Update')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{formattedTimestamp || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{location.accuracy && (
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{t('resources.accuracy', 'Accuracy')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{location.accuracy < 1000
|
||||||
|
? `${location.accuracy.toFixed(0)}m`
|
||||||
|
: `${(location.accuracy / 1000).toFixed(1)}km`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{location.speed !== undefined && location.speed !== null && (
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{t('resources.speed', 'Speed')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{(location.speed * 2.237).toFixed(1)} mph
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{location.heading !== undefined && location.heading !== null && (
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{t('resources.heading', 'Heading')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{location.heading.toFixed(0)}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResourceDetailModal;
|
||||||
@@ -4,7 +4,9 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { Clock, DollarSign } from 'lucide-react';
|
import { Clock, DollarSign } from 'lucide-react';
|
||||||
|
|
||||||
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
// Import from types.ts for consistency
|
||||||
|
import type { AppointmentStatus } from '../../types';
|
||||||
|
export type { AppointmentStatus };
|
||||||
|
|
||||||
export interface DraggableEventProps {
|
export interface DraggableEventProps {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
const { canUse } = usePlanFeatures();
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
const canViewAdminPages = role === 'owner' || role === 'manager';
|
||||||
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
|
const canViewManagementPages = role === 'owner' || role === 'manager';
|
||||||
|
const isStaff = role === 'staff';
|
||||||
const canViewSettings = role === 'owner';
|
const canViewSettings = role === 'owner';
|
||||||
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
||||||
|
|
||||||
@@ -110,19 +111,31 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
<SidebarItem
|
{!isStaff && (
|
||||||
to="/scheduler"
|
<SidebarItem
|
||||||
icon={CalendarDays}
|
to="/scheduler"
|
||||||
label={t('nav.scheduler')}
|
icon={CalendarDays}
|
||||||
isCollapsed={isCollapsed}
|
label={t('nav.scheduler')}
|
||||||
/>
|
isCollapsed={isCollapsed}
|
||||||
<SidebarItem
|
/>
|
||||||
to="/tasks"
|
)}
|
||||||
icon={Clock}
|
{!isStaff && (
|
||||||
label={t('nav.tasks', 'Tasks')}
|
<SidebarItem
|
||||||
isCollapsed={isCollapsed}
|
to="/tasks"
|
||||||
locked={!canUse('plugins') || !canUse('tasks')}
|
icon={Clock}
|
||||||
/>
|
label={t('nav.tasks', 'Tasks')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
locked={!canUse('plugins') || !canUse('tasks')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isStaff && (
|
||||||
|
<SidebarItem
|
||||||
|
to="/my-schedule"
|
||||||
|
icon={CalendarDays}
|
||||||
|
label={t('nav.mySchedule', 'My Schedule')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{(role === 'staff' || role === 'resource') && (
|
{(role === 'staff' || role === 'resource') && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/my-availability"
|
to="/my-availability"
|
||||||
|
|||||||
186
frontend/src/hooks/useResourceLocation.ts
Normal file
186
frontend/src/hooks/useResourceLocation.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Resource Location Hook
|
||||||
|
*
|
||||||
|
* Fetches the latest location for a resource's linked staff member.
|
||||||
|
* Used for tracking staff during en route jobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
|
export interface ResourceLocation {
|
||||||
|
hasLocation: boolean;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
accuracy?: number;
|
||||||
|
heading?: number;
|
||||||
|
speed?: number;
|
||||||
|
timestamp?: string;
|
||||||
|
isTracking: boolean;
|
||||||
|
activeJob?: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
statusDisplay: string;
|
||||||
|
} | null;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendLocationResponse {
|
||||||
|
has_location: boolean;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
accuracy?: number;
|
||||||
|
heading?: number;
|
||||||
|
speed?: number;
|
||||||
|
timestamp?: string;
|
||||||
|
is_tracking?: boolean;
|
||||||
|
active_job?: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
status_display: string;
|
||||||
|
} | null;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch a resource's latest location
|
||||||
|
*/
|
||||||
|
export const useResourceLocation = (resourceId: string | null, options?: { enabled?: boolean }) => {
|
||||||
|
return useQuery<ResourceLocation>({
|
||||||
|
queryKey: ['resourceLocation', resourceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<BackendLocationResponse>(`/resources/${resourceId}/location/`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasLocation: data.has_location,
|
||||||
|
latitude: data.latitude,
|
||||||
|
longitude: data.longitude,
|
||||||
|
accuracy: data.accuracy,
|
||||||
|
heading: data.heading,
|
||||||
|
speed: data.speed,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
isTracking: data.is_tracking ?? false,
|
||||||
|
activeJob: data.active_job ? {
|
||||||
|
id: data.active_job.id,
|
||||||
|
title: data.active_job.title,
|
||||||
|
status: data.active_job.status,
|
||||||
|
statusDisplay: data.active_job.status_display,
|
||||||
|
} : null,
|
||||||
|
message: data.message,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!resourceId && (options?.enabled !== false),
|
||||||
|
refetchInterval: false, // We'll use WebSocket for live updates instead
|
||||||
|
staleTime: 30000, // Consider data stale after 30 seconds
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for live location updates via WebSocket
|
||||||
|
*
|
||||||
|
* Connects to WebSocket when enabled and updates the query cache
|
||||||
|
* when new location data arrives.
|
||||||
|
*/
|
||||||
|
export const useLiveResourceLocation = (
|
||||||
|
resourceId: string | null,
|
||||||
|
options?: { enabled?: boolean }
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!resourceId || options?.enabled === false) return;
|
||||||
|
|
||||||
|
// Get WebSocket URL from current host
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const port = '8000'; // Backend port
|
||||||
|
const wsUrl = `${protocol}//${host}:${port}/ws/resource-location/${resourceId}/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('[useLiveResourceLocation] WebSocket connected for resource:', resourceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'location_update') {
|
||||||
|
// Update the query cache with new location data
|
||||||
|
queryClient.setQueryData<ResourceLocation>(['resourceLocation', resourceId], (old) => ({
|
||||||
|
...old,
|
||||||
|
hasLocation: true,
|
||||||
|
latitude: data.latitude,
|
||||||
|
longitude: data.longitude,
|
||||||
|
accuracy: data.accuracy,
|
||||||
|
heading: data.heading,
|
||||||
|
speed: data.speed,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
isTracking: true,
|
||||||
|
activeJob: data.active_job ? {
|
||||||
|
id: data.active_job.id,
|
||||||
|
title: data.active_job.title,
|
||||||
|
status: data.active_job.status,
|
||||||
|
statusDisplay: data.active_job.status_display,
|
||||||
|
} : old?.activeJob ?? null,
|
||||||
|
}));
|
||||||
|
} else if (data.type === 'tracking_stopped') {
|
||||||
|
// Staff stopped tracking
|
||||||
|
queryClient.setQueryData<ResourceLocation>(['resourceLocation', resourceId], (old) => ({
|
||||||
|
...old,
|
||||||
|
hasLocation: old?.hasLocation ?? false,
|
||||||
|
isTracking: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useLiveResourceLocation] Failed to parse message:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('[useLiveResourceLocation] WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
console.log('[useLiveResourceLocation] WebSocket closed:', event.code, event.reason);
|
||||||
|
wsRef.current = null;
|
||||||
|
|
||||||
|
// Reconnect after 5 seconds if not a clean close
|
||||||
|
if (event.code !== 1000 && options?.enabled !== false) {
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useLiveResourceLocation] Failed to connect:', err);
|
||||||
|
}
|
||||||
|
}, [resourceId, options?.enabled, queryClient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close(1000, 'Component unmounting');
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
// Return a function to manually refresh
|
||||||
|
return {
|
||||||
|
refresh: () => queryClient.invalidateQueries({ queryKey: ['resourceLocation', resourceId] }),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -30,6 +30,7 @@ export const useResources = (filters?: ResourceFilters) => {
|
|||||||
userId: r.user_id ? String(r.user_id) : undefined,
|
userId: r.user_id ? String(r.user_id) : undefined,
|
||||||
maxConcurrentEvents: r.max_concurrent_events ?? 1,
|
maxConcurrentEvents: r.max_concurrent_events ?? 1,
|
||||||
savedLaneCount: r.saved_lane_count,
|
savedLaneCount: r.saved_lane_count,
|
||||||
|
userCanEditSchedule: r.user_can_edit_schedule ?? false,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -51,6 +52,7 @@ export const useResource = (id: string) => {
|
|||||||
userId: data.user_id ? String(data.user_id) : undefined,
|
userId: data.user_id ? String(data.user_id) : undefined,
|
||||||
maxConcurrentEvents: data.max_concurrent_events ?? 1,
|
maxConcurrentEvents: data.max_concurrent_events ?? 1,
|
||||||
savedLaneCount: data.saved_lane_count,
|
savedLaneCount: data.saved_lane_count,
|
||||||
|
userCanEditSchedule: data.user_can_edit_schedule ?? false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -65,13 +67,23 @@ export const useCreateResource = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
|
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
|
||||||
const backendData = {
|
const backendData: any = {
|
||||||
name: resourceData.name,
|
name: resourceData.name,
|
||||||
type: resourceData.type,
|
type: resourceData.type,
|
||||||
user: resourceData.userId ? parseInt(resourceData.userId) : null,
|
user: resourceData.userId ? parseInt(resourceData.userId) : null,
|
||||||
timezone: 'UTC', // Default timezone
|
timezone: 'UTC', // Default timezone
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (resourceData.maxConcurrentEvents !== undefined) {
|
||||||
|
backendData.max_concurrent_events = resourceData.maxConcurrentEvents;
|
||||||
|
}
|
||||||
|
if (resourceData.savedLaneCount !== undefined) {
|
||||||
|
backendData.saved_lane_count = resourceData.savedLaneCount;
|
||||||
|
}
|
||||||
|
if (resourceData.userCanEditSchedule !== undefined) {
|
||||||
|
backendData.user_can_edit_schedule = resourceData.userCanEditSchedule;
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = await apiClient.post('/resources/', backendData);
|
const { data } = await apiClient.post('/resources/', backendData);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -101,6 +113,9 @@ export const useUpdateResource = () => {
|
|||||||
if (updates.savedLaneCount !== undefined) {
|
if (updates.savedLaneCount !== undefined) {
|
||||||
backendData.saved_lane_count = updates.savedLaneCount;
|
backendData.saved_lane_count = updates.savedLaneCount;
|
||||||
}
|
}
|
||||||
|
if (updates.userCanEditSchedule !== undefined) {
|
||||||
|
backendData.user_can_edit_schedule = updates.userCanEditSchedule;
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = await apiClient.patch(`/resources/${id}/`, backendData);
|
const { data } = await apiClient.patch(`/resources/${id}/`, backendData);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useResources, useCreateResource, useUpdateResource } from '../hooks/use
|
|||||||
import { useAppointments } from '../hooks/useAppointments';
|
import { useAppointments } from '../hooks/useAppointments';
|
||||||
import { useStaff, StaffMember } from '../hooks/useStaff';
|
import { useStaff, StaffMember } from '../hooks/useStaff';
|
||||||
import ResourceCalendar from '../components/ResourceCalendar';
|
import ResourceCalendar from '../components/ResourceCalendar';
|
||||||
|
import ResourceDetailModal from '../components/ResourceDetailModal';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
Pencil,
|
Pencil,
|
||||||
AlertTriangle
|
AlertTriangle,
|
||||||
|
MapPin
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
||||||
@@ -46,6 +48,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||||
const [editingResource, setEditingResource] = React.useState<Resource | null>(null);
|
const [editingResource, setEditingResource] = React.useState<Resource | null>(null);
|
||||||
const [calendarResource, setCalendarResource] = React.useState<{ id: string; name: string } | null>(null);
|
const [calendarResource, setCalendarResource] = React.useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [detailResource, setDetailResource] = React.useState<Resource | null>(null);
|
||||||
|
|
||||||
// Calculate over-quota resources (will be auto-archived when grace period ends)
|
// Calculate over-quota resources (will be auto-archived when grace period ends)
|
||||||
const overQuotaResourceIds = useMemo(
|
const overQuotaResourceIds = useMemo(
|
||||||
@@ -60,6 +63,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
const [formMaxConcurrent, setFormMaxConcurrent] = React.useState(1);
|
const [formMaxConcurrent, setFormMaxConcurrent] = React.useState(1);
|
||||||
const [formMultilaneEnabled, setFormMultilaneEnabled] = React.useState(false);
|
const [formMultilaneEnabled, setFormMultilaneEnabled] = React.useState(false);
|
||||||
const [formSavedLaneCount, setFormSavedLaneCount] = React.useState<number | undefined>(undefined);
|
const [formSavedLaneCount, setFormSavedLaneCount] = React.useState<number | undefined>(undefined);
|
||||||
|
const [formUserCanEditSchedule, setFormUserCanEditSchedule] = React.useState(false);
|
||||||
|
|
||||||
// Staff selection state
|
// Staff selection state
|
||||||
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
|
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
|
||||||
@@ -181,6 +185,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
setFormMaxConcurrent(editingResource.maxConcurrentEvents);
|
setFormMaxConcurrent(editingResource.maxConcurrentEvents);
|
||||||
setFormMultilaneEnabled(editingResource.maxConcurrentEvents > 1);
|
setFormMultilaneEnabled(editingResource.maxConcurrentEvents > 1);
|
||||||
setFormSavedLaneCount(editingResource.savedLaneCount);
|
setFormSavedLaneCount(editingResource.savedLaneCount);
|
||||||
|
setFormUserCanEditSchedule(editingResource.userCanEditSchedule ?? false);
|
||||||
// Pre-fill staff if editing a STAFF resource
|
// Pre-fill staff if editing a STAFF resource
|
||||||
if (editingResource.type === 'STAFF' && editingResource.userId) {
|
if (editingResource.type === 'STAFF' && editingResource.userId) {
|
||||||
setSelectedStaffId(editingResource.userId);
|
setSelectedStaffId(editingResource.userId);
|
||||||
@@ -197,6 +202,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
setFormMaxConcurrent(1);
|
setFormMaxConcurrent(1);
|
||||||
setFormMultilaneEnabled(false);
|
setFormMultilaneEnabled(false);
|
||||||
setFormSavedLaneCount(undefined);
|
setFormSavedLaneCount(undefined);
|
||||||
|
setFormUserCanEditSchedule(false);
|
||||||
setSelectedStaffId(null);
|
setSelectedStaffId(null);
|
||||||
setStaffSearchQuery('');
|
setStaffSearchQuery('');
|
||||||
setDebouncedSearchQuery('');
|
setDebouncedSearchQuery('');
|
||||||
@@ -258,6 +264,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
maxConcurrentEvents: number;
|
maxConcurrentEvents: number;
|
||||||
savedLaneCount: number | undefined;
|
savedLaneCount: number | undefined;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
userCanEditSchedule?: boolean;
|
||||||
} = {
|
} = {
|
||||||
name: formName,
|
name: formName,
|
||||||
type: formType,
|
type: formType,
|
||||||
@@ -267,6 +274,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
|
|
||||||
if (formType === 'STAFF' && selectedStaffId) {
|
if (formType === 'STAFF' && selectedStaffId) {
|
||||||
resourceData.userId = selectedStaffId;
|
resourceData.userId = selectedStaffId;
|
||||||
|
resourceData.userCanEditSchedule = formUserCanEditSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingResource) {
|
if (editingResource) {
|
||||||
@@ -409,6 +417,15 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{resource.type === 'STAFF' && resource.userId && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailResource(resource)}
|
||||||
|
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
|
||||||
|
title={t('resources.trackLocation', 'Track Location')}
|
||||||
|
>
|
||||||
|
<MapPin size={14} /> {t('resources.trackLocation', 'Track')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCalendarResource({ id: resource.id, name: resource.name })}
|
onClick={() => setCalendarResource({ id: resource.id, name: resource.name })}
|
||||||
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
|
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
|
||||||
@@ -646,6 +663,35 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Allow User to Edit Schedule Toggle (only for STAFF type) */}
|
||||||
|
{formType === 'STAFF' && selectedStaffId && (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{t('resources.allowEditSchedule', 'Allow User to Edit Schedule')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('resources.allowEditScheduleDescription', 'Let this staff member reschedule and resize their own appointments in the mobile app')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormUserCanEditSchedule(!formUserCanEditSchedule)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 ${
|
||||||
|
formUserCanEditSchedule ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={formUserCanEditSchedule}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
formUserCanEditSchedule ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submit Buttons */}
|
{/* Submit Buttons */}
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
@@ -682,6 +728,14 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
onClose={() => setCalendarResource(null)}
|
onClose={() => setCalendarResource(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Resource Detail Modal (with location tracking) */}
|
||||||
|
{detailResource && (
|
||||||
|
<ResourceDetailModal
|
||||||
|
resource={detailResource}
|
||||||
|
onClose={() => setDetailResource(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
460
frontend/src/pages/StaffSchedule.tsx
Normal file
460
frontend/src/pages/StaffSchedule.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
PointerSensor,
|
||||||
|
DragOverlay,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
|
addDays,
|
||||||
|
subDays,
|
||||||
|
differenceInMinutes,
|
||||||
|
addMinutes,
|
||||||
|
isSameDay,
|
||||||
|
parseISO,
|
||||||
|
} from 'date-fns';
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
GripVertical,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import apiClient from '../api/client';
|
||||||
|
import { User as UserType } from '../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface StaffScheduleProps {
|
||||||
|
user: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
customer_name?: string;
|
||||||
|
service_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR_HEIGHT = 60; // pixels per hour
|
||||||
|
const START_HOUR = 6; // 6 AM
|
||||||
|
const END_HOUR = 22; // 10 PM
|
||||||
|
|
||||||
|
const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [draggedJob, setDraggedJob] = useState<Job | null>(null);
|
||||||
|
|
||||||
|
const canEditSchedule = user.can_edit_schedule ?? false;
|
||||||
|
|
||||||
|
// Get the resource ID linked to this user (from the user object)
|
||||||
|
const userResourceId = user.linked_resource_id ?? null;
|
||||||
|
|
||||||
|
// Fetch appointments for the current staff member's resource
|
||||||
|
const { data: jobs = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['staff-jobs', format(currentDate, 'yyyy-MM-dd'), userResourceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!userResourceId) return [];
|
||||||
|
|
||||||
|
const start = startOfDay(currentDate).toISOString();
|
||||||
|
const end = endOfDay(currentDate).toISOString();
|
||||||
|
const response = await apiClient.get('/appointments/', {
|
||||||
|
params: {
|
||||||
|
resource: userResourceId,
|
||||||
|
start_date: start,
|
||||||
|
end_date: end,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform to Job format
|
||||||
|
return response.data.map((apt: any) => ({
|
||||||
|
id: apt.id,
|
||||||
|
title: apt.title || apt.service_name || 'Appointment',
|
||||||
|
start_time: apt.start_time,
|
||||||
|
end_time: apt.end_time,
|
||||||
|
status: apt.status,
|
||||||
|
notes: apt.notes,
|
||||||
|
customer_name: apt.customer_name,
|
||||||
|
service_name: apt.service_name,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
enabled: !!userResourceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation for rescheduling
|
||||||
|
const rescheduleMutation = useMutation({
|
||||||
|
mutationFn: async ({ jobId, newStart }: { jobId: number; newStart: Date }) => {
|
||||||
|
const job = jobs.find((j) => j.id === jobId);
|
||||||
|
if (!job) throw new Error('Job not found');
|
||||||
|
|
||||||
|
const duration = differenceInMinutes(parseISO(job.end_time), parseISO(job.start_time));
|
||||||
|
const newEnd = addMinutes(newStart, duration);
|
||||||
|
|
||||||
|
await apiClient.patch(`/appointments/${jobId}/`, {
|
||||||
|
start_time: newStart.toISOString(),
|
||||||
|
end_time: newEnd.toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['staff-jobs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['appointments'] });
|
||||||
|
toast.success(t('staff.jobRescheduled', 'Job rescheduled successfully'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('staff.rescheduleError', 'Failed to reschedule job'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate time slots
|
||||||
|
const timeSlots = useMemo(() => {
|
||||||
|
const slots = [];
|
||||||
|
for (let hour = START_HOUR; hour <= END_HOUR; hour++) {
|
||||||
|
slots.push({
|
||||||
|
hour,
|
||||||
|
label: format(new Date().setHours(hour, 0, 0, 0), 'h a'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate job positions
|
||||||
|
const jobsWithPositions = useMemo(() => {
|
||||||
|
return jobs
|
||||||
|
.filter((job) => {
|
||||||
|
const jobDate = parseISO(job.start_time);
|
||||||
|
return isSameDay(jobDate, currentDate);
|
||||||
|
})
|
||||||
|
.map((job) => {
|
||||||
|
const startTime = parseISO(job.start_time);
|
||||||
|
const endTime = parseISO(job.end_time);
|
||||||
|
const startHour = startTime.getHours() + startTime.getMinutes() / 60;
|
||||||
|
const endHour = endTime.getHours() + endTime.getMinutes() / 60;
|
||||||
|
|
||||||
|
const top = (startHour - START_HOUR) * HOUR_HEIGHT;
|
||||||
|
const height = (endHour - startHour) * HOUR_HEIGHT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
top: Math.max(0, top),
|
||||||
|
height: Math.max(30, height),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [jobs, currentDate]);
|
||||||
|
|
||||||
|
const handleDragStart = (event: any) => {
|
||||||
|
const jobId = parseInt(event.active.id.toString().replace('job-', ''));
|
||||||
|
const job = jobs.find((j) => j.id === jobId);
|
||||||
|
setDraggedJob(job || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setDraggedJob(null);
|
||||||
|
|
||||||
|
if (!canEditSchedule) return;
|
||||||
|
|
||||||
|
const { active, delta } = event;
|
||||||
|
if (!active || Math.abs(delta.y) < 10) return;
|
||||||
|
|
||||||
|
const jobId = parseInt(active.id.toString().replace('job-', ''));
|
||||||
|
const job = jobs.find((j) => j.id === jobId);
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
// Calculate new time based on drag delta
|
||||||
|
const minutesDelta = Math.round((delta.y / HOUR_HEIGHT) * 60);
|
||||||
|
const snappedMinutes = Math.round(minutesDelta / 15) * 15; // Snap to 15-minute intervals
|
||||||
|
|
||||||
|
const originalStart = parseISO(job.start_time);
|
||||||
|
const newStart = addMinutes(originalStart, snappedMinutes);
|
||||||
|
|
||||||
|
// Validate new time is within bounds
|
||||||
|
const newHour = newStart.getHours();
|
||||||
|
if (newHour < START_HOUR || newHour >= END_HOUR) {
|
||||||
|
toast.error(t('staff.timeOutOfBounds', 'Cannot schedule outside business hours'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rescheduleMutation.mutate({ jobId, newStart });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status.toUpperCase()) {
|
||||||
|
case 'SCHEDULED':
|
||||||
|
case 'CONFIRMED':
|
||||||
|
return 'bg-blue-100 border-blue-500 text-blue-800 dark:bg-blue-900/30 dark:border-blue-400 dark:text-blue-300';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'bg-yellow-100 border-yellow-500 text-yellow-800 dark:bg-yellow-900/30 dark:border-yellow-400 dark:text-yellow-300';
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'bg-green-100 border-green-500 text-green-800 dark:bg-green-900/30 dark:border-green-400 dark:text-green-300';
|
||||||
|
case 'CANCELLED':
|
||||||
|
case 'NO_SHOW':
|
||||||
|
return 'bg-red-100 border-red-500 text-red-800 dark:bg-red-900/30 dark:border-red-400 dark:text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 border-gray-500 text-gray-800 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateDate = (direction: 'prev' | 'next') => {
|
||||||
|
setCurrentDate((d) => (direction === 'prev' ? subDays(d, 1) : addDays(d, 1)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToToday = () => {
|
||||||
|
setCurrentDate(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show message if no resource is linked
|
||||||
|
if (!userResourceId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staff.mySchedule', 'My Schedule')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<Calendar size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
{t('staff.noResourceLinked', 'No Schedule Available')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t(
|
||||||
|
'staff.noResourceLinkedDesc',
|
||||||
|
'Your account is not linked to a resource yet. Please contact your manager to set up your schedule.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staff.mySchedule', 'My Schedule')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{canEditSchedule
|
||||||
|
? t('staff.dragToReschedule', 'Drag jobs to reschedule them')
|
||||||
|
: t('staff.viewOnlySchedule', 'View your scheduled jobs for the day')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigateDate('prev')}
|
||||||
|
className="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goToToday}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.today', 'Today')}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||||
|
<Calendar size={16} className="text-gray-500 dark:text-gray-400" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{format(currentDate, 'EEEE, MMMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigateDate('next')}
|
||||||
|
className="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="flex">
|
||||||
|
{/* Time Column */}
|
||||||
|
<div className="w-20 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
{timeSlots.map((slot) => (
|
||||||
|
<div
|
||||||
|
key={slot.hour}
|
||||||
|
className="border-b border-gray-100 dark:border-gray-700/50 text-right pr-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
|
style={{ height: HOUR_HEIGHT }}
|
||||||
|
>
|
||||||
|
{slot.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events Column */}
|
||||||
|
<div
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{ height: (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT }}
|
||||||
|
>
|
||||||
|
{/* Hour Grid Lines */}
|
||||||
|
{timeSlots.map((slot) => (
|
||||||
|
<div
|
||||||
|
key={slot.hour}
|
||||||
|
className="absolute left-0 right-0 border-b border-gray-100 dark:border-gray-700/50"
|
||||||
|
style={{ top: (slot.hour - START_HOUR) * HOUR_HEIGHT, height: HOUR_HEIGHT }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Current Time Line */}
|
||||||
|
{isSameDay(currentDate, new Date()) && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 border-t-2 border-red-500 z-20"
|
||||||
|
style={{
|
||||||
|
top:
|
||||||
|
(new Date().getHours() +
|
||||||
|
new Date().getMinutes() / 60 -
|
||||||
|
START_HOUR) *
|
||||||
|
HOUR_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute -left-1 -top-1.5 w-3 h-3 bg-red-500 rounded-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jobs */}
|
||||||
|
{jobsWithPositions.map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
id={`job-${job.id}`}
|
||||||
|
className={`absolute left-2 right-2 rounded-lg border-l-4 p-3 transition-shadow ${getStatusColor(job.status)} ${
|
||||||
|
canEditSchedule ? 'cursor-grab active:cursor-grabbing hover:shadow-lg' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
top: job.top,
|
||||||
|
height: job.height,
|
||||||
|
minHeight: 60,
|
||||||
|
}}
|
||||||
|
draggable={canEditSchedule}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between h-full">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{canEditSchedule && (
|
||||||
|
<GripVertical
|
||||||
|
size={14}
|
||||||
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h3 className="font-semibold text-sm truncate">
|
||||||
|
{job.title || job.service_name || 'Appointment'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs opacity-80">
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>
|
||||||
|
{format(parseISO(job.start_time), 'h:mm a')} -{' '}
|
||||||
|
{format(parseISO(job.end_time), 'h:mm a')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.customer_name && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs opacity-80">
|
||||||
|
<User size={12} />
|
||||||
|
<span className="truncate">{job.customer_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
job.status === 'IN_PROGRESS'
|
||||||
|
? 'bg-yellow-200 text-yellow-800'
|
||||||
|
: 'bg-white/50 dark:bg-gray-900/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{jobsWithPositions.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Calendar
|
||||||
|
size={48}
|
||||||
|
className="mx-auto text-gray-300 dark:text-gray-600 mb-4"
|
||||||
|
/>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
{t('staff.noJobsToday', 'No jobs scheduled')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t(
|
||||||
|
'staff.noJobsDescription',
|
||||||
|
'You have no jobs scheduled for this day'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag Overlay */}
|
||||||
|
<DragOverlay>
|
||||||
|
{draggedJob ? (
|
||||||
|
<div className="p-3 bg-white dark:bg-gray-700 border-l-4 border-blue-500 rounded-lg shadow-xl opacity-90 w-64">
|
||||||
|
<div className="font-semibold text-sm text-gray-900 dark:text-white">
|
||||||
|
{draggedJob.title || draggedJob.service_name || 'Appointment'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>
|
||||||
|
{format(parseISO(draggedJob.start_time), 'h:mm a')} -{' '}
|
||||||
|
{format(parseISO(draggedJob.end_time), 'h:mm a')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StaffSchedule;
|
||||||
285
frontend/src/pages/help/StaffHelp.tsx
Normal file
285
frontend/src/pages/help/StaffHelp.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Staff Help Guide
|
||||||
|
*
|
||||||
|
* Simplified documentation for staff members.
|
||||||
|
* Only covers features that staff have access to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
LayoutDashboard,
|
||||||
|
Calendar,
|
||||||
|
CalendarOff,
|
||||||
|
HelpCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
GripVertical,
|
||||||
|
Ticket,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { User } from '../../types';
|
||||||
|
|
||||||
|
interface StaffHelpProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StaffHelp: React.FC<StaffHelpProps> = ({ user }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const canAccessTickets = user.can_access_tickets ?? false;
|
||||||
|
const canEditSchedule = user.can_edit_schedule ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-20 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen size={24} className="text-brand-600 dark:text-brand-400" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staffHelp.title', 'Staff Guide')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
{/* Introduction */}
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{t('staffHelp.welcome', 'Welcome to SmoothSchedule')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
{t(
|
||||||
|
'staffHelp.intro',
|
||||||
|
'This guide covers everything you need to know as a staff member. You can view your schedule, manage your availability, and stay updated on your assignments.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Dashboard Section */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<LayoutDashboard size={20} className="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staffHelp.dashboard.title', 'Dashboard')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
{t(
|
||||||
|
'staffHelp.dashboard.description',
|
||||||
|
"Your dashboard provides a quick overview of your day. Here you can see today's summary and any important updates."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>{t('staffHelp.dashboard.feature1', 'View daily summary and stats')}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>{t('staffHelp.dashboard.feature2', 'Quick access to your schedule')}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* My Schedule Section */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<Calendar size={20} className="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staffHelp.schedule.title', 'My Schedule')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
{t(
|
||||||
|
'staffHelp.schedule.description',
|
||||||
|
'The My Schedule page shows a vertical timeline of all your jobs for the day. You can navigate between days to see past and future appointments.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
{t('staffHelp.schedule.features', 'Features')}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Clock size={16} className="text-brand-500" />
|
||||||
|
<span>
|
||||||
|
{t('staffHelp.schedule.feature1', 'See all your jobs in a vertical timeline')}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
'staffHelp.schedule.feature2',
|
||||||
|
'View customer name and appointment details'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>
|
||||||
|
{t('staffHelp.schedule.feature3', 'Navigate between days using arrows')}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>
|
||||||
|
{t('staffHelp.schedule.feature4', 'See current time indicator on today\'s view')}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{canEditSchedule ? (
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||||
|
<GripVertical size={18} className="text-green-500" />
|
||||||
|
{t('staffHelp.schedule.rescheduleTitle', 'Drag to Reschedule')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t(
|
||||||
|
'staffHelp.schedule.rescheduleDesc',
|
||||||
|
'You have permission to reschedule your jobs. Simply drag a job up or down on the timeline to move it to a different time slot. Changes will be saved automatically.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t(
|
||||||
|
'staffHelp.schedule.viewOnly',
|
||||||
|
'Your schedule is view-only. Contact a manager if you need to reschedule an appointment.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* My Availability Section */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center">
|
||||||
|
<CalendarOff size={20} className="text-rose-600 dark:text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staffHelp.availability.title', 'My Availability')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
{t(
|
||||||
|
'staffHelp.availability.description',
|
||||||
|
'Use the My Availability page to set times when you are not available for bookings. This helps managers and the booking system know when not to schedule you.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
{t('staffHelp.availability.howTo', 'How to Block Time')}
|
||||||
|
</h3>
|
||||||
|
<ol className="space-y-2 text-sm text-gray-600 dark:text-gray-300 list-decimal list-inside mb-4">
|
||||||
|
<li>{t('staffHelp.availability.step1', 'Click "Add Time Block" button')}</li>
|
||||||
|
<li>{t('staffHelp.availability.step2', 'Select the date and time range')}</li>
|
||||||
|
<li>{t('staffHelp.availability.step3', 'Add an optional reason (e.g., "Vacation", "Doctor appointment")')}</li>
|
||||||
|
<li>{t('staffHelp.availability.step4', 'Choose if it repeats (one-time, weekly, etc.)')}</li>
|
||||||
|
<li>{t('staffHelp.availability.step5', 'Save your time block')}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<strong>{t('staffHelp.availability.note', 'Note:')}</strong>{' '}
|
||||||
|
{t(
|
||||||
|
'staffHelp.availability.noteDesc',
|
||||||
|
'Time blocks you create will prevent new bookings during those times. Existing appointments are not affected.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tickets Section - Only if user has access */}
|
||||||
|
{canAccessTickets && (
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
|
<Ticket size={20} className="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staffHelp.tickets.title', 'Tickets')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
{t(
|
||||||
|
'staffHelp.tickets.description',
|
||||||
|
'You have access to the ticketing system. Use tickets to communicate with customers, report issues, or track requests.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>{t('staffHelp.tickets.feature1', 'View and respond to tickets')}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>{t('staffHelp.tickets.feature2', 'Create new tickets for customer issues')}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span>{t('staffHelp.tickets.feature3', 'Track ticket status and history')}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Help Footer */}
|
||||||
|
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center">
|
||||||
|
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
{t('staffHelp.footer.title', 'Need More Help?')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
{t(
|
||||||
|
'staffHelp.footer.description',
|
||||||
|
"If you have questions or need assistance, please contact your manager or supervisor."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{canAccessTickets && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/tickets')}
|
||||||
|
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
{t('staffHelp.footer.openTicket', 'Open a Ticket')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StaffHelp;
|
||||||
@@ -124,6 +124,8 @@ export interface User {
|
|||||||
notification_preferences?: NotificationPreferences;
|
notification_preferences?: NotificationPreferences;
|
||||||
can_invite_staff?: boolean;
|
can_invite_staff?: boolean;
|
||||||
can_access_tickets?: boolean;
|
can_access_tickets?: boolean;
|
||||||
|
can_edit_schedule?: boolean;
|
||||||
|
linked_resource_id?: number;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
quota_overages?: QuotaOverage[];
|
quota_overages?: QuotaOverage[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Jobs List Screen
|
* Jobs List Screen
|
||||||
*
|
*
|
||||||
* Displays jobs in a timeline view with day/week toggle.
|
* Displays jobs in a timeline view with day/week toggle.
|
||||||
|
* Supports drag-and-drop rescheduling and resize if user has permission.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState, useRef, useMemo } from 'react';
|
import { useCallback, useState, useRef, useMemo } from 'react';
|
||||||
@@ -16,12 +17,15 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { getJobColor, jobStatusColors } from '../../src/api/jobs';
|
import { getJobColor, updateAppointmentTime } from '../../src/api/jobs';
|
||||||
import { useAuth } from '../../src/hooks/useAuth';
|
import { useAuth } from '../../src/hooks/useAuth';
|
||||||
import { useJobs } from '../../src/hooks/useJobs';
|
import { useJobs } from '../../src/hooks/useJobs';
|
||||||
|
import { DraggableJobBlock } from '../../src/components/DraggableJobBlock';
|
||||||
import type { JobListItem, JobStatus } from '../../src/types';
|
import type { JobListItem, JobStatus } from '../../src/types';
|
||||||
|
|
||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
@@ -149,91 +153,8 @@ function getWeekDays(baseDate: Date): Date[] {
|
|||||||
return days;
|
return days;
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobBlock({
|
// Note: JobBlock functionality moved to DraggableJobBlock component
|
||||||
job,
|
// which supports drag-and-drop rescheduling and resize
|
||||||
onPress,
|
|
||||||
viewMode,
|
|
||||||
dayIndex = 0,
|
|
||||||
laneIndex = 0,
|
|
||||||
totalLanes = 1,
|
|
||||||
}: {
|
|
||||||
job: JobListItem;
|
|
||||||
onPress: () => void;
|
|
||||||
viewMode: ViewMode;
|
|
||||||
dayIndex?: number;
|
|
||||||
laneIndex?: number;
|
|
||||||
totalLanes?: number;
|
|
||||||
}) {
|
|
||||||
// Use time-aware color function (shows red for overdue, yellow for in-progress window, etc.)
|
|
||||||
const statusColor = getJobColor(job);
|
|
||||||
|
|
||||||
const startDate = new Date(job.start_time);
|
|
||||||
const endDate = new Date(job.end_time);
|
|
||||||
|
|
||||||
const startHour = startDate.getHours() + startDate.getMinutes() / 60;
|
|
||||||
const endHour = endDate.getHours() + endDate.getMinutes() / 60;
|
|
||||||
|
|
||||||
const top = startHour * HOUR_HEIGHT;
|
|
||||||
const height = Math.max((endHour - startHour) * HOUR_HEIGHT, 40);
|
|
||||||
|
|
||||||
// Calculate width and position based on lanes
|
|
||||||
let blockStyle: { left: number; width: number };
|
|
||||||
|
|
||||||
if (viewMode === 'week') {
|
|
||||||
// Week view: divide the day column by lanes
|
|
||||||
const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes;
|
|
||||||
blockStyle = {
|
|
||||||
left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth,
|
|
||||||
width: laneWidth - 2,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Day view: divide the full width by lanes
|
|
||||||
const laneWidth = DAY_VIEW_WIDTH / totalLanes;
|
|
||||||
blockStyle = {
|
|
||||||
left: laneIndex * laneWidth,
|
|
||||||
width: laneWidth - 4,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.jobBlock,
|
|
||||||
{
|
|
||||||
top,
|
|
||||||
height,
|
|
||||||
borderLeftColor: statusColor,
|
|
||||||
...blockStyle,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.jobBlockHeader}>
|
|
||||||
<Text style={[styles.jobBlockTime, viewMode === 'week' && styles.jobBlockTimeSmall]}>
|
|
||||||
{formatTime(job.start_time)}
|
|
||||||
</Text>
|
|
||||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={[styles.jobBlockTitle, viewMode === 'week' && styles.jobBlockTitleSmall]}
|
|
||||||
numberOfLines={viewMode === 'week' ? 1 : 2}
|
|
||||||
>
|
|
||||||
{job.title}
|
|
||||||
</Text>
|
|
||||||
{viewMode === 'day' && job.customer_name && (
|
|
||||||
<Text style={styles.jobBlockCustomer} numberOfLines={1}>
|
|
||||||
{job.customer_name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{viewMode === 'day' && job.address && (
|
|
||||||
<Text style={styles.jobBlockAddress} numberOfLines={1}>
|
|
||||||
{job.address}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimelineGrid({ viewMode }: { viewMode: ViewMode }) {
|
function TimelineGrid({ viewMode }: { viewMode: ViewMode }) {
|
||||||
const hours = [];
|
const hours = [];
|
||||||
@@ -302,12 +223,16 @@ function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) {
|
|||||||
export default function JobsScreen() {
|
export default function JobsScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
const scrollRef = useRef<ScrollView>(null);
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
// Check if user can edit schedule
|
||||||
|
const canEditSchedule = user?.can_edit_schedule ?? false;
|
||||||
|
|
||||||
const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]);
|
const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]);
|
||||||
|
|
||||||
// Calculate the date range to fetch based on view mode
|
// Calculate the date range to fetch based on view mode
|
||||||
@@ -374,6 +299,24 @@ export default function JobsScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle drag-and-drop time changes
|
||||||
|
const handleTimeChange = useCallback(async (
|
||||||
|
jobId: number,
|
||||||
|
newStartTime: Date,
|
||||||
|
newEndTime: Date
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await updateAppointmentTime(jobId, {
|
||||||
|
start_time: newStartTime.toISOString(),
|
||||||
|
end_time: newEndTime.toISOString(),
|
||||||
|
});
|
||||||
|
// Invalidate queries to refresh the data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(error.response?.data?.detail || 'Failed to update appointment');
|
||||||
|
}
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
// Scroll to current time on mount
|
// Scroll to current time on mount
|
||||||
const scrollToNow = useCallback(() => {
|
const scrollToNow = useCallback(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -409,23 +352,25 @@ export default function JobsScreen() {
|
|||||||
const jobsByDay = useMemo(() => {
|
const jobsByDay = useMemo(() => {
|
||||||
if (viewMode !== 'week') return {};
|
if (viewMode !== 'week') return {};
|
||||||
|
|
||||||
const grouped: Record<number, JobWithLane[]> = {};
|
// First group raw jobs by day
|
||||||
|
const tempGrouped: Record<number, JobListItem[]> = {};
|
||||||
weekDays.forEach((_, index) => {
|
weekDays.forEach((_, index) => {
|
||||||
grouped[index] = [];
|
tempGrouped[index] = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
filteredJobs.forEach(job => {
|
filteredJobs.forEach(job => {
|
||||||
const jobDate = new Date(job.start_time);
|
const jobDate = new Date(job.start_time);
|
||||||
const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate));
|
const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate));
|
||||||
if (dayIndex !== -1) {
|
if (dayIndex !== -1) {
|
||||||
grouped[dayIndex].push(job);
|
tempGrouped[dayIndex].push(job);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate lane layout for each day
|
// Then calculate lane layout for each day
|
||||||
Object.keys(grouped).forEach(key => {
|
const grouped: Record<number, JobWithLane[]> = {};
|
||||||
|
Object.keys(tempGrouped).forEach(key => {
|
||||||
const dayIndex = parseInt(key);
|
const dayIndex = parseInt(key);
|
||||||
grouped[dayIndex] = calculateLaneLayout(grouped[dayIndex]);
|
grouped[dayIndex] = calculateLaneLayout(tempGrouped[dayIndex]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
@@ -459,6 +404,7 @@ export default function JobsScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View>
|
<View>
|
||||||
@@ -577,10 +523,12 @@ export default function JobsScreen() {
|
|||||||
]}>
|
]}>
|
||||||
{viewMode === 'day' ? (
|
{viewMode === 'day' ? (
|
||||||
dayJobsWithLanes.map((job) => (
|
dayJobsWithLanes.map((job) => (
|
||||||
<JobBlock
|
<DraggableJobBlock
|
||||||
key={job.id}
|
key={job.id}
|
||||||
job={job}
|
job={job}
|
||||||
onPress={() => handleJobPress(job.id)}
|
onPress={() => handleJobPress(job.id)}
|
||||||
|
onTimeChange={handleTimeChange}
|
||||||
|
canEdit={canEditSchedule}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
laneIndex={job.laneIndex}
|
laneIndex={job.laneIndex}
|
||||||
totalLanes={job.totalLanes}
|
totalLanes={job.totalLanes}
|
||||||
@@ -589,10 +537,12 @@ export default function JobsScreen() {
|
|||||||
) : (
|
) : (
|
||||||
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
|
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
|
||||||
jobs.map((job) => (
|
jobs.map((job) => (
|
||||||
<JobBlock
|
<DraggableJobBlock
|
||||||
key={job.id}
|
key={job.id}
|
||||||
job={job}
|
job={job}
|
||||||
onPress={() => handleJobPress(job.id)}
|
onPress={() => handleJobPress(job.id)}
|
||||||
|
onTimeChange={handleTimeChange}
|
||||||
|
canEdit={false}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
dayIndex={parseInt(dayIndex)}
|
dayIndex={parseInt(dayIndex)}
|
||||||
laneIndex={job.laneIndex}
|
laneIndex={job.laneIndex}
|
||||||
@@ -616,6 +566,7 @@ export default function JobsScreen() {
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ module.exports = function (api) {
|
|||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ['babel-preset-expo'],
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
199
mobile/field-app/package-lock.json
generated
199
mobile/field-app/package-lock.json
generated
@@ -25,7 +25,9 @@
|
|||||||
"expo-task-manager": "~14.0.9",
|
"expo-task-manager": "~14.0.9",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-maps": "1.20.1",
|
"react-native-maps": "1.20.1",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0"
|
"react-native-screens": "~4.16.0"
|
||||||
},
|
},
|
||||||
@@ -1366,6 +1368,22 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/plugin-transform-template-literals": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/plugin-transform-typescript": {
|
"node_modules/@babel/plugin-transform-typescript": {
|
||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz",
|
||||||
@@ -1513,6 +1531,18 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@egjs/hammerjs": {
|
||||||
|
"version": "2.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||||
|
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hammerjs": "^2.0.36"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
||||||
@@ -3151,6 +3181,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/hammerjs": {
|
||||||
|
"version": "2.0.46",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
||||||
|
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -3202,7 +3238,7 @@
|
|||||||
"version": "19.1.17",
|
"version": "19.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -5020,7 +5056,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
@@ -7292,6 +7328,21 @@
|
|||||||
"hermes-estree": "0.32.0"
|
"hermes-estree": "0.32.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
||||||
@@ -10022,9 +10073,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.0",
|
"version": "19.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -10040,6 +10091,19 @@
|
|||||||
"ws": "^7"
|
"ws": "^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||||
|
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-fast-compare": {
|
"node_modules/react-fast-compare": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
@@ -10121,6 +10185,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-gesture-handler": {
|
||||||
|
"version": "2.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
|
||||||
|
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@egjs/hammerjs": "^2.0.17",
|
||||||
|
"hoist-non-react-statics": "^3.3.0",
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-is-edge-to-edge": {
|
"node_modules/react-native-is-edge-to-edge": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
||||||
@@ -10153,6 +10232,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-reanimated": {
|
||||||
|
"version": "4.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
|
||||||
|
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-native-is-edge-to-edge": "^1.2.1",
|
||||||
|
"semver": "7.7.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-worklets": ">=0.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-reanimated/node_modules/semver": {
|
||||||
|
"version": "7.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
|
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-safe-area-context": {
|
"node_modules/react-native-safe-area-context": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||||
@@ -10178,6 +10285,81 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-worklets": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-KNsvR48ULg73QhTlmwPbdJLPsWcyBotrGPsrDRDswb5FYpQaJEThUKc2ncXE4UM5dn/ewLoQHjSjLaKUVPxPhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/plugin-transform-arrow-functions": "7.27.1",
|
||||||
|
"@babel/plugin-transform-class-properties": "7.27.1",
|
||||||
|
"@babel/plugin-transform-classes": "7.28.4",
|
||||||
|
"@babel/plugin-transform-nullish-coalescing-operator": "7.27.1",
|
||||||
|
"@babel/plugin-transform-optional-chaining": "7.27.1",
|
||||||
|
"@babel/plugin-transform-shorthand-properties": "7.27.1",
|
||||||
|
"@babel/plugin-transform-template-literals": "7.27.1",
|
||||||
|
"@babel/plugin-transform-unicode-regex": "7.27.1",
|
||||||
|
"@babel/preset-typescript": "7.27.1",
|
||||||
|
"convert-source-map": "2.0.0",
|
||||||
|
"semver": "7.7.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
|
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-worklets/node_modules/@babel/preset-typescript": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
|
"@babel/helper-validator-option": "^7.27.1",
|
||||||
|
"@babel/plugin-syntax-jsx": "^7.27.1",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
|
||||||
|
"@babel/plugin-transform-typescript": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-worklets/node_modules/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|
||||||
@@ -10676,6 +10858,13 @@
|
|||||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
"expo-task-manager": "~14.0.9",
|
"expo-task-manager": "~14.0.9",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-maps": "1.20.1",
|
"react-native-maps": "1.20.1",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0"
|
"react-native-screens": "~4.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -185,3 +185,44 @@ export async function rescheduleJob(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an appointment's time via PATCH to /appointments/{id}/
|
||||||
|
* Used for drag-and-drop rescheduling on the mobile timeline
|
||||||
|
*/
|
||||||
|
export async function updateAppointmentTime(
|
||||||
|
appointmentId: number,
|
||||||
|
data: { start_time: string; end_time: string }
|
||||||
|
): Promise<JobListItem> {
|
||||||
|
const token = await getAuthToken();
|
||||||
|
const userData = await getUserData();
|
||||||
|
const subdomain = userData?.business_subdomain;
|
||||||
|
const apiUrl = getAppointmentsApiUrl();
|
||||||
|
|
||||||
|
const response = await axios.patch<any>(`${apiUrl}/appointments/${appointmentId}/`, data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Token ${token}` }),
|
||||||
|
...(subdomain && { 'X-Business-Subdomain': subdomain }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform response to match JobListItem format
|
||||||
|
const apt = response.data;
|
||||||
|
const start = new Date(apt.start_time);
|
||||||
|
const end = new Date(apt.end_time);
|
||||||
|
const durationMinutes = Math.round((end.getTime() - start.getTime()) / 60000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: apt.id,
|
||||||
|
title: apt.title || apt.service_name || 'Appointment',
|
||||||
|
start_time: apt.start_time,
|
||||||
|
end_time: apt.end_time,
|
||||||
|
status: apt.status as JobStatus,
|
||||||
|
status_display: jobStatusLabels[apt.status as JobStatus] || apt.status,
|
||||||
|
customer_name: apt.customer_name || null,
|
||||||
|
address: apt.address || apt.location || null,
|
||||||
|
service_name: apt.service_name || null,
|
||||||
|
duration_minutes: durationMinutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
415
mobile/field-app/src/components/DraggableJobBlock.tsx
Normal file
415
mobile/field-app/src/components/DraggableJobBlock.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
/**
|
||||||
|
* DraggableJobBlock Component
|
||||||
|
*
|
||||||
|
* Allows drag-and-drop rescheduling and resize of appointments on the timeline.
|
||||||
|
* Features:
|
||||||
|
* - Drag to move: Changes start/end time while preserving duration
|
||||||
|
* - Drag edges to resize: Changes duration
|
||||||
|
* - 15-minute snap: All changes snap to 15-minute intervals
|
||||||
|
* - Permission check: Only allows editing if user has permission
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
runOnJS,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { getJobColor } from '../api/jobs';
|
||||||
|
import type { JobListItem } from '../types';
|
||||||
|
|
||||||
|
const HOUR_HEIGHT = 60;
|
||||||
|
const SNAP_MINUTES = 15;
|
||||||
|
const RESIZE_HANDLE_HEIGHT = 12;
|
||||||
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
|
const DAY_VIEW_WIDTH = SCREEN_WIDTH - 70;
|
||||||
|
const DAY_COLUMN_WIDTH = (SCREEN_WIDTH - 50) / 7;
|
||||||
|
|
||||||
|
// Convert pixels to minutes
|
||||||
|
function pixelsToMinutes(pixels: number): number {
|
||||||
|
return (pixels / HOUR_HEIGHT) * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert minutes to pixels
|
||||||
|
function minutesToPixels(minutes: number): number {
|
||||||
|
return (minutes / 60) * HOUR_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap to nearest 15 minutes
|
||||||
|
function snapToInterval(minutes: number): number {
|
||||||
|
return Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time for display
|
||||||
|
function formatTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggableJobBlockProps {
|
||||||
|
job: JobListItem;
|
||||||
|
onPress: () => void;
|
||||||
|
onTimeChange: (jobId: number, newStartTime: Date, newEndTime: Date) => Promise<void>;
|
||||||
|
canEdit: boolean;
|
||||||
|
viewMode: 'day' | 'week';
|
||||||
|
dayIndex?: number;
|
||||||
|
laneIndex?: number;
|
||||||
|
totalLanes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraggableJobBlock({
|
||||||
|
job,
|
||||||
|
onPress,
|
||||||
|
onTimeChange,
|
||||||
|
canEdit,
|
||||||
|
viewMode,
|
||||||
|
dayIndex = 0,
|
||||||
|
laneIndex = 0,
|
||||||
|
totalLanes = 1,
|
||||||
|
}: DraggableJobBlockProps) {
|
||||||
|
const statusColor = getJobColor(job);
|
||||||
|
|
||||||
|
const startDate = new Date(job.start_time);
|
||||||
|
const endDate = new Date(job.end_time);
|
||||||
|
|
||||||
|
const startHour = startDate.getHours() + startDate.getMinutes() / 60;
|
||||||
|
const endHour = endDate.getHours() + endDate.getMinutes() / 60;
|
||||||
|
|
||||||
|
const initialTop = startHour * HOUR_HEIGHT;
|
||||||
|
const initialHeight = Math.max((endHour - startHour) * HOUR_HEIGHT, 40);
|
||||||
|
|
||||||
|
// Shared values for animations
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
const blockHeight = useSharedValue(initialHeight);
|
||||||
|
const isActive = useSharedValue(false);
|
||||||
|
const resizeMode = useSharedValue<'none' | 'top' | 'bottom'>('none');
|
||||||
|
|
||||||
|
// Calculate width and position based on lanes
|
||||||
|
const blockStyle = useMemo(() => {
|
||||||
|
if (viewMode === 'week') {
|
||||||
|
const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes;
|
||||||
|
return {
|
||||||
|
left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth,
|
||||||
|
width: laneWidth - 2,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const laneWidth = DAY_VIEW_WIDTH / totalLanes;
|
||||||
|
return {
|
||||||
|
left: laneIndex * laneWidth,
|
||||||
|
width: laneWidth - 4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [viewMode, dayIndex, laneIndex, totalLanes]);
|
||||||
|
|
||||||
|
// Calculate new times from current position
|
||||||
|
const calculateNewTimes = useCallback((
|
||||||
|
deltaY: number,
|
||||||
|
heightDelta: number,
|
||||||
|
mode: 'none' | 'top' | 'bottom'
|
||||||
|
): { newStart: Date; newEnd: Date } => {
|
||||||
|
const deltaMinutes = pixelsToMinutes(deltaY);
|
||||||
|
const heightDeltaMinutes = pixelsToMinutes(heightDelta);
|
||||||
|
|
||||||
|
let newStartMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
||||||
|
let newEndMinutes = endDate.getHours() * 60 + endDate.getMinutes();
|
||||||
|
|
||||||
|
if (mode === 'none') {
|
||||||
|
// Moving the whole block
|
||||||
|
const snappedDelta = snapToInterval(deltaMinutes);
|
||||||
|
newStartMinutes += snappedDelta;
|
||||||
|
newEndMinutes += snappedDelta;
|
||||||
|
} else if (mode === 'top') {
|
||||||
|
// Resizing from top
|
||||||
|
const snappedDelta = snapToInterval(deltaMinutes);
|
||||||
|
newStartMinutes += snappedDelta;
|
||||||
|
// Minimum 15 minutes duration
|
||||||
|
if (newEndMinutes - newStartMinutes < SNAP_MINUTES) {
|
||||||
|
newStartMinutes = newEndMinutes - SNAP_MINUTES;
|
||||||
|
}
|
||||||
|
} else if (mode === 'bottom') {
|
||||||
|
// Resizing from bottom
|
||||||
|
const snappedDelta = snapToInterval(heightDeltaMinutes);
|
||||||
|
newEndMinutes = (startDate.getHours() * 60 + startDate.getMinutes()) +
|
||||||
|
snapToInterval((endHour - startHour) * 60 + heightDeltaMinutes);
|
||||||
|
// Minimum 15 minutes duration
|
||||||
|
if (newEndMinutes - newStartMinutes < SNAP_MINUTES) {
|
||||||
|
newEndMinutes = newStartMinutes + SNAP_MINUTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to valid day range (0:00 - 23:59)
|
||||||
|
newStartMinutes = Math.max(0, Math.min(newStartMinutes, 24 * 60 - SNAP_MINUTES));
|
||||||
|
newEndMinutes = Math.max(SNAP_MINUTES, Math.min(newEndMinutes, 24 * 60));
|
||||||
|
|
||||||
|
const newStart = new Date(startDate);
|
||||||
|
newStart.setHours(Math.floor(newStartMinutes / 60), newStartMinutes % 60, 0, 0);
|
||||||
|
|
||||||
|
const newEnd = new Date(endDate);
|
||||||
|
newEnd.setHours(Math.floor(newEndMinutes / 60), newEndMinutes % 60, 0, 0);
|
||||||
|
|
||||||
|
return { newStart, newEnd };
|
||||||
|
}, [startDate, endDate, startHour, endHour]);
|
||||||
|
|
||||||
|
// Handle the end of a gesture
|
||||||
|
const handleGestureEnd = useCallback(async (
|
||||||
|
deltaY: number,
|
||||||
|
heightDelta: number,
|
||||||
|
mode: 'none' | 'top' | 'bottom'
|
||||||
|
) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
|
||||||
|
const { newStart, newEnd } = calculateNewTimes(deltaY, heightDelta, mode);
|
||||||
|
|
||||||
|
// Check if times actually changed
|
||||||
|
if (newStart.getTime() === startDate.getTime() && newEnd.getTime() === endDate.getTime()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onTimeChange(job.id, newStart, newEnd);
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update appointment time');
|
||||||
|
}
|
||||||
|
}, [canEdit, calculateNewTimes, startDate, endDate, job.id, onTimeChange]);
|
||||||
|
|
||||||
|
// Main drag gesture (move the whole block)
|
||||||
|
const dragGesture = Gesture.Pan()
|
||||||
|
.enabled(canEdit)
|
||||||
|
.onStart(() => {
|
||||||
|
isActive.value = true;
|
||||||
|
resizeMode.value = 'none';
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
if (resizeMode.value === 'none') {
|
||||||
|
// Snap to 15-minute intervals while dragging
|
||||||
|
const snappedY = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY)));
|
||||||
|
translateY.value = snappedY;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
isActive.value = false;
|
||||||
|
const finalY = translateY.value;
|
||||||
|
translateY.value = withSpring(0, { damping: 20 });
|
||||||
|
runOnJS(handleGestureEnd)(finalY, 0, 'none');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top resize gesture
|
||||||
|
const topResizeGesture = Gesture.Pan()
|
||||||
|
.enabled(canEdit)
|
||||||
|
.onStart(() => {
|
||||||
|
isActive.value = true;
|
||||||
|
resizeMode.value = 'top';
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
const snappedY = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY)));
|
||||||
|
translateY.value = snappedY;
|
||||||
|
// Height decreases as top moves down
|
||||||
|
const newHeight = Math.max(initialHeight - snappedY, minutesToPixels(SNAP_MINUTES));
|
||||||
|
blockHeight.value = newHeight;
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
isActive.value = false;
|
||||||
|
const finalY = translateY.value;
|
||||||
|
translateY.value = withSpring(0, { damping: 20 });
|
||||||
|
blockHeight.value = withSpring(initialHeight, { damping: 20 });
|
||||||
|
runOnJS(handleGestureEnd)(finalY, 0, 'top');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bottom resize gesture
|
||||||
|
const bottomResizeGesture = Gesture.Pan()
|
||||||
|
.enabled(canEdit)
|
||||||
|
.onStart(() => {
|
||||||
|
isActive.value = true;
|
||||||
|
resizeMode.value = 'bottom';
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
const snappedDelta = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY)));
|
||||||
|
const newHeight = Math.max(initialHeight + snappedDelta, minutesToPixels(SNAP_MINUTES));
|
||||||
|
blockHeight.value = newHeight;
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
isActive.value = false;
|
||||||
|
const heightDelta = blockHeight.value - initialHeight;
|
||||||
|
blockHeight.value = withSpring(initialHeight, { damping: 20 });
|
||||||
|
runOnJS(handleGestureEnd)(0, heightDelta, 'bottom');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tap gesture for navigation
|
||||||
|
const tapGesture = Gesture.Tap()
|
||||||
|
.onEnd(() => {
|
||||||
|
runOnJS(onPress)();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine gestures
|
||||||
|
const composedGesture = Gesture.Race(
|
||||||
|
tapGesture,
|
||||||
|
dragGesture
|
||||||
|
);
|
||||||
|
|
||||||
|
// Animated styles
|
||||||
|
const animatedBlockStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
height: blockHeight.value,
|
||||||
|
opacity: isActive.value ? 0.8 : 1,
|
||||||
|
zIndex: isActive.value ? 100 : 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={composedGesture}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.jobBlock,
|
||||||
|
{
|
||||||
|
top: initialTop,
|
||||||
|
borderLeftColor: statusColor,
|
||||||
|
...blockStyle,
|
||||||
|
},
|
||||||
|
animatedBlockStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Top resize handle */}
|
||||||
|
{canEdit && (
|
||||||
|
<GestureDetector gesture={topResizeGesture}>
|
||||||
|
<View style={styles.resizeHandleTop}>
|
||||||
|
<View style={styles.resizeBar} />
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.jobBlockHeader}>
|
||||||
|
<Text style={[styles.jobBlockTime, viewMode === 'week' && styles.jobBlockTimeSmall]}>
|
||||||
|
{formatTime(job.start_time)}
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[styles.jobBlockTitle, viewMode === 'week' && styles.jobBlockTitleSmall]}
|
||||||
|
numberOfLines={viewMode === 'week' ? 1 : 2}
|
||||||
|
>
|
||||||
|
{job.title}
|
||||||
|
</Text>
|
||||||
|
{viewMode === 'day' && job.customer_name && (
|
||||||
|
<Text style={styles.jobBlockCustomer} numberOfLines={1}>
|
||||||
|
{job.customer_name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{viewMode === 'day' && job.address && (
|
||||||
|
<Text style={styles.jobBlockAddress} numberOfLines={1}>
|
||||||
|
{job.address}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom resize handle */}
|
||||||
|
{canEdit && (
|
||||||
|
<GestureDetector gesture={bottomResizeGesture}>
|
||||||
|
<View style={styles.resizeHandleBottom}>
|
||||||
|
<View style={styles.resizeBar} />
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
jobBlock: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 6,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
overflow: 'visible',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 6,
|
||||||
|
paddingTop: RESIZE_HANDLE_HEIGHT / 2,
|
||||||
|
paddingBottom: RESIZE_HANDLE_HEIGHT / 2,
|
||||||
|
},
|
||||||
|
resizeHandleTop: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: RESIZE_HANDLE_HEIGHT,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
resizeHandleBottom: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: RESIZE_HANDLE_HEIGHT,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
resizeBar: {
|
||||||
|
width: 30,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: '#d1d5db',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
jobBlockHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
jobBlockTime: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#6b7280',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
jobBlockTimeSmall: {
|
||||||
|
fontSize: 9,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
jobBlockTitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
jobBlockTitleSmall: {
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
jobBlockCustomer: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#4b5563',
|
||||||
|
},
|
||||||
|
jobBlockAddress: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#9ca3af',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DraggableJobBlock;
|
||||||
@@ -12,6 +12,7 @@ export interface User {
|
|||||||
business_name?: string;
|
business_name?: string;
|
||||||
business_subdomain?: string;
|
business_subdomain?: string;
|
||||||
can_use_masked_calls?: boolean;
|
can_use_masked_calls?: boolean;
|
||||||
|
can_edit_schedule?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# General
|
# General
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
USE_DOCKER=yes
|
USE_DOCKER=yes
|
||||||
|
DJANGO_CORS_ALLOW_ALL_ORIGINS=True
|
||||||
IPYTHONDIR=/app/.ipython
|
IPYTHONDIR=/app/.ipython
|
||||||
# Redis
|
# Redis
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,18 +10,19 @@ django_asgi_app = get_asgi_application()
|
|||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
|
||||||
from tickets import routing as tickets_routing # Assuming we'll have tickets routing
|
from tickets import routing as tickets_routing
|
||||||
|
from schedule import routing as schedule_routing
|
||||||
from tickets.middleware import TokenAuthMiddleware
|
from tickets.middleware import TokenAuthMiddleware
|
||||||
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
application = ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
"http": django_asgi_app,
|
"http": django_asgi_app,
|
||||||
# Just HTTP for now. (We can add other protocols later.)
|
|
||||||
"websocket": AuthMiddlewareStack(
|
"websocket": AuthMiddlewareStack(
|
||||||
TokenAuthMiddleware(
|
TokenAuthMiddleware(
|
||||||
URLRouter(
|
URLRouter(
|
||||||
tickets_routing.websocket_urlpatterns # Include ticket-specific WebSocket routes
|
tickets_routing.websocket_urlpatterns +
|
||||||
|
schedule_routing.websocket_urlpatterns
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ LOCAL_APPS = [
|
|||||||
"notifications", # New: Generic notification app
|
"notifications", # New: Generic notification app
|
||||||
"tickets", # New: Support tickets app
|
"tickets", # New: Support tickets app
|
||||||
"smoothschedule.comms_credits", # Communication credits and SMS/calling
|
"smoothschedule.comms_credits", # Communication credits and SMS/calling
|
||||||
|
"smoothschedule.field_mobile", # Field employee mobile app
|
||||||
# Your stuff: custom apps go here
|
# Your stuff: custom apps go here
|
||||||
]
|
]
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ SECRET_KEY = env(
|
|||||||
default="JETIHIJaLl2niIyj134Crg2S2dTURSzyXtd02XPicYcjaK5lJb1otLmNHqs6ZVs0",
|
default="JETIHIJaLl2niIyj134Crg2S2dTURSzyXtd02XPicYcjaK5lJb1otLmNHqs6ZVs0",
|
||||||
)
|
)
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||||
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me"] # noqa: S104
|
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me", "10.0.1.242"] # noqa: S104
|
||||||
|
|
||||||
# CORS and CSRF are configured in base.py with environment variable overrides
|
# CORS and CSRF are configured in base.py with environment variable overrides
|
||||||
# Local development uses the .env file to set DJANGO_CORS_ALLOWED_ORIGINS
|
# Local development uses the .env file to set DJANGO_CORS_ALLOWED_ORIGINS
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ SHARED_APPS = [
|
|||||||
'notifications', # Notification system - shared for platform to notify tenants
|
'notifications', # Notification system - shared for platform to notify tenants
|
||||||
'smoothschedule.public_api', # Public API v1 for third-party integrations
|
'smoothschedule.public_api', # Public API v1 for third-party integrations
|
||||||
'smoothschedule.comms_credits', # Communication credits (SMS/calling) - shared for billing
|
'smoothschedule.comms_credits', # Communication credits (SMS/calling) - shared for billing
|
||||||
|
'smoothschedule.field_mobile', # Field employee mobile app - shared for location tracking
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ urlpatterns = [
|
|||||||
|
|
||||||
# API URLS
|
# API URLS
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
|
# Staff Invitations API - MUST come before schedule.urls to avoid conflict with /staff/ viewset
|
||||||
|
path("staff/invitations/", staff_invitations_view, name="staff_invitations"),
|
||||||
|
path("staff/invitations/<int:invitation_id>/", cancel_invitation_view, name="cancel_invitation"),
|
||||||
|
path("staff/invitations/<int:invitation_id>/resend/", resend_invitation_view, name="resend_invitation"),
|
||||||
|
path("staff/invitations/token/<str:token>/", invitation_details_view, name="invitation_details"),
|
||||||
|
path("staff/invitations/token/<str:token>/accept/", accept_invitation_view, name="accept_invitation"),
|
||||||
|
path("staff/invitations/token/<str:token>/decline/", decline_invitation_view, name="decline_invitation"),
|
||||||
# Stripe Webhooks (dj-stripe built-in handler)
|
# Stripe Webhooks (dj-stripe built-in handler)
|
||||||
path("stripe/", include("djstripe.urls", namespace="djstripe")),
|
path("stripe/", include("djstripe.urls", namespace="djstripe")),
|
||||||
# Public API v1 (for third-party integrations)
|
# Public API v1 (for third-party integrations)
|
||||||
@@ -82,6 +89,8 @@ urlpatterns += [
|
|||||||
path("contracts/", include("contracts.urls")),
|
path("contracts/", include("contracts.urls")),
|
||||||
# Communication Credits API
|
# Communication Credits API
|
||||||
path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
|
path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
|
||||||
|
# Field Mobile API (for field employee mobile app)
|
||||||
|
path("mobile/", include("smoothschedule.field_mobile.urls", namespace="field_mobile")),
|
||||||
# Tickets API
|
# Tickets API
|
||||||
path("tickets/", include("tickets.urls")),
|
path("tickets/", include("tickets.urls")),
|
||||||
# Notifications API
|
# Notifications API
|
||||||
@@ -103,13 +112,6 @@ urlpatterns += [
|
|||||||
# Hijack (masquerade) API
|
# Hijack (masquerade) API
|
||||||
path("auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
path("auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
||||||
path("auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
path("auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
||||||
# Staff Invitations API
|
|
||||||
path("staff/invitations/", staff_invitations_view, name="staff_invitations"),
|
|
||||||
path("staff/invitations/<int:invitation_id>/", cancel_invitation_view, name="cancel_invitation"),
|
|
||||||
path("staff/invitations/<int:invitation_id>/resend/", resend_invitation_view, name="resend_invitation"),
|
|
||||||
path("staff/invitations/token/<str:token>/", invitation_details_view, name="invitation_details"),
|
|
||||||
path("staff/invitations/token/<str:token>/accept/", accept_invitation_view, name="accept_invitation"),
|
|
||||||
path("staff/invitations/token/<str:token>/decline/", decline_invitation_view, name="decline_invitation"),
|
|
||||||
# Business API
|
# Business API
|
||||||
path("business/current/", current_business_view, name="current_business"),
|
path("business/current/", current_business_view, name="current_business"),
|
||||||
path("business/current/update/", update_business_view, name="update_business"),
|
path("business/current/update/", update_business_view, name="update_business"),
|
||||||
|
|||||||
@@ -25,16 +25,27 @@ class TenantHeaderMiddleware(MiddlewareMixin):
|
|||||||
subdomain = request.META.get('HTTP_X_BUSINESS_SUBDOMAIN')
|
subdomain = request.META.get('HTTP_X_BUSINESS_SUBDOMAIN')
|
||||||
if subdomain:
|
if subdomain:
|
||||||
Tenant = get_tenant_model()
|
Tenant = get_tenant_model()
|
||||||
|
tenant = None
|
||||||
|
|
||||||
|
# First try by schema_name (for backwards compatibility)
|
||||||
try:
|
try:
|
||||||
tenant = Tenant.objects.get(schema_name=subdomain)
|
tenant = Tenant.objects.get(schema_name=subdomain)
|
||||||
# Only switch if different from current tenant (which might be 'public')
|
|
||||||
if request.tenant.schema_name != tenant.schema_name:
|
|
||||||
request.tenant = tenant
|
|
||||||
connection.set_tenant(request.tenant)
|
|
||||||
# sandbox_logger.debug(f"Switched to tenant '{subdomain}' via header")
|
|
||||||
except Tenant.DoesNotExist:
|
except Tenant.DoesNotExist:
|
||||||
# Invalid subdomain in header - ignore or could raise 400
|
# Try looking up by domain (subdomain matching)
|
||||||
pass
|
from django_tenants.models import DomainMixin
|
||||||
|
from django.apps import apps
|
||||||
|
Domain = apps.get_model('tenants', 'Domain')
|
||||||
|
try:
|
||||||
|
# Look for domain that starts with the subdomain
|
||||||
|
domain_obj = Domain.objects.filter(domain__startswith=f"{subdomain}.").first()
|
||||||
|
if domain_obj:
|
||||||
|
tenant = domain_obj.tenant
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if tenant and request.tenant.schema_name != tenant.schema_name:
|
||||||
|
request.tenant = tenant
|
||||||
|
connection.set_tenant(request.tenant)
|
||||||
|
|
||||||
|
|
||||||
class SandboxModeMiddleware(MiddlewareMixin):
|
class SandboxModeMiddleware(MiddlewareMixin):
|
||||||
|
|||||||
450
smoothschedule/schedule/consumers.py
Normal file
450
smoothschedule/schedule/consumers.py
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
"""
|
||||||
|
WebSocket consumers for real-time calendar updates.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- Mobile app for field employees to get job updates
|
||||||
|
- Web frontend for real-time calendar sync
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""
|
||||||
|
WebSocket consumer for real-time calendar/job updates.
|
||||||
|
|
||||||
|
Groups:
|
||||||
|
- calendar_{tenant_schema}: All calendar updates for a tenant
|
||||||
|
- employee_jobs_{user_id}: Jobs assigned to a specific employee
|
||||||
|
- event_{event_id}: Updates for a specific event
|
||||||
|
|
||||||
|
Message types:
|
||||||
|
- event_created: New event was created
|
||||||
|
- event_updated: Event details changed (time, status, etc.)
|
||||||
|
- event_deleted: Event was deleted
|
||||||
|
- job_assigned: Job was assigned to this employee
|
||||||
|
- job_unassigned: Job was unassigned from this employee
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Handle WebSocket connection."""
|
||||||
|
user = self.scope.get("user")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"CalendarConsumer Connect: User={user}, "
|
||||||
|
f"Auth={user.is_authenticated if user else False}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
logger.warning("CalendarConsumer: Rejecting unauthenticated connection")
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store user for later use
|
||||||
|
self.user = user
|
||||||
|
self.groups = []
|
||||||
|
|
||||||
|
# Add to user's personal job updates group
|
||||||
|
self.user_group = f"employee_jobs_{user.id}"
|
||||||
|
await self.channel_layer.group_add(self.user_group, self.channel_name)
|
||||||
|
self.groups.append(self.user_group)
|
||||||
|
|
||||||
|
# Add to tenant group if user has a tenant
|
||||||
|
# Use sync_to_async for database access (tenant is a ForeignKey)
|
||||||
|
tenant = await self._get_user_tenant(user)
|
||||||
|
if tenant:
|
||||||
|
self.tenant_group = f"calendar_{tenant.schema_name}"
|
||||||
|
await self.channel_layer.group_add(self.tenant_group, self.channel_name)
|
||||||
|
self.groups.append(self.tenant_group)
|
||||||
|
logger.info(f"CalendarConsumer: User {user.id} joined tenant group {self.tenant_group}")
|
||||||
|
else:
|
||||||
|
self.tenant_group = None
|
||||||
|
|
||||||
|
await self.accept()
|
||||||
|
logger.info(f"CalendarConsumer: Connection accepted for user {user.id}")
|
||||||
|
|
||||||
|
# Send initial connection confirmation
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'connection_established',
|
||||||
|
'user_id': user.id,
|
||||||
|
'groups': self.groups,
|
||||||
|
}))
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _get_user_tenant(self, user):
|
||||||
|
"""Get user's tenant in a sync context."""
|
||||||
|
try:
|
||||||
|
# Force refresh from database to get tenant
|
||||||
|
user.refresh_from_db()
|
||||||
|
return user.tenant
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
"""Handle WebSocket disconnection."""
|
||||||
|
logger.info(f"CalendarConsumer: Disconnecting, code={close_code}")
|
||||||
|
|
||||||
|
# Remove from all groups
|
||||||
|
for group in getattr(self, 'groups', []):
|
||||||
|
await self.channel_layer.group_discard(group, self.channel_name)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
"""
|
||||||
|
Handle incoming messages from client.
|
||||||
|
|
||||||
|
Supported messages:
|
||||||
|
- subscribe_event: Subscribe to updates for a specific event
|
||||||
|
- unsubscribe_event: Unsubscribe from event updates
|
||||||
|
- ping: Keep-alive ping (responds with pong)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(text_data)
|
||||||
|
message_type = data.get('type')
|
||||||
|
|
||||||
|
if message_type == 'subscribe_event':
|
||||||
|
event_id = data.get('event_id')
|
||||||
|
if event_id:
|
||||||
|
group_name = f"event_{event_id}"
|
||||||
|
await self.channel_layer.group_add(group_name, self.channel_name)
|
||||||
|
self.groups.append(group_name)
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'subscribed',
|
||||||
|
'event_id': event_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
elif message_type == 'unsubscribe_event':
|
||||||
|
event_id = data.get('event_id')
|
||||||
|
if event_id:
|
||||||
|
group_name = f"event_{event_id}"
|
||||||
|
await self.channel_layer.group_discard(group_name, self.channel_name)
|
||||||
|
if group_name in self.groups:
|
||||||
|
self.groups.remove(group_name)
|
||||||
|
|
||||||
|
elif message_type == 'ping':
|
||||||
|
await self.send(text_data=json.dumps({'type': 'pong'}))
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"CalendarConsumer: Invalid JSON received: {text_data}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"CalendarConsumer: Error processing message: {e}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Event handlers - called when messages are sent to groups
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def event_created(self, event):
|
||||||
|
"""Handle new event creation."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'event_created',
|
||||||
|
'event': event.get('event'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def event_updated(self, event):
|
||||||
|
"""Handle event update."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'event_updated',
|
||||||
|
'event': event.get('event'),
|
||||||
|
'changed_fields': event.get('changed_fields', []),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def event_deleted(self, event):
|
||||||
|
"""Handle event deletion."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'event_deleted',
|
||||||
|
'event_id': event.get('event_id'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def event_status_changed(self, event):
|
||||||
|
"""Handle status change (common for mobile app)."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'event_status_changed',
|
||||||
|
'event_id': event.get('event_id'),
|
||||||
|
'old_status': event.get('old_status'),
|
||||||
|
'new_status': event.get('new_status'),
|
||||||
|
'event': event.get('event'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def job_assigned(self, event):
|
||||||
|
"""Handle job assignment to employee."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'job_assigned',
|
||||||
|
'event': event.get('event'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def job_unassigned(self, event):
|
||||||
|
"""Handle job unassignment from employee."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'job_unassigned',
|
||||||
|
'event_id': event.get('event_id'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper functions to broadcast updates
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_event_broadcast_data(event):
|
||||||
|
"""
|
||||||
|
Serialize an Event for WebSocket broadcast.
|
||||||
|
|
||||||
|
Returns a dict suitable for JSON serialization.
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'id': event.id,
|
||||||
|
'title': event.title,
|
||||||
|
'start_time': event.start_time.isoformat() if event.start_time else None,
|
||||||
|
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||||
|
'status': event.status,
|
||||||
|
'notes': event.notes,
|
||||||
|
'created_at': event.created_at.isoformat() if event.created_at else None,
|
||||||
|
'updated_at': event.updated_at.isoformat() if event.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add service info if available
|
||||||
|
if event.service:
|
||||||
|
data['service'] = {
|
||||||
|
'id': event.service.id,
|
||||||
|
'name': event.service.name,
|
||||||
|
'duration': event.service.duration,
|
||||||
|
'price': str(event.service.price),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add pricing info
|
||||||
|
if event.deposit_amount:
|
||||||
|
data['deposit_amount'] = str(event.deposit_amount)
|
||||||
|
if event.final_price:
|
||||||
|
data['final_price'] = str(event.final_price)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_staff_user_ids(event):
|
||||||
|
"""
|
||||||
|
Get user IDs of staff assigned to an event.
|
||||||
|
|
||||||
|
Returns list of user IDs for broadcasting.
|
||||||
|
"""
|
||||||
|
from schedule.models import Participant, Resource
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
user_ids = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
resource_ct = ContentType.objects.get_for_model(Resource)
|
||||||
|
|
||||||
|
# Get direct user participants (staff role)
|
||||||
|
for participant in event.participants.filter(
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE],
|
||||||
|
content_type=user_ct
|
||||||
|
):
|
||||||
|
if participant.object_id:
|
||||||
|
user_ids.add(participant.object_id)
|
||||||
|
|
||||||
|
# Get users linked to resource participants
|
||||||
|
for participant in event.participants.filter(
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE],
|
||||||
|
content_type=resource_ct
|
||||||
|
):
|
||||||
|
resource = participant.content_object
|
||||||
|
if resource and resource.user_id:
|
||||||
|
user_ids.add(resource.user_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting staff user IDs for event {event.id}: {e}")
|
||||||
|
|
||||||
|
return list(user_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_event_update(event, update_type='event_updated', changed_fields=None, old_status=None):
|
||||||
|
"""
|
||||||
|
Broadcast an event update to all relevant WebSocket groups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The Event instance
|
||||||
|
update_type: One of 'event_created', 'event_updated', 'event_deleted', 'event_status_changed'
|
||||||
|
changed_fields: List of field names that changed (for event_updated)
|
||||||
|
old_status: Previous status (for event_status_changed)
|
||||||
|
"""
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
if not channel_layer:
|
||||||
|
logger.warning("No channel layer configured, skipping WebSocket broadcast")
|
||||||
|
return
|
||||||
|
|
||||||
|
event_data = await sync_to_async(get_event_broadcast_data)(event)
|
||||||
|
staff_user_ids = await sync_to_async(get_event_staff_user_ids)(event)
|
||||||
|
|
||||||
|
# Get tenant schema for group name
|
||||||
|
tenant_schema = connection.schema_name if hasattr(connection, 'schema_name') else 'public'
|
||||||
|
|
||||||
|
message = {
|
||||||
|
'type': update_type,
|
||||||
|
'event': event_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed_fields:
|
||||||
|
message['changed_fields'] = changed_fields
|
||||||
|
|
||||||
|
if old_status and update_type == 'event_status_changed':
|
||||||
|
message['old_status'] = old_status
|
||||||
|
message['new_status'] = event.status
|
||||||
|
message['event_id'] = event.id
|
||||||
|
|
||||||
|
if update_type == 'event_deleted':
|
||||||
|
message['event_id'] = event.id
|
||||||
|
|
||||||
|
# Broadcast to tenant group
|
||||||
|
if tenant_schema != 'public':
|
||||||
|
await channel_layer.group_send(
|
||||||
|
f"calendar_{tenant_schema}",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast to individual employee groups
|
||||||
|
for user_id in staff_user_ids:
|
||||||
|
await channel_layer.group_send(
|
||||||
|
f"employee_jobs_{user_id}",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast to event-specific group
|
||||||
|
await channel_layer.group_send(
|
||||||
|
f"event_{event.id}",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Broadcast {update_type} for event {event.id} to "
|
||||||
|
f"tenant={tenant_schema}, staff={staff_user_ids}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceLocationConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""
|
||||||
|
WebSocket consumer for real-time resource location tracking.
|
||||||
|
|
||||||
|
Used by web dashboard to show live staff location on map while en route.
|
||||||
|
|
||||||
|
Groups:
|
||||||
|
- resource_location_{resource_id}: Location updates for a specific resource
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Handle WebSocket connection."""
|
||||||
|
user = self.scope.get("user")
|
||||||
|
self.resource_id = self.scope['url_route']['kwargs'].get('resource_id')
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"ResourceLocationConsumer Connect: User={user}, Resource={self.resource_id}, "
|
||||||
|
f"Auth={user.is_authenticated if user else False}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
logger.warning("ResourceLocationConsumer: Rejecting unauthenticated connection")
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.resource_id:
|
||||||
|
logger.warning("ResourceLocationConsumer: No resource_id provided")
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store user for later use
|
||||||
|
self.user = user
|
||||||
|
self.group_name = f"resource_location_{self.resource_id}"
|
||||||
|
|
||||||
|
# Add to resource location group
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
logger.info(f"ResourceLocationConsumer: Connection accepted for resource {self.resource_id}")
|
||||||
|
|
||||||
|
# Send initial connection confirmation
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'connection_established',
|
||||||
|
'resource_id': self.resource_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
"""Handle WebSocket disconnection."""
|
||||||
|
logger.info(f"ResourceLocationConsumer: Disconnecting resource {getattr(self, 'resource_id', 'unknown')}, code={close_code}")
|
||||||
|
|
||||||
|
if hasattr(self, 'group_name'):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
"""Handle incoming messages from client (ping/pong only)."""
|
||||||
|
try:
|
||||||
|
data = json.loads(text_data)
|
||||||
|
message_type = data.get('type')
|
||||||
|
|
||||||
|
if message_type == 'ping':
|
||||||
|
await self.send(text_data=json.dumps({'type': 'pong'}))
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"ResourceLocationConsumer: Invalid JSON received: {text_data}")
|
||||||
|
|
||||||
|
async def location_update(self, event):
|
||||||
|
"""Handle location update broadcast."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'location_update',
|
||||||
|
'latitude': event.get('latitude'),
|
||||||
|
'longitude': event.get('longitude'),
|
||||||
|
'accuracy': event.get('accuracy'),
|
||||||
|
'heading': event.get('heading'),
|
||||||
|
'speed': event.get('speed'),
|
||||||
|
'timestamp': event.get('timestamp'),
|
||||||
|
'active_job': event.get('active_job'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def tracking_stopped(self, event):
|
||||||
|
"""Handle tracking stopped notification."""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'tracking_stopped',
|
||||||
|
'resource_id': event.get('resource_id'),
|
||||||
|
'reason': event.get('reason'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_resource_location_update(resource_id, location_data, active_job=None):
|
||||||
|
"""
|
||||||
|
Broadcast a location update to all connected clients watching this resource.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource_id: The resource ID to broadcast to
|
||||||
|
location_data: Dict with latitude, longitude, accuracy, heading, speed, timestamp
|
||||||
|
active_job: Optional dict with id, title, status, status_display
|
||||||
|
"""
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
if not channel_layer:
|
||||||
|
logger.warning("No channel layer configured, skipping location broadcast")
|
||||||
|
return
|
||||||
|
|
||||||
|
message = {
|
||||||
|
'type': 'location_update',
|
||||||
|
'latitude': location_data.get('latitude'),
|
||||||
|
'longitude': location_data.get('longitude'),
|
||||||
|
'accuracy': location_data.get('accuracy'),
|
||||||
|
'heading': location_data.get('heading'),
|
||||||
|
'speed': location_data.get('speed'),
|
||||||
|
'timestamp': location_data.get('timestamp'),
|
||||||
|
'active_job': active_job,
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel_layer.group_send(
|
||||||
|
f"resource_location_{resource_id}",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Broadcast location update for resource {resource_id}")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-07 02:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0028_add_timeblock_and_holiday'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='resource',
|
||||||
|
name='user_can_edit_schedule',
|
||||||
|
field=models.BooleanField(default=False, help_text="Allow the resource's linked user to edit their own schedule (reschedule/resize) regardless of role"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='event',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('SCHEDULED', 'Scheduled'), ('EN_ROUTE', 'En Route'), ('IN_PROGRESS', 'In Progress'), ('CANCELED', 'Canceled'), ('COMPLETED', 'Completed'), ('AWAITING_PAYMENT', 'Awaiting Payment'), ('PAID', 'Paid'), ('NOSHOW', 'No Show')], db_index=True, default='SCHEDULED', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -230,6 +230,12 @@ class Resource(models.Model):
|
|||||||
help_text="When this resource was archived due to quota overage"
|
help_text="When this resource was archived due to quota overage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Permission for linked user to edit their own schedule
|
||||||
|
user_can_edit_schedule = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Allow the resource's linked user to edit their own schedule (reschedule/resize) regardless of role"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
indexes = [models.Index(fields=['is_active', 'name'])]
|
indexes = [models.Index(fields=['is_active', 'name'])]
|
||||||
@@ -246,6 +252,8 @@ class Event(models.Model):
|
|||||||
"""
|
"""
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
||||||
|
EN_ROUTE = 'EN_ROUTE', 'En Route' # Employee is traveling to job site
|
||||||
|
IN_PROGRESS = 'IN_PROGRESS', 'In Progress' # Employee is working on job
|
||||||
CANCELED = 'CANCELED', 'Canceled'
|
CANCELED = 'CANCELED', 'Canceled'
|
||||||
COMPLETED = 'COMPLETED', 'Completed'
|
COMPLETED = 'COMPLETED', 'Completed'
|
||||||
AWAITING_PAYMENT = 'AWAITING_PAYMENT', 'Awaiting Payment' # Service done, waiting for final charge
|
AWAITING_PAYMENT = 'AWAITING_PAYMENT', 'Awaiting Payment' # Service done, waiting for final charge
|
||||||
|
|||||||
13
smoothschedule/schedule/routing.py
Normal file
13
smoothschedule/schedule/routing.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
WebSocket URL routing for the schedule app.
|
||||||
|
"""
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
# Calendar updates for web and mobile apps
|
||||||
|
re_path(r"^/?ws/calendar/?$", consumers.CalendarConsumer.as_asgi()),
|
||||||
|
# Resource location tracking for web dashboard
|
||||||
|
re_path(r"^/?ws/resource-location/(?P<resource_id>\d+)/?$", consumers.ResourceLocationConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -203,7 +203,7 @@ class ResourceSerializer(serializers.ModelSerializer):
|
|||||||
'description', 'max_concurrent_events',
|
'description', 'max_concurrent_events',
|
||||||
'buffer_duration', 'is_active', 'capacity_description',
|
'buffer_duration', 'is_active', 'capacity_description',
|
||||||
'saved_lane_count', 'created_at', 'updated_at',
|
'saved_lane_count', 'created_at', 'updated_at',
|
||||||
'is_archived_by_quota',
|
'is_archived_by_quota', 'user_can_edit_schedule',
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota']
|
read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota']
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Handles:
|
|||||||
2. Rescheduling Celery tasks when events are modified (time/duration changes)
|
2. Rescheduling Celery tasks when events are modified (time/duration changes)
|
||||||
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
|
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
|
||||||
4. Cancelling tasks when Events are deleted or cancelled
|
4. Cancelling tasks when Events are deleted or cancelled
|
||||||
|
5. Broadcasting real-time updates via WebSocket for calendar sync
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
|
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
|
||||||
@@ -14,6 +15,36 @@ from django.dispatch import receiver
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WebSocket Broadcasting Helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def broadcast_event_change_sync(event, update_type, changed_fields=None, old_status=None):
|
||||||
|
"""
|
||||||
|
Synchronous wrapper to broadcast event changes via WebSocket.
|
||||||
|
|
||||||
|
Uses async_to_sync to call the async broadcast function from signals.
|
||||||
|
"""
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
if not channel_layer:
|
||||||
|
logger.debug("No channel layer configured, skipping WebSocket broadcast")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .consumers import broadcast_event_update
|
||||||
|
async_to_sync(broadcast_event_update)(
|
||||||
|
event,
|
||||||
|
update_type=update_type,
|
||||||
|
changed_fields=changed_fields,
|
||||||
|
old_status=old_status
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to broadcast event change: {e}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender='schedule.Event')
|
@receiver(post_save, sender='schedule.Event')
|
||||||
def auto_attach_global_plugins(sender, instance, created, **kwargs):
|
def auto_attach_global_plugins(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -313,3 +344,61 @@ def cancel_event_tasks_on_cancel(sender, instance, created, **kwargs):
|
|||||||
from .tasks import cancel_event_tasks
|
from .tasks import cancel_event_tasks
|
||||||
logger.info(f"Event '{instance}' was cancelled, cancelling all plugin tasks")
|
logger.info(f"Event '{instance}' was cancelled, cancelling all plugin tasks")
|
||||||
cancel_event_tasks(instance.id)
|
cancel_event_tasks(instance.id)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WebSocket Broadcasting Signals
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@receiver(post_save, sender='schedule.Event')
|
||||||
|
def broadcast_event_save(sender, instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
Broadcast event creation/update via WebSocket for real-time calendar sync.
|
||||||
|
"""
|
||||||
|
old_status = getattr(instance, '_old_status', None)
|
||||||
|
old_start = getattr(instance, '_old_start_time', None)
|
||||||
|
old_end = getattr(instance, '_old_end_time', None)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
# New event created
|
||||||
|
broadcast_event_change_sync(instance, 'event_created')
|
||||||
|
logger.info(f"Broadcast event_created for event {instance.id}")
|
||||||
|
|
||||||
|
elif old_status and old_status != instance.status:
|
||||||
|
# Status changed
|
||||||
|
broadcast_event_change_sync(
|
||||||
|
instance,
|
||||||
|
'event_status_changed',
|
||||||
|
old_status=old_status
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Broadcast event_status_changed for event {instance.id}: "
|
||||||
|
f"{old_status} -> {instance.status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Other update - determine what changed
|
||||||
|
changed_fields = []
|
||||||
|
|
||||||
|
if old_start and old_start != instance.start_time:
|
||||||
|
changed_fields.append('start_time')
|
||||||
|
if old_end and old_end != instance.end_time:
|
||||||
|
changed_fields.append('end_time')
|
||||||
|
|
||||||
|
# Always broadcast updates for changes
|
||||||
|
broadcast_event_change_sync(
|
||||||
|
instance,
|
||||||
|
'event_updated',
|
||||||
|
changed_fields=changed_fields if changed_fields else None
|
||||||
|
)
|
||||||
|
logger.info(f"Broadcast event_updated for event {instance.id}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender='schedule.Event')
|
||||||
|
def broadcast_event_delete(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Broadcast event deletion via WebSocket.
|
||||||
|
"""
|
||||||
|
# Store the event data before deletion for broadcasting
|
||||||
|
broadcast_event_change_sync(instance, 'event_deleted')
|
||||||
|
logger.info(f"Broadcast event_deleted for event {instance.id}")
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- Must be authenticated
|
- Must be authenticated
|
||||||
|
- Staff members cannot access resources (owners/managers only)
|
||||||
- Subject to MAX_RESOURCES quota (hard block on creation)
|
- Subject to MAX_RESOURCES quota (hard block on creation)
|
||||||
|
|
||||||
The HasQuota permission prevents creating resources when tenant
|
The HasQuota permission prevents creating resources when tenant
|
||||||
@@ -91,6 +92,7 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
|||||||
Return resources for the current tenant.
|
Return resources for the current tenant.
|
||||||
|
|
||||||
CRITICAL: Validates user belongs to the current tenant.
|
CRITICAL: Validates user belongs to the current tenant.
|
||||||
|
Staff members are denied access to resources.
|
||||||
"""
|
"""
|
||||||
queryset = Resource.objects.all()
|
queryset = Resource.objects.all()
|
||||||
|
|
||||||
@@ -98,6 +100,10 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
|||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
# Staff members cannot access resources
|
||||||
|
if user.role == User.Role.TENANT_STAFF:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
# Validate user belongs to the current tenant
|
# Validate user belongs to the current tenant
|
||||||
request_tenant = getattr(self.request, 'tenant', None)
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
if user.tenant and request_tenant:
|
if user.tenant and request_tenant:
|
||||||
@@ -106,6 +112,32 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def _check_staff_permission(self):
|
||||||
|
"""Deny access for staff members."""
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
if self.request.user.role == User.Role.TENANT_STAFF:
|
||||||
|
raise PermissionDenied("Staff members do not have access to resources.")
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""Create resource - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""Update resource - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
"""Partial update resource - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Delete resource - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Create resource (quota-checked by HasQuota permission)"""
|
"""Create resource (quota-checked by HasQuota permission)"""
|
||||||
serializer.save()
|
serializer.save()
|
||||||
@@ -114,6 +146,108 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
|||||||
"""Update resource"""
|
"""Update resource"""
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def location(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Get the latest location for a resource's linked staff member.
|
||||||
|
|
||||||
|
GET /api/resources/{id}/location/
|
||||||
|
|
||||||
|
Returns the most recent location update for the user linked to this resource,
|
||||||
|
along with their current job status (if any).
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.models import EmployeeLocationUpdate
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
resource = self.get_object()
|
||||||
|
|
||||||
|
# Check if resource has a linked user
|
||||||
|
if not resource.user:
|
||||||
|
return Response({
|
||||||
|
'has_location': False,
|
||||||
|
'message': 'Resource has no linked user'
|
||||||
|
})
|
||||||
|
|
||||||
|
user = resource.user
|
||||||
|
tenant = getattr(request, 'tenant', None)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response({
|
||||||
|
'has_location': False,
|
||||||
|
'message': 'No tenant context'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get the latest location for this user
|
||||||
|
latest_location = EmployeeLocationUpdate.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
employee=user
|
||||||
|
).order_by('-timestamp').first()
|
||||||
|
|
||||||
|
if not latest_location:
|
||||||
|
return Response({
|
||||||
|
'has_location': False,
|
||||||
|
'message': 'No location data available'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get the current active job for this user (EN_ROUTE or IN_PROGRESS)
|
||||||
|
from django.db.models import Q
|
||||||
|
user_ct = ContentType.objects.get_for_model(user.__class__)
|
||||||
|
resource_ct = ContentType.objects.get_for_model(Resource)
|
||||||
|
|
||||||
|
# Get resource IDs linked to this user
|
||||||
|
user_resource_ids = list(
|
||||||
|
Resource.objects.filter(user=user).values_list('id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find active events
|
||||||
|
from schedule.models import Event, Participant
|
||||||
|
active_statuses = ['EN_ROUTE', 'IN_PROGRESS']
|
||||||
|
|
||||||
|
# Events where user is a participant
|
||||||
|
user_participant_events = Participant.objects.filter(
|
||||||
|
content_type=user_ct,
|
||||||
|
object_id=user.id
|
||||||
|
).values_list('event_id', flat=True)
|
||||||
|
|
||||||
|
# Events where user's resource is a participant
|
||||||
|
resource_participant_events = Participant.objects.filter(
|
||||||
|
content_type=resource_ct,
|
||||||
|
object_id__in=user_resource_ids
|
||||||
|
).values_list('event_id', flat=True) if user_resource_ids else []
|
||||||
|
|
||||||
|
all_event_ids = set(user_participant_events) | set(resource_participant_events)
|
||||||
|
|
||||||
|
active_job = Event.objects.filter(
|
||||||
|
id__in=all_event_ids,
|
||||||
|
status__in=active_statuses
|
||||||
|
).order_by('-start_time').first()
|
||||||
|
|
||||||
|
# Check if this location is recent (within last 10 minutes) and for an active job
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
is_tracking = False
|
||||||
|
if active_job and latest_location.event_id == active_job.id:
|
||||||
|
time_since_update = timezone.now() - latest_location.timestamp
|
||||||
|
is_tracking = time_since_update < timedelta(minutes=10)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'has_location': True,
|
||||||
|
'latitude': float(latest_location.latitude),
|
||||||
|
'longitude': float(latest_location.longitude),
|
||||||
|
'accuracy': latest_location.accuracy,
|
||||||
|
'heading': latest_location.heading,
|
||||||
|
'speed': latest_location.speed,
|
||||||
|
'timestamp': latest_location.timestamp.isoformat(),
|
||||||
|
'is_tracking': is_tracking,
|
||||||
|
'active_job': {
|
||||||
|
'id': active_job.id,
|
||||||
|
'title': active_job.title,
|
||||||
|
'status': active_job.status,
|
||||||
|
'status_display': active_job.get_status_display(),
|
||||||
|
} if active_job else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class EventViewSet(viewsets.ModelViewSet):
|
class EventViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -257,6 +391,10 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
|||||||
API endpoint for managing Customers.
|
API endpoint for managing Customers.
|
||||||
|
|
||||||
Customers are Users with role=CUSTOMER belonging to the current tenant.
|
Customers are Users with role=CUSTOMER belonging to the current tenant.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- Staff members cannot list customers
|
||||||
|
- Staff can only retrieve individual customers with limited fields (name, address)
|
||||||
"""
|
"""
|
||||||
serializer_class = CustomerSerializer
|
serializer_class = CustomerSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
@@ -266,6 +404,56 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ['email', 'created_at']
|
ordering_fields = ['email', 'created_at']
|
||||||
ordering = ['email']
|
ordering = ['email']
|
||||||
|
|
||||||
|
def _check_staff_permission_for_list(self):
|
||||||
|
"""Deny list access for staff members."""
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
if self.request.user.role == User.Role.TENANT_STAFF:
|
||||||
|
raise PermissionDenied("Staff members do not have access to customer list.")
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
"""List customers - deny staff access."""
|
||||||
|
self._check_staff_permission_for_list()
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""Create customer - deny staff access."""
|
||||||
|
self._check_staff_permission_for_list()
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""Update customer - deny staff access."""
|
||||||
|
self._check_staff_permission_for_list()
|
||||||
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
"""Partial update customer - deny staff access."""
|
||||||
|
self._check_staff_permission_for_list()
|
||||||
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Delete customer - deny staff access."""
|
||||||
|
self._check_staff_permission_for_list()
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Retrieve customer - staff get limited fields only.
|
||||||
|
"""
|
||||||
|
instance = self.get_object()
|
||||||
|
if request.user.role == User.Role.TENANT_STAFF:
|
||||||
|
# Return only name and address for staff
|
||||||
|
return Response({
|
||||||
|
'id': instance.id,
|
||||||
|
'name': instance.full_name,
|
||||||
|
'first_name': instance.first_name,
|
||||||
|
'last_name': instance.last_name,
|
||||||
|
'city': getattr(instance, 'city', ''),
|
||||||
|
'state': getattr(instance, 'state', ''),
|
||||||
|
'zip': getattr(instance, 'zip', ''),
|
||||||
|
})
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Return customers for the current tenant, filtered by sandbox mode.
|
Return customers for the current tenant, filtered by sandbox mode.
|
||||||
@@ -343,6 +531,7 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- Must be authenticated
|
- Must be authenticated
|
||||||
|
- Staff members cannot access services
|
||||||
- Subject to MAX_SERVICES quota (hard block on creation)
|
- Subject to MAX_SERVICES quota (hard block on creation)
|
||||||
"""
|
"""
|
||||||
queryset = Service.objects.filter(is_active=True)
|
queryset = Service.objects.filter(is_active=True)
|
||||||
@@ -354,6 +543,42 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at']
|
ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at']
|
||||||
ordering = ['display_order', 'name']
|
ordering = ['display_order', 'name']
|
||||||
|
|
||||||
|
def _check_staff_permission(self):
|
||||||
|
"""Deny access for staff members."""
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
if self.request.user.role == User.Role.TENANT_STAFF:
|
||||||
|
raise PermissionDenied("Staff members do not have access to services.")
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
"""List services - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""Retrieve service - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""Create service - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""Update service - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
"""Partial update service - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Delete service - deny staff access."""
|
||||||
|
self._check_staff_permission()
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return services for the current tenant, optionally including inactive ones."""
|
"""Return services for the current tenant, optionally including inactive ones."""
|
||||||
queryset = Service.objects.all()
|
queryset = Service.objects.all()
|
||||||
@@ -362,6 +587,10 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
# Staff members cannot access services
|
||||||
|
if user.role == User.Role.TENANT_STAFF:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
# CRITICAL: Validate user belongs to the current request tenant
|
# CRITICAL: Validate user belongs to the current request tenant
|
||||||
request_tenant = getattr(self.request, 'tenant', None)
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
if user.tenant and request_tenant:
|
if user.tenant and request_tenant:
|
||||||
@@ -384,6 +613,7 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
Expects: { "order": [1, 3, 2, 5, 4] }
|
Expects: { "order": [1, 3, 2, 5, 4] }
|
||||||
Where the list contains service IDs in the desired display order.
|
Where the list contains service IDs in the desired display order.
|
||||||
"""
|
"""
|
||||||
|
self._check_staff_permission()
|
||||||
order = request.data.get('order', [])
|
order = request.data.get('order', [])
|
||||||
|
|
||||||
if not isinstance(order, list):
|
if not isinstance(order, list):
|
||||||
@@ -560,6 +790,11 @@ class ScheduledTaskViewSet(viewsets.ModelViewSet):
|
|||||||
"""Check if tenant has permission to access scheduled tasks."""
|
"""Check if tenant has permission to access scheduled tasks."""
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
# Staff members cannot access scheduled tasks
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_authenticated and user.role == User.Role.TENANT_STAFF:
|
||||||
|
raise PermissionDenied("Staff members do not have access to scheduled tasks.")
|
||||||
|
|
||||||
tenant = getattr(self.request, 'tenant', None)
|
tenant = getattr(self.request, 'tenant', None)
|
||||||
if tenant:
|
if tenant:
|
||||||
if not tenant.has_feature('can_use_plugins'):
|
if not tenant.has_feature('can_use_plugins'):
|
||||||
|
|||||||
1
smoothschedule/smoothschedule/field_mobile/__init__.py
Normal file
1
smoothschedule/smoothschedule/field_mobile/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Field Mobile App - Backend API for field employee mobile app
|
||||||
12
smoothschedule/smoothschedule/field_mobile/apps.py
Normal file
12
smoothschedule/smoothschedule/field_mobile/apps.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FieldMobileConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'smoothschedule.field_mobile'
|
||||||
|
label = 'field_mobile'
|
||||||
|
verbose_name = 'Field Mobile App'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# Import signals if needed in future
|
||||||
|
pass
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-06 20:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('comms_credits', '0002_add_stripe_customer_id'),
|
||||||
|
('core', '0022_add_can_use_tasks'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmployeeLocationUpdate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('event_id', models.IntegerField(db_index=True, help_text='ID of the active job in tenant schema')),
|
||||||
|
('latitude', models.DecimalField(decimal_places=7, help_text='GPS latitude', max_digits=10)),
|
||||||
|
('longitude', models.DecimalField(decimal_places=7, help_text='GPS longitude', max_digits=10)),
|
||||||
|
('accuracy', models.FloatField(blank=True, help_text='GPS accuracy in meters', null=True)),
|
||||||
|
('altitude', models.FloatField(blank=True, help_text='Altitude in meters (if available)', null=True)),
|
||||||
|
('heading', models.FloatField(blank=True, help_text='Direction of travel in degrees (0-360)', null=True)),
|
||||||
|
('speed', models.FloatField(blank=True, help_text='Speed in meters per second', null=True)),
|
||||||
|
('timestamp', models.DateTimeField(db_index=True, help_text='When the location was captured on device')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='When the server received this update')),
|
||||||
|
('battery_level', models.FloatField(blank=True, help_text='Device battery level (0.0-1.0)', null=True)),
|
||||||
|
('employee', models.ForeignKey(help_text='Employee being tracked', on_delete=django.db.models.deletion.CASCADE, related_name='location_updates', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('tenant', models.ForeignKey(help_text='Tenant this location update belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='employee_locations', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Employee Location Update',
|
||||||
|
'verbose_name_plural': 'Employee Location Updates',
|
||||||
|
'ordering': ['-timestamp'],
|
||||||
|
'indexes': [models.Index(fields=['tenant', 'event_id', '-timestamp'], name='field_mobil_tenant__1284a1_idx'), models.Index(fields=['employee', '-timestamp'], name='field_mobil_employe_7b4ee0_idx'), models.Index(fields=['tenant', 'employee', 'event_id'], name='field_mobil_tenant__f49e41_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EventStatusHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('event_id', models.IntegerField(db_index=True, help_text='ID of the Event in tenant schema')),
|
||||||
|
('old_status', models.CharField(help_text='Previous status before change', max_length=20)),
|
||||||
|
('new_status', models.CharField(help_text='New status after change', max_length=20)),
|
||||||
|
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('notes', models.TextField(blank=True, help_text='Optional notes about the status change')),
|
||||||
|
('latitude', models.DecimalField(blank=True, decimal_places=7, help_text='Latitude where status was changed', max_digits=10, null=True)),
|
||||||
|
('longitude', models.DecimalField(blank=True, decimal_places=7, help_text='Longitude where status was changed', max_digits=10, null=True)),
|
||||||
|
('source', models.CharField(choices=[('mobile_app', 'Mobile App'), ('web_app', 'Web App'), ('api', 'API'), ('system', 'System')], default='mobile_app', help_text='Where the status change originated', max_length=20)),
|
||||||
|
('changed_by', models.ForeignKey(help_text='User who made the status change', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('tenant', models.ForeignKey(help_text='Tenant this status change belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='event_status_history', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Event Status History',
|
||||||
|
'verbose_name_plural': 'Event Status Histories',
|
||||||
|
'ordering': ['-changed_at'],
|
||||||
|
'indexes': [models.Index(fields=['tenant', 'event_id'], name='field_mobil_tenant__746f7d_idx'), models.Index(fields=['changed_by', '-changed_at'], name='field_mobil_changed_18d75d_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FieldCallLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('event_id', models.IntegerField(db_index=True, help_text='ID of the job this call is associated with')),
|
||||||
|
('call_type', models.CharField(choices=[('voice', 'Voice Call'), ('sms', 'SMS')], max_length=10)),
|
||||||
|
('direction', models.CharField(choices=[('outbound', 'Employee to Customer'), ('inbound', 'Customer to Employee')], max_length=10)),
|
||||||
|
('status', models.CharField(choices=[('initiated', 'Initiated'), ('ringing', 'Ringing'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('busy', 'Busy'), ('no_answer', 'No Answer'), ('failed', 'Failed'), ('canceled', 'Canceled')], default='initiated', max_length=20)),
|
||||||
|
('customer_phone', models.CharField(help_text="Customer's phone number (E.164 format)", max_length=20)),
|
||||||
|
('proxy_number', models.CharField(help_text='Twilio proxy number used', max_length=20)),
|
||||||
|
('twilio_call_sid', models.CharField(blank=True, help_text='Twilio Call SID for voice calls', max_length=50)),
|
||||||
|
('twilio_message_sid', models.CharField(blank=True, help_text='Twilio Message SID for SMS', max_length=50)),
|
||||||
|
('duration_seconds', models.IntegerField(blank=True, help_text='Call duration in seconds (for voice)', null=True)),
|
||||||
|
('cost_cents', models.IntegerField(default=0, help_text='Cost charged to tenant in cents')),
|
||||||
|
('initiated_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('answered_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('ended_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('employee', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='field_calls', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('masked_session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='field_calls', to='comms_credits.maskedsession')),
|
||||||
|
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_call_logs', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Field Call Log',
|
||||||
|
'verbose_name_plural': 'Field Call Logs',
|
||||||
|
'ordering': ['-initiated_at'],
|
||||||
|
'indexes': [models.Index(fields=['tenant', 'event_id'], name='field_mobil_tenant__8235dc_idx'), models.Index(fields=['employee', '-initiated_at'], name='field_mobil_employe_2a932d_idx'), models.Index(fields=['twilio_call_sid'], name='field_mobil_twilio__4c77e8_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
329
smoothschedule/smoothschedule/field_mobile/models.py
Normal file
329
smoothschedule/smoothschedule/field_mobile/models.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"""
|
||||||
|
Field Mobile Models
|
||||||
|
|
||||||
|
Models for tracking field employee activity:
|
||||||
|
- EventStatusHistory: Audit log for job status changes
|
||||||
|
- EmployeeLocationUpdate: GPS tracking during en-route/in-progress
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class EventStatusHistory(models.Model):
|
||||||
|
"""
|
||||||
|
Audit log for event/job status changes.
|
||||||
|
|
||||||
|
Records who changed the status, when, and from what location.
|
||||||
|
This model lives in the public schema and references events by ID
|
||||||
|
since events are in tenant schemas.
|
||||||
|
"""
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
'core.Tenant',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='event_status_history',
|
||||||
|
help_text="Tenant this status change belongs to"
|
||||||
|
)
|
||||||
|
event_id = models.IntegerField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="ID of the Event in tenant schema"
|
||||||
|
)
|
||||||
|
old_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
help_text="Previous status before change"
|
||||||
|
)
|
||||||
|
new_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
help_text="New status after change"
|
||||||
|
)
|
||||||
|
changed_by = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='status_changes',
|
||||||
|
help_text="User who made the status change"
|
||||||
|
)
|
||||||
|
changed_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
notes = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Optional notes about the status change"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Location at time of status change (optional)
|
||||||
|
latitude = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=7,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Latitude where status was changed"
|
||||||
|
)
|
||||||
|
longitude = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=7,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Longitude where status was changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source of the change
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='mobile_app',
|
||||||
|
choices=[
|
||||||
|
('mobile_app', 'Mobile App'),
|
||||||
|
('web_app', 'Web App'),
|
||||||
|
('api', 'API'),
|
||||||
|
('system', 'System'),
|
||||||
|
],
|
||||||
|
help_text="Where the status change originated"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-changed_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'event_id']),
|
||||||
|
models.Index(fields=['changed_by', '-changed_at']),
|
||||||
|
]
|
||||||
|
verbose_name = 'Event Status History'
|
||||||
|
verbose_name_plural = 'Event Status Histories'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Event {self.event_id}: {self.old_status} → {self.new_status}"
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeLocationUpdate(models.Model):
|
||||||
|
"""
|
||||||
|
Periodic location updates from field employees.
|
||||||
|
|
||||||
|
Stored while an employee is en-route to or working on a job.
|
||||||
|
Location tracking automatically stops when job is completed.
|
||||||
|
|
||||||
|
Privacy considerations:
|
||||||
|
- Only tracked during active jobs (EN_ROUTE, IN_PROGRESS)
|
||||||
|
- Automatically stops when job status changes to COMPLETED/CANCELED
|
||||||
|
- Old location data can be purged after a configurable retention period
|
||||||
|
"""
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
'core.Tenant',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='employee_locations',
|
||||||
|
help_text="Tenant this location update belongs to"
|
||||||
|
)
|
||||||
|
employee = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='location_updates',
|
||||||
|
help_text="Employee being tracked"
|
||||||
|
)
|
||||||
|
event_id = models.IntegerField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="ID of the active job in tenant schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Location data
|
||||||
|
latitude = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=7,
|
||||||
|
help_text="GPS latitude"
|
||||||
|
)
|
||||||
|
longitude = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=7,
|
||||||
|
help_text="GPS longitude"
|
||||||
|
)
|
||||||
|
accuracy = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="GPS accuracy in meters"
|
||||||
|
)
|
||||||
|
altitude = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Altitude in meters (if available)"
|
||||||
|
)
|
||||||
|
heading = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Direction of travel in degrees (0-360)"
|
||||||
|
)
|
||||||
|
speed = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Speed in meters per second"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamp from device
|
||||||
|
timestamp = models.DateTimeField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="When the location was captured on device"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Server timestamp
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="When the server received this update"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Battery level (useful for understanding tracking reliability)
|
||||||
|
battery_level = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Device battery level (0.0-1.0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'event_id', '-timestamp']),
|
||||||
|
models.Index(fields=['employee', '-timestamp']),
|
||||||
|
models.Index(fields=['tenant', 'employee', 'event_id']),
|
||||||
|
]
|
||||||
|
verbose_name = 'Employee Location Update'
|
||||||
|
verbose_name_plural = 'Employee Location Updates'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.employee} @ ({self.latitude}, {self.longitude})"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_latest_for_event(cls, tenant_id, event_id):
|
||||||
|
"""Get the most recent location update for an event."""
|
||||||
|
return cls.objects.filter(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
event_id=event_id
|
||||||
|
).order_by('-timestamp').first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_route_for_event(cls, tenant_id, event_id, limit=100):
|
||||||
|
"""
|
||||||
|
Get location history for an event (for drawing route on map).
|
||||||
|
|
||||||
|
Returns locations ordered by timestamp ascending (oldest first).
|
||||||
|
"""
|
||||||
|
return list(
|
||||||
|
cls.objects.filter(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
event_id=event_id
|
||||||
|
).order_by('timestamp')[:limit].values(
|
||||||
|
'latitude', 'longitude', 'timestamp', 'accuracy'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldCallLog(models.Model):
|
||||||
|
"""
|
||||||
|
Log of masked calls and SMS between employees and customers.
|
||||||
|
|
||||||
|
Tracks all communication through the proxy number for billing
|
||||||
|
and audit purposes.
|
||||||
|
"""
|
||||||
|
class CallType(models.TextChoices):
|
||||||
|
VOICE = 'voice', 'Voice Call'
|
||||||
|
SMS = 'sms', 'SMS'
|
||||||
|
|
||||||
|
class Direction(models.TextChoices):
|
||||||
|
OUTBOUND = 'outbound', 'Employee to Customer'
|
||||||
|
INBOUND = 'inbound', 'Customer to Employee'
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
INITIATED = 'initiated', 'Initiated'
|
||||||
|
RINGING = 'ringing', 'Ringing'
|
||||||
|
IN_PROGRESS = 'in_progress', 'In Progress'
|
||||||
|
COMPLETED = 'completed', 'Completed'
|
||||||
|
BUSY = 'busy', 'Busy'
|
||||||
|
NO_ANSWER = 'no_answer', 'No Answer'
|
||||||
|
FAILED = 'failed', 'Failed'
|
||||||
|
CANCELED = 'canceled', 'Canceled'
|
||||||
|
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
'core.Tenant',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='field_call_logs'
|
||||||
|
)
|
||||||
|
event_id = models.IntegerField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="ID of the job this call is associated with"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call metadata
|
||||||
|
call_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=CallType.choices
|
||||||
|
)
|
||||||
|
direction = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=Direction.choices
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.INITIATED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Participants
|
||||||
|
employee = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='field_calls'
|
||||||
|
)
|
||||||
|
customer_phone = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
help_text="Customer's phone number (E.164 format)"
|
||||||
|
)
|
||||||
|
proxy_number = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
help_text="Twilio proxy number used"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Twilio references
|
||||||
|
twilio_call_sid = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text="Twilio Call SID for voice calls"
|
||||||
|
)
|
||||||
|
twilio_message_sid = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text="Twilio Message SID for SMS"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Masked session reference
|
||||||
|
masked_session = models.ForeignKey(
|
||||||
|
'comms_credits.MaskedSession',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='field_calls'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Duration and cost
|
||||||
|
duration_seconds = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Call duration in seconds (for voice)"
|
||||||
|
)
|
||||||
|
cost_cents = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Cost charged to tenant in cents"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
initiated_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
answered_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
ended_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-initiated_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'event_id']),
|
||||||
|
models.Index(fields=['employee', '-initiated_at']),
|
||||||
|
models.Index(fields=['twilio_call_sid']),
|
||||||
|
]
|
||||||
|
verbose_name = 'Field Call Log'
|
||||||
|
verbose_name_plural = 'Field Call Logs'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
direction = "→" if self.direction == self.Direction.OUTBOUND else "←"
|
||||||
|
return f"{self.call_type}: Employee {direction} Customer ({self.status})"
|
||||||
514
smoothschedule/smoothschedule/field_mobile/serializers.py
Normal file
514
smoothschedule/smoothschedule/field_mobile/serializers.py
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
"""
|
||||||
|
Field Mobile Serializers
|
||||||
|
|
||||||
|
Serializers for the field employee mobile app API.
|
||||||
|
"""
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from schedule.models import Event, Service, Participant
|
||||||
|
from smoothschedule.field_mobile.models import (
|
||||||
|
EventStatusHistory,
|
||||||
|
EmployeeLocationUpdate,
|
||||||
|
FieldCallLog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSummarySerializer(serializers.ModelSerializer):
|
||||||
|
"""Minimal service info for job cards."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Service
|
||||||
|
fields = ['id', 'name', 'duration', 'price']
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerInfoSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Customer information for a job.
|
||||||
|
|
||||||
|
Phone number is masked for privacy - actual calls go through proxy.
|
||||||
|
"""
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
phone_masked = serializers.SerializerMethodField()
|
||||||
|
email = serializers.CharField(allow_null=True)
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
"""Return customer's full name."""
|
||||||
|
if hasattr(obj, 'full_name') and obj.full_name:
|
||||||
|
return obj.full_name
|
||||||
|
if hasattr(obj, 'get_full_name'):
|
||||||
|
return obj.get_full_name() or getattr(obj, 'username', 'Customer')
|
||||||
|
return getattr(obj, 'username', 'Customer')
|
||||||
|
|
||||||
|
def get_phone_masked(self, obj):
|
||||||
|
"""Return masked phone number (last 4 digits only)."""
|
||||||
|
phone = getattr(obj, 'phone', None)
|
||||||
|
if phone and len(phone) >= 4:
|
||||||
|
return f"***-***-{phone[-4:]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class JobListSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for job list (today's and upcoming jobs).
|
||||||
|
|
||||||
|
Optimized for quick loading on mobile.
|
||||||
|
"""
|
||||||
|
service_name = serializers.SerializerMethodField()
|
||||||
|
customer_name = serializers.SerializerMethodField()
|
||||||
|
address = serializers.SerializerMethodField()
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
duration_minutes = serializers.SerializerMethodField()
|
||||||
|
allowed_transitions = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'status',
|
||||||
|
'status_display',
|
||||||
|
'service_name',
|
||||||
|
'customer_name',
|
||||||
|
'address',
|
||||||
|
'duration_minutes',
|
||||||
|
'allowed_transitions',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_service_name(self, obj):
|
||||||
|
return obj.service.name if obj.service else None
|
||||||
|
|
||||||
|
def get_customer_name(self, obj):
|
||||||
|
"""Get the customer's name from participants."""
|
||||||
|
customer = self._get_customer_participant(obj)
|
||||||
|
if customer:
|
||||||
|
return getattr(customer, 'full_name', None) or getattr(customer, 'username', 'Customer')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_address(self, obj):
|
||||||
|
"""Get customer's address if available."""
|
||||||
|
# First check event notes for address
|
||||||
|
if obj.notes and 'address' in obj.notes.lower():
|
||||||
|
return obj.notes
|
||||||
|
|
||||||
|
# Try to get from customer
|
||||||
|
customer = self._get_customer_participant(obj)
|
||||||
|
if customer and hasattr(customer, 'address'):
|
||||||
|
return customer.address
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_duration_minutes(self, obj):
|
||||||
|
"""Calculate event duration in minutes."""
|
||||||
|
if obj.start_time and obj.end_time:
|
||||||
|
delta = obj.end_time - obj.start_time
|
||||||
|
return int(delta.total_seconds() / 60)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_allowed_transitions(self, obj):
|
||||||
|
"""Get list of statuses this job can transition to."""
|
||||||
|
from smoothschedule.field_mobile.services import StatusMachine
|
||||||
|
|
||||||
|
# Get the valid transitions without needing user context
|
||||||
|
return StatusMachine.VALID_TRANSITIONS.get(obj.status, [])
|
||||||
|
|
||||||
|
def _get_customer_participant(self, obj):
|
||||||
|
"""Get the customer User from participants."""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
if not hasattr(self, '_customer_cache'):
|
||||||
|
self._customer_cache = {}
|
||||||
|
|
||||||
|
if obj.id in self._customer_cache:
|
||||||
|
return self._customer_cache[obj.id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
participant = obj.participants.filter(
|
||||||
|
role=Participant.Role.CUSTOMER,
|
||||||
|
content_type=user_ct
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if participant:
|
||||||
|
self._customer_cache[obj.id] = participant.content_object
|
||||||
|
return participant.content_object
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class JobDetailSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Full job details for the job detail screen.
|
||||||
|
|
||||||
|
Includes all information needed to work on the job.
|
||||||
|
"""
|
||||||
|
service = ServiceSummarySerializer(read_only=True)
|
||||||
|
customer = serializers.SerializerMethodField()
|
||||||
|
assigned_staff = serializers.SerializerMethodField()
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
duration_minutes = serializers.SerializerMethodField()
|
||||||
|
allowed_transitions = serializers.SerializerMethodField()
|
||||||
|
can_track_location = serializers.SerializerMethodField()
|
||||||
|
has_active_call_session = serializers.SerializerMethodField()
|
||||||
|
status_history = serializers.SerializerMethodField()
|
||||||
|
latest_location = serializers.SerializerMethodField()
|
||||||
|
can_edit_schedule = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'status',
|
||||||
|
'status_display',
|
||||||
|
'notes',
|
||||||
|
'service',
|
||||||
|
'customer',
|
||||||
|
'assigned_staff',
|
||||||
|
'duration_minutes',
|
||||||
|
'allowed_transitions',
|
||||||
|
'can_track_location',
|
||||||
|
'has_active_call_session',
|
||||||
|
'status_history',
|
||||||
|
'latest_location',
|
||||||
|
'deposit_amount',
|
||||||
|
'final_price',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'can_edit_schedule',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_customer(self, obj):
|
||||||
|
"""Get customer info with masked phone."""
|
||||||
|
customer = self._get_customer_participant(obj)
|
||||||
|
if customer:
|
||||||
|
return CustomerInfoSerializer(customer).data
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_assigned_staff(self, obj):
|
||||||
|
"""Get list of assigned staff members."""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
from schedule.models import Resource
|
||||||
|
|
||||||
|
staff = []
|
||||||
|
|
||||||
|
# Get staff from User participants
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
for participant in obj.participants.filter(
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE],
|
||||||
|
content_type=user_ct
|
||||||
|
):
|
||||||
|
user = participant.content_object
|
||||||
|
if user:
|
||||||
|
staff.append({
|
||||||
|
'id': user.id,
|
||||||
|
'name': user.full_name or user.username,
|
||||||
|
'type': 'user',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get staff from Resource participants
|
||||||
|
resource_ct = ContentType.objects.get_for_model(Resource)
|
||||||
|
for participant in obj.participants.filter(
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE],
|
||||||
|
content_type=resource_ct
|
||||||
|
):
|
||||||
|
resource = participant.content_object
|
||||||
|
if resource:
|
||||||
|
staff.append({
|
||||||
|
'id': resource.id,
|
||||||
|
'name': resource.name,
|
||||||
|
'type': 'resource',
|
||||||
|
'user_id': resource.user_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return staff
|
||||||
|
|
||||||
|
def get_duration_minutes(self, obj):
|
||||||
|
if obj.start_time and obj.end_time:
|
||||||
|
delta = obj.end_time - obj.start_time
|
||||||
|
return int(delta.total_seconds() / 60)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_allowed_transitions(self, obj):
|
||||||
|
from smoothschedule.field_mobile.services import StatusMachine
|
||||||
|
return StatusMachine.VALID_TRANSITIONS.get(obj.status, [])
|
||||||
|
|
||||||
|
def get_can_track_location(self, obj):
|
||||||
|
"""Check if location tracking is allowed for current status."""
|
||||||
|
from smoothschedule.field_mobile.services import StatusMachine
|
||||||
|
return obj.status in StatusMachine.TRACKING_STATUSES
|
||||||
|
|
||||||
|
def get_has_active_call_session(self, obj):
|
||||||
|
"""Check if there's an active masked call session."""
|
||||||
|
tenant = self.context.get('tenant')
|
||||||
|
if not tenant:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from smoothschedule.comms_credits.models import MaskedSession
|
||||||
|
return MaskedSession.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
event_id=obj.id,
|
||||||
|
status=MaskedSession.Status.ACTIVE,
|
||||||
|
expires_at__gt=timezone.now()
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def get_status_history(self, obj):
|
||||||
|
"""Get recent status change history."""
|
||||||
|
tenant = self.context.get('tenant')
|
||||||
|
if not tenant:
|
||||||
|
return []
|
||||||
|
|
||||||
|
history = EventStatusHistory.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
event_id=obj.id
|
||||||
|
).select_related('changed_by')[:10]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'old_status': h.old_status,
|
||||||
|
'new_status': h.new_status,
|
||||||
|
'changed_by': h.changed_by.full_name if h.changed_by else 'System',
|
||||||
|
'changed_at': h.changed_at,
|
||||||
|
'notes': h.notes,
|
||||||
|
}
|
||||||
|
for h in history
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_latest_location(self, obj):
|
||||||
|
"""Get the most recent location update for this job."""
|
||||||
|
tenant = self.context.get('tenant')
|
||||||
|
if not tenant:
|
||||||
|
return None
|
||||||
|
|
||||||
|
location = EmployeeLocationUpdate.get_latest_for_event(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
event_id=obj.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if location:
|
||||||
|
return {
|
||||||
|
'latitude': float(location.latitude),
|
||||||
|
'longitude': float(location.longitude),
|
||||||
|
'timestamp': location.timestamp,
|
||||||
|
'accuracy': location.accuracy,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_customer_participant(self, obj):
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
participant = obj.participants.filter(
|
||||||
|
role=Participant.Role.CUSTOMER,
|
||||||
|
content_type=user_ct
|
||||||
|
).first()
|
||||||
|
return participant.content_object if participant else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_can_edit_schedule(self, obj):
|
||||||
|
"""
|
||||||
|
Check if the current user can edit this job's schedule.
|
||||||
|
|
||||||
|
Returns True if the user's linked resource has user_can_edit_schedule=True.
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from schedule.models import Resource
|
||||||
|
|
||||||
|
# Get the current user from context
|
||||||
|
request = self.context.get('request')
|
||||||
|
if not request or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Get resources linked to this user
|
||||||
|
user_resources = Resource.objects.filter(user=user)
|
||||||
|
|
||||||
|
# Check if any of the user's resources has edit permission
|
||||||
|
for resource in user_resources:
|
||||||
|
if resource.user_can_edit_schedule:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SetStatusSerializer(serializers.Serializer):
|
||||||
|
"""Request to change a job's status."""
|
||||||
|
status = serializers.ChoiceField(choices=Event.Status.choices)
|
||||||
|
notes = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
latitude = serializers.DecimalField(
|
||||||
|
max_digits=10, decimal_places=7,
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
longitude = serializers.DecimalField(
|
||||||
|
max_digits=10, decimal_places=7,
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RescheduleJobSerializer(serializers.Serializer):
|
||||||
|
"""Request to reschedule a job (change start time and/or duration)."""
|
||||||
|
start_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
duration_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=5, max_value=1440)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Validate that we have either start/end times or duration."""
|
||||||
|
start_time = attrs.get('start_time')
|
||||||
|
end_time = attrs.get('end_time')
|
||||||
|
duration_minutes = attrs.get('duration_minutes')
|
||||||
|
|
||||||
|
# Must have at least one field to update
|
||||||
|
if not start_time and not end_time and not duration_minutes:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Must provide start_time, end_time, or duration_minutes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If both start_time and end_time are provided, validate end > start
|
||||||
|
if start_time and end_time and end_time <= start_time:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"end_time must be after start_time"
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class StartEnRouteSerializer(serializers.Serializer):
|
||||||
|
"""Request to start en-route to a job (includes location)."""
|
||||||
|
latitude = serializers.DecimalField(
|
||||||
|
max_digits=10, decimal_places=7,
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
longitude = serializers.DecimalField(
|
||||||
|
max_digits=10, decimal_places=7,
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
send_customer_notification = serializers.BooleanField(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationUpdateSerializer(serializers.Serializer):
|
||||||
|
"""Employee location update while en-route or in-progress."""
|
||||||
|
latitude = serializers.DecimalField(max_digits=10, decimal_places=7)
|
||||||
|
longitude = serializers.DecimalField(max_digits=10, decimal_places=7)
|
||||||
|
accuracy = serializers.FloatField(required=False, allow_null=True)
|
||||||
|
altitude = serializers.FloatField(required=False, allow_null=True)
|
||||||
|
heading = serializers.FloatField(required=False, allow_null=True)
|
||||||
|
speed = serializers.FloatField(required=False, allow_null=True)
|
||||||
|
timestamp = serializers.DateTimeField()
|
||||||
|
battery_level = serializers.FloatField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationUpdateResponseSerializer(serializers.Serializer):
|
||||||
|
"""Response after recording a location update."""
|
||||||
|
success = serializers.BooleanField()
|
||||||
|
should_continue_tracking = serializers.BooleanField()
|
||||||
|
message = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class InitiateCallSerializer(serializers.Serializer):
|
||||||
|
"""Request to initiate a masked call to customer."""
|
||||||
|
# No required fields - customer phone comes from the job
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InitiateCallResponseSerializer(serializers.Serializer):
|
||||||
|
"""Response after initiating a call."""
|
||||||
|
call_sid = serializers.CharField()
|
||||||
|
call_log_id = serializers.IntegerField()
|
||||||
|
proxy_number = serializers.CharField()
|
||||||
|
status = serializers.CharField()
|
||||||
|
message = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SendSMSSerializer(serializers.Serializer):
|
||||||
|
"""Request to send SMS to customer."""
|
||||||
|
message = serializers.CharField(max_length=1600)
|
||||||
|
|
||||||
|
|
||||||
|
class SendSMSResponseSerializer(serializers.Serializer):
|
||||||
|
"""Response after sending SMS."""
|
||||||
|
message_sid = serializers.CharField()
|
||||||
|
call_log_id = serializers.IntegerField()
|
||||||
|
status = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class CallHistorySerializer(serializers.ModelSerializer):
|
||||||
|
"""Call/SMS history for a job."""
|
||||||
|
employee_name = serializers.SerializerMethodField()
|
||||||
|
type_display = serializers.CharField(source='get_call_type_display')
|
||||||
|
direction_display = serializers.CharField(source='get_direction_display')
|
||||||
|
status_display = serializers.CharField(source='get_status_display')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FieldCallLog
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'call_type',
|
||||||
|
'type_display',
|
||||||
|
'direction',
|
||||||
|
'direction_display',
|
||||||
|
'status',
|
||||||
|
'status_display',
|
||||||
|
'duration_seconds',
|
||||||
|
'initiated_at',
|
||||||
|
'answered_at',
|
||||||
|
'ended_at',
|
||||||
|
'employee_name',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_employee_name(self, obj):
|
||||||
|
return obj.employee.full_name if obj.employee else None
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeProfileSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Employee profile for the mobile app.
|
||||||
|
|
||||||
|
Includes tenant context and permissions.
|
||||||
|
"""
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
email = serializers.EmailField()
|
||||||
|
name = serializers.CharField()
|
||||||
|
phone = serializers.CharField(allow_null=True)
|
||||||
|
role = serializers.CharField()
|
||||||
|
|
||||||
|
# Business context
|
||||||
|
business_id = serializers.IntegerField(source='tenant_id')
|
||||||
|
business_name = serializers.SerializerMethodField()
|
||||||
|
business_subdomain = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Feature flags
|
||||||
|
can_use_masked_calls = serializers.SerializerMethodField()
|
||||||
|
can_track_location = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_business_name(self, obj):
|
||||||
|
return obj.tenant.name if obj.tenant else None
|
||||||
|
|
||||||
|
def get_business_subdomain(self, obj):
|
||||||
|
if obj.tenant:
|
||||||
|
domain = obj.tenant.domains.filter(is_primary=True).first()
|
||||||
|
if domain:
|
||||||
|
return domain.domain.split('.')[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_can_use_masked_calls(self, obj):
|
||||||
|
if obj.tenant:
|
||||||
|
return obj.tenant.has_feature('can_use_masked_phone_numbers')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_can_track_location(self, obj):
|
||||||
|
if obj.tenant:
|
||||||
|
return obj.tenant.has_feature('can_use_mobile_app')
|
||||||
|
return False
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Field Mobile Services
|
||||||
|
from .status_machine import StatusMachine
|
||||||
|
from .twilio_calls import TwilioFieldCallService
|
||||||
|
|
||||||
|
__all__ = ['StatusMachine', 'TwilioFieldCallService']
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""
|
||||||
|
Status Machine Service
|
||||||
|
|
||||||
|
Enforces valid status transitions for jobs/events and records history.
|
||||||
|
"""
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from schedule.models import Event
|
||||||
|
from smoothschedule.field_mobile.models import EventStatusHistory, EmployeeLocationUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class StatusTransitionError(Exception):
|
||||||
|
"""Raised when an invalid status transition is attempted."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StatusMachine:
|
||||||
|
"""
|
||||||
|
Manages event/job status transitions with validation and audit logging.
|
||||||
|
|
||||||
|
Status Flow:
|
||||||
|
SCHEDULED → EN_ROUTE → IN_PROGRESS → COMPLETED
|
||||||
|
↘ ↘
|
||||||
|
CANCELED NOSHOW
|
||||||
|
↓
|
||||||
|
AWAITING_PAYMENT → PAID
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only assigned employees can change status (except admin override)
|
||||||
|
- Cannot go backward in the flow (no COMPLETED → SCHEDULED)
|
||||||
|
- Location tracking stops on COMPLETED, CANCELED, NOSHOW
|
||||||
|
- Some transitions trigger customer notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Define valid transitions: current_status -> [allowed_next_statuses]
|
||||||
|
VALID_TRANSITIONS = {
|
||||||
|
Event.Status.SCHEDULED: [
|
||||||
|
Event.Status.EN_ROUTE,
|
||||||
|
Event.Status.IN_PROGRESS, # Can skip EN_ROUTE if already at location
|
||||||
|
Event.Status.CANCELED,
|
||||||
|
],
|
||||||
|
Event.Status.EN_ROUTE: [
|
||||||
|
Event.Status.IN_PROGRESS,
|
||||||
|
Event.Status.CANCELED,
|
||||||
|
Event.Status.NOSHOW, # Customer not available when arrived
|
||||||
|
],
|
||||||
|
Event.Status.IN_PROGRESS: [
|
||||||
|
Event.Status.COMPLETED,
|
||||||
|
Event.Status.CANCELED,
|
||||||
|
Event.Status.NOSHOW,
|
||||||
|
],
|
||||||
|
Event.Status.COMPLETED: [
|
||||||
|
Event.Status.AWAITING_PAYMENT, # For variable pricing
|
||||||
|
],
|
||||||
|
Event.Status.AWAITING_PAYMENT: [
|
||||||
|
Event.Status.PAID,
|
||||||
|
],
|
||||||
|
# Terminal states - no transitions allowed
|
||||||
|
Event.Status.CANCELED: [],
|
||||||
|
Event.Status.PAID: [],
|
||||||
|
Event.Status.NOSHOW: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statuses that allow location tracking
|
||||||
|
TRACKING_STATUSES = {
|
||||||
|
Event.Status.EN_ROUTE,
|
||||||
|
Event.Status.IN_PROGRESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Transitions that should trigger customer notification
|
||||||
|
NOTIFY_CUSTOMER_TRANSITIONS = {
|
||||||
|
(Event.Status.SCHEDULED, Event.Status.EN_ROUTE): 'en_route_notification',
|
||||||
|
(Event.Status.EN_ROUTE, Event.Status.IN_PROGRESS): 'arrived_notification',
|
||||||
|
(Event.Status.IN_PROGRESS, Event.Status.COMPLETED): 'completed_notification',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, tenant, user):
|
||||||
|
"""
|
||||||
|
Initialize the status machine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant: The tenant (business) context
|
||||||
|
user: The user making the status change
|
||||||
|
"""
|
||||||
|
self.tenant = tenant
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def can_transition(self, event: Event, new_status: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check if a status transition is valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The event to check
|
||||||
|
new_status: The proposed new status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, reason_if_invalid)
|
||||||
|
"""
|
||||||
|
current_status = event.status
|
||||||
|
|
||||||
|
# Same status - no change needed
|
||||||
|
if current_status == new_status:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Check if transition is in the allowed list
|
||||||
|
allowed = self.VALID_TRANSITIONS.get(current_status, [])
|
||||||
|
if new_status not in allowed:
|
||||||
|
return False, (
|
||||||
|
f"Cannot transition from {current_status} to {new_status}. "
|
||||||
|
f"Allowed transitions: {', '.join(allowed) if allowed else 'none (terminal state)'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def is_employee_assigned(self, event: Event) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the current user is assigned to this event as staff.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- User is a participant with STAFF role, OR
|
||||||
|
- User is linked to a Resource that is a participant
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from schedule.models import Participant, Resource
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
# Check if user is directly a participant
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
if Participant.objects.filter(
|
||||||
|
event=event,
|
||||||
|
content_type=user_ct,
|
||||||
|
object_id=self.user.id,
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE]
|
||||||
|
).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if user is linked to a Resource that is a participant
|
||||||
|
resource_ct = ContentType.objects.get_for_model(Resource)
|
||||||
|
user_resources = Resource.objects.filter(user=self.user).values_list('id', flat=True)
|
||||||
|
|
||||||
|
if Participant.objects.filter(
|
||||||
|
event=event,
|
||||||
|
content_type=resource_ct,
|
||||||
|
object_id__in=user_resources,
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE]
|
||||||
|
).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_user_change_status(self, event: Event) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check if the current user has permission to change this event's status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_allowed, reason_if_not)
|
||||||
|
"""
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
# Owners and managers can always change status
|
||||||
|
if self.user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Staff must be assigned to the event
|
||||||
|
if self.user.role == User.Role.TENANT_STAFF:
|
||||||
|
if self.is_employee_assigned(event):
|
||||||
|
return True, ""
|
||||||
|
return False, "You are not assigned to this job"
|
||||||
|
|
||||||
|
return False, "You do not have permission to change job status"
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def transition(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
new_status: str,
|
||||||
|
notes: str = "",
|
||||||
|
latitude: Optional[Decimal] = None,
|
||||||
|
longitude: Optional[Decimal] = None,
|
||||||
|
source: str = "mobile_app",
|
||||||
|
skip_notifications: bool = False,
|
||||||
|
) -> Event:
|
||||||
|
"""
|
||||||
|
Transition an event to a new status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The event to update
|
||||||
|
new_status: The target status
|
||||||
|
notes: Optional notes about the change
|
||||||
|
latitude: GPS latitude at time of change
|
||||||
|
longitude: GPS longitude at time of change
|
||||||
|
source: Where the change originated
|
||||||
|
skip_notifications: If True, don't send customer notifications
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated event
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StatusTransitionError: If the transition is not allowed
|
||||||
|
"""
|
||||||
|
old_status = event.status
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
can_change, reason = self.can_user_change_status(event)
|
||||||
|
if not can_change:
|
||||||
|
raise StatusTransitionError(reason)
|
||||||
|
|
||||||
|
# Check if transition is valid
|
||||||
|
is_valid, reason = self.can_transition(event, new_status)
|
||||||
|
if not is_valid:
|
||||||
|
raise StatusTransitionError(reason)
|
||||||
|
|
||||||
|
# Perform the transition
|
||||||
|
event.status = new_status
|
||||||
|
event.save(update_fields=['status', 'updated_at'])
|
||||||
|
|
||||||
|
# Record in history
|
||||||
|
EventStatusHistory.objects.create(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event.id,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=new_status,
|
||||||
|
changed_by=self.user,
|
||||||
|
notes=notes,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle location tracking cleanup if needed
|
||||||
|
if new_status not in self.TRACKING_STATUSES:
|
||||||
|
self._stop_location_tracking(event)
|
||||||
|
|
||||||
|
# Trigger notifications if needed
|
||||||
|
if not skip_notifications:
|
||||||
|
notification_type = self.NOTIFY_CUSTOMER_TRANSITIONS.get(
|
||||||
|
(old_status, new_status)
|
||||||
|
)
|
||||||
|
if notification_type:
|
||||||
|
self._send_customer_notification(event, notification_type)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
def _stop_location_tracking(self, event: Event):
|
||||||
|
"""
|
||||||
|
Mark that location tracking should stop for this event.
|
||||||
|
|
||||||
|
The mobile app checks this to know when to stop sending updates.
|
||||||
|
We don't delete existing location data - it's kept for the route history.
|
||||||
|
"""
|
||||||
|
# Future: Could add a field to mark tracking as stopped
|
||||||
|
# For now, the app will check the event status before sending updates
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send_customer_notification(self, event: Event, notification_type: str):
|
||||||
|
"""
|
||||||
|
Send a notification to the customer about the status change.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The event that changed
|
||||||
|
notification_type: Type of notification to send
|
||||||
|
"""
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from smoothschedule.field_mobile.tasks import send_customer_status_notification
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Queue the notification task
|
||||||
|
send_customer_status_notification.delay(
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
event_id=event.id,
|
||||||
|
notification_type=notification_type,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Don't fail the status change if notification fails
|
||||||
|
# The notification system should handle retries
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_allowed_transitions(self, event: Event) -> list:
|
||||||
|
"""
|
||||||
|
Get the list of statuses this event can transition to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of allowed status values
|
||||||
|
"""
|
||||||
|
return self.VALID_TRANSITIONS.get(event.status, [])
|
||||||
|
|
||||||
|
def get_status_history(self, event_id: int, limit: int = 50) -> list:
|
||||||
|
"""
|
||||||
|
Get the status change history for an event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_id: The event ID
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of EventStatusHistory records
|
||||||
|
"""
|
||||||
|
return list(
|
||||||
|
EventStatusHistory.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event_id
|
||||||
|
).select_related('changed_by')[:limit]
|
||||||
|
)
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
"""
|
||||||
|
Twilio Field Call Service
|
||||||
|
|
||||||
|
Handles masked calling and SMS between field employees and customers.
|
||||||
|
Bi-directional: both employee→customer and customer→employee are supported.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TwilioFieldCallError(Exception):
|
||||||
|
"""Raised when a Twilio call operation fails."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TwilioFieldCallService:
|
||||||
|
"""
|
||||||
|
Service for managing masked calls and SMS for field employees.
|
||||||
|
|
||||||
|
Uses Twilio Proxy-like functionality where:
|
||||||
|
- A proxy number is assigned to each job session
|
||||||
|
- Employee and customer both call/text the proxy number
|
||||||
|
- Twilio webhooks route calls to the appropriate party
|
||||||
|
"""
|
||||||
|
|
||||||
|
# How long a masked session lasts (extends past job end)
|
||||||
|
SESSION_DURATION_HOURS = 4
|
||||||
|
|
||||||
|
def __init__(self, tenant):
|
||||||
|
"""
|
||||||
|
Initialize the service for a tenant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant: The tenant (business) making the call
|
||||||
|
"""
|
||||||
|
self.tenant = tenant
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self):
|
||||||
|
"""Lazy-load Twilio client using tenant's subaccount credentials."""
|
||||||
|
if self._client is None:
|
||||||
|
from twilio.rest import Client
|
||||||
|
|
||||||
|
# Check for tenant subaccount first
|
||||||
|
if self.tenant.twilio_subaccount_sid and self.tenant.twilio_subaccount_auth_token:
|
||||||
|
self._client = Client(
|
||||||
|
self.tenant.twilio_subaccount_sid,
|
||||||
|
self.tenant.twilio_subaccount_auth_token
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fall back to master account (not recommended for production)
|
||||||
|
master_sid = getattr(settings, 'TWILIO_ACCOUNT_SID', '')
|
||||||
|
master_token = getattr(settings, 'TWILIO_AUTH_TOKEN', '')
|
||||||
|
|
||||||
|
if not master_sid or not master_token:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Twilio is not configured for this business. "
|
||||||
|
"Please contact support."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._client = Client(master_sid, master_token)
|
||||||
|
logger.warning(
|
||||||
|
f"Using master Twilio account for tenant {self.tenant.name}. "
|
||||||
|
"This should be avoided in production."
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _get_or_create_session(
|
||||||
|
self,
|
||||||
|
event_id: int,
|
||||||
|
employee_phone: str,
|
||||||
|
customer_phone: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get existing masked session for an event, or create a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_id: The job/event ID
|
||||||
|
employee_phone: Employee's real phone number
|
||||||
|
customer_phone: Customer's real phone number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MaskedSession instance
|
||||||
|
"""
|
||||||
|
from smoothschedule.comms_credits.models import MaskedSession, ProxyPhoneNumber
|
||||||
|
|
||||||
|
# Check for existing active session
|
||||||
|
existing = MaskedSession.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event_id,
|
||||||
|
status=MaskedSession.Status.ACTIVE,
|
||||||
|
expires_at__gt=timezone.now()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update phone numbers if changed (shouldn't happen but be safe)
|
||||||
|
if existing.staff_phone != employee_phone or existing.customer_phone != customer_phone:
|
||||||
|
existing.staff_phone = employee_phone
|
||||||
|
existing.customer_phone = customer_phone
|
||||||
|
existing.save(update_fields=['staff_phone', 'customer_phone', 'updated_at'])
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Need to create a new session - get a proxy number
|
||||||
|
proxy_number = self._get_available_proxy_number()
|
||||||
|
if not proxy_number:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"No proxy numbers available. Please contact support."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the session
|
||||||
|
session = MaskedSession.objects.create(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event_id,
|
||||||
|
proxy_number=proxy_number,
|
||||||
|
customer_phone=customer_phone,
|
||||||
|
staff_phone=employee_phone,
|
||||||
|
expires_at=timezone.now() + timedelta(hours=self.SESSION_DURATION_HOURS),
|
||||||
|
status=MaskedSession.Status.ACTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark the proxy number as reserved for this session
|
||||||
|
proxy_number.status = ProxyPhoneNumber.Status.RESERVED
|
||||||
|
proxy_number.save(update_fields=['status', 'updated_at'])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created masked session {session.id} for event {event_id} "
|
||||||
|
f"using proxy {proxy_number.phone_number}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _get_available_proxy_number(self):
|
||||||
|
"""
|
||||||
|
Get an available proxy number from the pool.
|
||||||
|
|
||||||
|
Prefers:
|
||||||
|
1. Numbers already assigned to this tenant
|
||||||
|
2. Numbers in the shared pool (AVAILABLE status)
|
||||||
|
"""
|
||||||
|
from smoothschedule.comms_credits.models import ProxyPhoneNumber
|
||||||
|
|
||||||
|
# First, try tenant's assigned numbers
|
||||||
|
tenant_number = ProxyPhoneNumber.objects.filter(
|
||||||
|
assigned_tenant=self.tenant,
|
||||||
|
status=ProxyPhoneNumber.Status.ASSIGNED,
|
||||||
|
is_active=True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if tenant_number:
|
||||||
|
return tenant_number
|
||||||
|
|
||||||
|
# Fall back to shared pool
|
||||||
|
available = ProxyPhoneNumber.objects.filter(
|
||||||
|
status=ProxyPhoneNumber.Status.AVAILABLE,
|
||||||
|
is_active=True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
def _check_feature_permission(self):
|
||||||
|
"""Check if tenant has masked calling feature enabled."""
|
||||||
|
if not self.tenant.has_feature('can_use_masked_phone_numbers'):
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Masked calling is not available on your current plan. "
|
||||||
|
"Please upgrade to access this feature."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_credits(self, estimated_cost_cents: int = 50):
|
||||||
|
"""
|
||||||
|
Check if tenant has sufficient communication credits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
estimated_cost_cents: Estimated cost of the call/SMS
|
||||||
|
"""
|
||||||
|
from smoothschedule.comms_credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
try:
|
||||||
|
credits = CommunicationCredits.objects.get(tenant=self.tenant)
|
||||||
|
if credits.balance_cents < estimated_cost_cents:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Insufficient communication credits. "
|
||||||
|
f"Current balance: ${credits.balance_cents/100:.2f}"
|
||||||
|
)
|
||||||
|
except CommunicationCredits.DoesNotExist:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Communication credits not set up. "
|
||||||
|
"Please add credits to use calling features."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_customer_phone_for_event(self, event_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the customer's phone number for an event.
|
||||||
|
|
||||||
|
Looks up the customer participant and returns their phone.
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from schedule.models import Event, Participant
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
|
||||||
|
with schema_context(self.tenant.schema_name):
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(id=event_id)
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find customer participant
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
customer_participant = Participant.objects.filter(
|
||||||
|
event=event,
|
||||||
|
role=Participant.Role.CUSTOMER,
|
||||||
|
content_type=user_ct
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if customer_participant:
|
||||||
|
customer = customer_participant.content_object
|
||||||
|
if customer and hasattr(customer, 'phone') and customer.phone:
|
||||||
|
return customer.phone
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def initiate_call(
|
||||||
|
self,
|
||||||
|
event_id: int,
|
||||||
|
employee,
|
||||||
|
customer_phone: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Initiate a masked call from employee to customer.
|
||||||
|
|
||||||
|
The employee's phone will ring first. When they answer,
|
||||||
|
the customer's phone will be connected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_id: The job/event ID
|
||||||
|
employee: The employee User making the call
|
||||||
|
customer_phone: Customer's phone (optional, will look up from event)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with call_sid, proxy_number, status
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.models import FieldCallLog
|
||||||
|
|
||||||
|
# Check permissions and credits
|
||||||
|
self._check_feature_permission()
|
||||||
|
self._check_credits(50) # Voice calls cost more
|
||||||
|
|
||||||
|
# Get customer phone if not provided
|
||||||
|
if not customer_phone:
|
||||||
|
customer_phone = self._get_customer_phone_for_event(event_id)
|
||||||
|
if not customer_phone:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Customer phone number not found for this job."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get employee phone
|
||||||
|
if not employee.phone:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Your phone number is not set. Please update your profile."
|
||||||
|
)
|
||||||
|
|
||||||
|
employee_phone = employee.phone
|
||||||
|
|
||||||
|
# Get or create masked session
|
||||||
|
session = self._get_or_create_session(
|
||||||
|
event_id=event_id,
|
||||||
|
employee_phone=employee_phone,
|
||||||
|
customer_phone=customer_phone,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build callback URL for Twilio
|
||||||
|
callback_url = self._get_callback_url('voice', session.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create the call - connect employee first, then bridge to customer
|
||||||
|
call = self.client.calls.create(
|
||||||
|
to=employee_phone, # Call employee first
|
||||||
|
from_=session.proxy_number.phone_number,
|
||||||
|
url=callback_url,
|
||||||
|
status_callback=self._get_status_callback_url(session.id),
|
||||||
|
status_callback_event=['initiated', 'ringing', 'answered', 'completed'],
|
||||||
|
machine_detection='Enable', # Detect voicemail
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log the call
|
||||||
|
call_log = FieldCallLog.objects.create(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event_id,
|
||||||
|
call_type=FieldCallLog.CallType.VOICE,
|
||||||
|
direction=FieldCallLog.Direction.OUTBOUND,
|
||||||
|
status=FieldCallLog.Status.INITIATED,
|
||||||
|
employee=employee,
|
||||||
|
customer_phone=customer_phone,
|
||||||
|
proxy_number=session.proxy_number.phone_number,
|
||||||
|
twilio_call_sid=call.sid,
|
||||||
|
masked_session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Initiated call {call.sid} from employee {employee.id} "
|
||||||
|
f"to customer via proxy {session.proxy_number.phone_number}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'call_sid': call.sid,
|
||||||
|
'call_log_id': call_log.id,
|
||||||
|
'proxy_number': session.proxy_number.phone_number,
|
||||||
|
'status': 'initiated',
|
||||||
|
'message': 'Your phone will ring shortly. Answer to connect to the customer.',
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initiating call: {e}")
|
||||||
|
raise TwilioFieldCallError(f"Failed to initiate call: {str(e)}")
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def send_sms(
|
||||||
|
self,
|
||||||
|
event_id: int,
|
||||||
|
employee,
|
||||||
|
message: str,
|
||||||
|
customer_phone: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Send a masked SMS from employee to customer.
|
||||||
|
|
||||||
|
The customer will see the proxy number as the sender.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_id: The job/event ID
|
||||||
|
employee: The employee User sending the SMS
|
||||||
|
message: The message to send
|
||||||
|
customer_phone: Customer's phone (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with message_sid, status
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.models import FieldCallLog
|
||||||
|
|
||||||
|
# Check permissions and credits
|
||||||
|
self._check_feature_permission()
|
||||||
|
self._check_credits(5) # SMS costs less
|
||||||
|
|
||||||
|
# Validate message
|
||||||
|
if not message or len(message.strip()) == 0:
|
||||||
|
raise TwilioFieldCallError("Message cannot be empty")
|
||||||
|
|
||||||
|
if len(message) > 1600: # Twilio limit
|
||||||
|
raise TwilioFieldCallError("Message too long (max 1600 characters)")
|
||||||
|
|
||||||
|
# Get customer phone if not provided
|
||||||
|
if not customer_phone:
|
||||||
|
customer_phone = self._get_customer_phone_for_event(event_id)
|
||||||
|
if not customer_phone:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Customer phone number not found for this job."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not employee.phone:
|
||||||
|
raise TwilioFieldCallError(
|
||||||
|
"Your phone number is not set. Please update your profile."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get or create masked session
|
||||||
|
session = self._get_or_create_session(
|
||||||
|
event_id=event_id,
|
||||||
|
employee_phone=employee.phone,
|
||||||
|
customer_phone=customer_phone,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send the SMS
|
||||||
|
sms = self.client.messages.create(
|
||||||
|
to=customer_phone,
|
||||||
|
from_=session.proxy_number.phone_number,
|
||||||
|
body=message,
|
||||||
|
status_callback=self._get_sms_status_callback_url(session.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log the message
|
||||||
|
call_log = FieldCallLog.objects.create(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event_id,
|
||||||
|
call_type=FieldCallLog.CallType.SMS,
|
||||||
|
direction=FieldCallLog.Direction.OUTBOUND,
|
||||||
|
status=FieldCallLog.Status.COMPLETED, # SMS is instant
|
||||||
|
employee=employee,
|
||||||
|
customer_phone=customer_phone,
|
||||||
|
proxy_number=session.proxy_number.phone_number,
|
||||||
|
twilio_message_sid=sms.sid,
|
||||||
|
masked_session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update session SMS count
|
||||||
|
session.sms_count += 1
|
||||||
|
session.save(update_fields=['sms_count', 'updated_at'])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Sent SMS {sms.sid} from employee {employee.id} "
|
||||||
|
f"to customer via proxy {session.proxy_number.phone_number}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'message_sid': sms.sid,
|
||||||
|
'call_log_id': call_log.id,
|
||||||
|
'status': 'sent',
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending SMS: {e}")
|
||||||
|
raise TwilioFieldCallError(f"Failed to send SMS: {str(e)}")
|
||||||
|
|
||||||
|
def get_session_for_event(self, event_id: int):
|
||||||
|
"""
|
||||||
|
Get the active masked session for an event.
|
||||||
|
|
||||||
|
Returns None if no active session exists.
|
||||||
|
"""
|
||||||
|
from smoothschedule.comms_credits.models import MaskedSession
|
||||||
|
|
||||||
|
return MaskedSession.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event_id,
|
||||||
|
status=MaskedSession.Status.ACTIVE,
|
||||||
|
expires_at__gt=timezone.now()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def close_session(self, event_id: int):
|
||||||
|
"""
|
||||||
|
Close the masked session for an event.
|
||||||
|
|
||||||
|
Called when a job is completed to stop allowing calls/SMS.
|
||||||
|
"""
|
||||||
|
from smoothschedule.comms_credits.models import MaskedSession
|
||||||
|
|
||||||
|
session = self.get_session_for_event(event_id)
|
||||||
|
if session:
|
||||||
|
session.close()
|
||||||
|
logger.info(f"Closed masked session {session.id} for event {event_id}")
|
||||||
|
|
||||||
|
def get_call_history(self, event_id: int, limit: int = 20) -> list:
|
||||||
|
"""
|
||||||
|
Get call/SMS history for an event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_id: The job/event ID
|
||||||
|
limit: Maximum records to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FieldCallLog records
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.models import FieldCallLog
|
||||||
|
|
||||||
|
return list(
|
||||||
|
FieldCallLog.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
event_id=event_id
|
||||||
|
).select_related('employee')[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_callback_url(self, call_type: str, session_id: int) -> str:
|
||||||
|
"""Build Twilio webhook callback URL for voice calls."""
|
||||||
|
base_url = getattr(settings, 'TWILIO_WEBHOOK_BASE_URL', '')
|
||||||
|
if not base_url:
|
||||||
|
# Fall back to site URL
|
||||||
|
base_url = getattr(settings, 'SITE_URL', 'https://api.smoothschedule.com')
|
||||||
|
|
||||||
|
return f"{base_url}/api/mobile/twilio/voice/{session_id}/"
|
||||||
|
|
||||||
|
def _get_status_callback_url(self, session_id: int) -> str:
|
||||||
|
"""Build Twilio status callback URL."""
|
||||||
|
base_url = getattr(settings, 'TWILIO_WEBHOOK_BASE_URL', '')
|
||||||
|
if not base_url:
|
||||||
|
base_url = getattr(settings, 'SITE_URL', 'https://api.smoothschedule.com')
|
||||||
|
|
||||||
|
return f"{base_url}/api/mobile/twilio/voice-status/{session_id}/"
|
||||||
|
|
||||||
|
def _get_sms_status_callback_url(self, session_id: int) -> str:
|
||||||
|
"""Build Twilio SMS status callback URL."""
|
||||||
|
base_url = getattr(settings, 'TWILIO_WEBHOOK_BASE_URL', '')
|
||||||
|
if not base_url:
|
||||||
|
base_url = getattr(settings, 'SITE_URL', 'https://api.smoothschedule.com')
|
||||||
|
|
||||||
|
return f"{base_url}/api/mobile/twilio/sms-status/{session_id}/"
|
||||||
|
|
||||||
|
|
||||||
|
def handle_incoming_call(session_id: int, from_number: str) -> str:
|
||||||
|
"""
|
||||||
|
Handle an incoming call to a proxy number.
|
||||||
|
|
||||||
|
This is called by the Twilio webhook when someone calls the proxy.
|
||||||
|
Routes the call to the appropriate party.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The MaskedSession ID
|
||||||
|
from_number: The caller's phone number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TwiML response string
|
||||||
|
"""
|
||||||
|
from smoothschedule.comms_credits.models import MaskedSession
|
||||||
|
from twilio.twiml.voice_response import VoiceResponse
|
||||||
|
|
||||||
|
response = VoiceResponse()
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = MaskedSession.objects.select_related('proxy_number').get(id=session_id)
|
||||||
|
|
||||||
|
if not session.is_active():
|
||||||
|
response.say("This number is no longer in service for this appointment.")
|
||||||
|
response.hangup()
|
||||||
|
return str(response)
|
||||||
|
|
||||||
|
# Determine who to connect to
|
||||||
|
destination = session.get_destination_for_caller(from_number)
|
||||||
|
|
||||||
|
if not destination:
|
||||||
|
response.say("Unable to connect your call. Please try again later.")
|
||||||
|
response.hangup()
|
||||||
|
return str(response)
|
||||||
|
|
||||||
|
# Connect the call
|
||||||
|
response.dial(
|
||||||
|
destination,
|
||||||
|
caller_id=session.proxy_number.phone_number,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(response)
|
||||||
|
|
||||||
|
except MaskedSession.DoesNotExist:
|
||||||
|
response.say("This number is not currently in service.")
|
||||||
|
response.hangup()
|
||||||
|
return str(response)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_incoming_sms(session_id: int, from_number: str, body: str) -> str:
|
||||||
|
"""
|
||||||
|
Handle an incoming SMS to a proxy number.
|
||||||
|
|
||||||
|
Routes the SMS to the appropriate party.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The MaskedSession ID
|
||||||
|
from_number: The sender's phone number
|
||||||
|
body: The SMS body
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TwiML response string (empty for SMS)
|
||||||
|
"""
|
||||||
|
from smoothschedule.comms_credits.models import MaskedSession
|
||||||
|
from twilio.rest import Client
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = MaskedSession.objects.select_related(
|
||||||
|
'proxy_number', 'tenant'
|
||||||
|
).get(id=session_id)
|
||||||
|
|
||||||
|
if not session.is_active():
|
||||||
|
# Session expired - don't forward
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Determine where to forward
|
||||||
|
destination = session.get_destination_for_caller(from_number)
|
||||||
|
|
||||||
|
if not destination:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Get Twilio client for tenant
|
||||||
|
tenant = session.tenant
|
||||||
|
if tenant.twilio_subaccount_sid and tenant.twilio_subaccount_auth_token:
|
||||||
|
client = Client(
|
||||||
|
tenant.twilio_subaccount_sid,
|
||||||
|
tenant.twilio_subaccount_auth_token
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
client = Client(
|
||||||
|
django_settings.TWILIO_ACCOUNT_SID,
|
||||||
|
django_settings.TWILIO_AUTH_TOKEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Forward the SMS
|
||||||
|
client.messages.create(
|
||||||
|
to=destination,
|
||||||
|
from_=session.proxy_number.phone_number,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update session SMS count
|
||||||
|
session.sms_count += 1
|
||||||
|
session.save(update_fields=['sms_count', 'updated_at'])
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
except MaskedSession.DoesNotExist:
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error forwarding SMS: {e}")
|
||||||
|
return ""
|
||||||
271
smoothschedule/smoothschedule/field_mobile/tasks.py
Normal file
271
smoothschedule/smoothschedule/field_mobile/tasks.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Field Mobile Celery Tasks
|
||||||
|
|
||||||
|
Background tasks for notifications and cleanup.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from celery import shared_task
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_customer_status_notification(tenant_id, event_id, notification_type):
|
||||||
|
"""
|
||||||
|
Send a notification to the customer about a job status change.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: The tenant ID
|
||||||
|
event_id: The event/job ID
|
||||||
|
notification_type: One of 'en_route_notification', 'arrived_notification', 'completed_notification'
|
||||||
|
"""
|
||||||
|
from core.models import Tenant
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
from schedule.models import Event, Participant
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
logger.error(f"Tenant {tenant_id} not found")
|
||||||
|
return {'error': 'Tenant not found'}
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(id=event_id)
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
logger.error(f"Event {event_id} not found")
|
||||||
|
return {'error': 'Event not found'}
|
||||||
|
|
||||||
|
# Get customer from participants
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
customer_participant = Participant.objects.filter(
|
||||||
|
event=event,
|
||||||
|
role=Participant.Role.CUSTOMER,
|
||||||
|
content_type=user_ct
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not customer_participant:
|
||||||
|
logger.warning(f"No customer found for event {event_id}")
|
||||||
|
return {'error': 'No customer found'}
|
||||||
|
|
||||||
|
customer = customer_participant.content_object
|
||||||
|
if not customer:
|
||||||
|
return {'error': 'Customer object not found'}
|
||||||
|
|
||||||
|
# Determine notification content based on type
|
||||||
|
messages = {
|
||||||
|
'en_route_notification': {
|
||||||
|
'sms': f"Your technician from {tenant.name} is on the way! They should arrive soon.",
|
||||||
|
'subject': f"Technician En Route - {tenant.name}",
|
||||||
|
},
|
||||||
|
'arrived_notification': {
|
||||||
|
'sms': f"Your technician from {tenant.name} has arrived and is starting work.",
|
||||||
|
'subject': f"Technician Arrived - {tenant.name}",
|
||||||
|
},
|
||||||
|
'completed_notification': {
|
||||||
|
'sms': f"Your appointment with {tenant.name} has been completed. Thank you!",
|
||||||
|
'subject': f"Appointment Completed - {tenant.name}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content = messages.get(notification_type, {})
|
||||||
|
if not content:
|
||||||
|
logger.warning(f"Unknown notification type: {notification_type}")
|
||||||
|
return {'error': f'Unknown notification type: {notification_type}'}
|
||||||
|
|
||||||
|
# Send SMS if customer has phone and tenant has SMS enabled
|
||||||
|
if customer.phone and tenant.can_use_sms_reminders:
|
||||||
|
try:
|
||||||
|
send_sms_notification.delay(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
phone_number=customer.phone,
|
||||||
|
message=content['sms'],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error queuing SMS: {e}")
|
||||||
|
|
||||||
|
# Send email notification
|
||||||
|
if customer.email:
|
||||||
|
try:
|
||||||
|
send_email_notification.delay(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
email=customer.email,
|
||||||
|
subject=content['subject'],
|
||||||
|
message=content['sms'], # Use SMS content as email body for now
|
||||||
|
customer_name=customer.full_name or 'Customer',
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error queuing email: {e}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Queued {notification_type} for event {event_id}, "
|
||||||
|
f"customer: {customer.email}"
|
||||||
|
)
|
||||||
|
return {'success': True, 'notification_type': notification_type}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_sms_notification(tenant_id, phone_number, message):
|
||||||
|
"""
|
||||||
|
Send an SMS notification using the tenant's Twilio account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: The tenant ID
|
||||||
|
phone_number: Recipient phone number
|
||||||
|
message: SMS message body
|
||||||
|
"""
|
||||||
|
from core.models import Tenant
|
||||||
|
from smoothschedule.comms_credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
return {'error': 'Tenant not found'}
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
try:
|
||||||
|
credits = CommunicationCredits.objects.get(tenant=tenant)
|
||||||
|
if credits.balance_cents < 5: # Minimum for SMS
|
||||||
|
logger.warning(f"Insufficient credits for tenant {tenant.name}")
|
||||||
|
return {'error': 'Insufficient credits'}
|
||||||
|
except CommunicationCredits.DoesNotExist:
|
||||||
|
return {'error': 'Credits not configured'}
|
||||||
|
|
||||||
|
# Get Twilio client
|
||||||
|
if not tenant.twilio_subaccount_sid:
|
||||||
|
return {'error': 'Twilio not configured'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from twilio.rest import Client
|
||||||
|
|
||||||
|
client = Client(
|
||||||
|
tenant.twilio_subaccount_sid,
|
||||||
|
tenant.twilio_subaccount_auth_token
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use tenant's phone number or default
|
||||||
|
from_number = tenant.twilio_phone_number
|
||||||
|
if not from_number:
|
||||||
|
from_number = getattr(settings, 'TWILIO_DEFAULT_FROM_NUMBER', '')
|
||||||
|
|
||||||
|
if not from_number:
|
||||||
|
return {'error': 'No from number configured'}
|
||||||
|
|
||||||
|
# Send SMS
|
||||||
|
sms = client.messages.create(
|
||||||
|
to=phone_number,
|
||||||
|
from_=from_number,
|
||||||
|
body=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct credits
|
||||||
|
credits.deduct(
|
||||||
|
5, # SMS cost in cents
|
||||||
|
f"Status notification SMS to {phone_number[-4:]}",
|
||||||
|
reference_type='notification_sms',
|
||||||
|
reference_id=sms.sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Sent SMS notification {sms.sid} to {phone_number[-4:]}")
|
||||||
|
return {'success': True, 'message_sid': sms.sid}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending SMS: {e}")
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_email_notification(tenant_id, email, subject, message, customer_name='Customer'):
|
||||||
|
"""
|
||||||
|
Send an email notification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: The tenant ID
|
||||||
|
email: Recipient email address
|
||||||
|
subject: Email subject
|
||||||
|
message: Email body
|
||||||
|
customer_name: Customer's name for personalization
|
||||||
|
"""
|
||||||
|
from core.models import Tenant
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
return {'error': 'Tenant not found'}
|
||||||
|
|
||||||
|
# Build email body
|
||||||
|
email_body = f"""
|
||||||
|
Hi {customer_name},
|
||||||
|
|
||||||
|
{message}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{tenant.name}
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from_email = tenant.contact_email or settings.DEFAULT_FROM_EMAIL
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
email_body,
|
||||||
|
from_email,
|
||||||
|
[email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Sent email notification to {email}")
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending email: {e}")
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_old_location_data(days_to_keep=30):
|
||||||
|
"""
|
||||||
|
Clean up old location tracking data.
|
||||||
|
|
||||||
|
Removes location updates older than the specified number of days.
|
||||||
|
This is a privacy measure to not retain location data indefinitely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days_to_keep: Number of days of data to retain (default 30)
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.models import EmployeeLocationUpdate
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timezone.timedelta(days=days_to_keep)
|
||||||
|
|
||||||
|
deleted_count, _ = EmployeeLocationUpdate.objects.filter(
|
||||||
|
created_at__lt=cutoff
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
logger.info(f"Deleted {deleted_count} old location updates (older than {days_to_keep} days)")
|
||||||
|
return {'deleted': deleted_count}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_old_status_history(days_to_keep=365):
|
||||||
|
"""
|
||||||
|
Clean up old status history records.
|
||||||
|
|
||||||
|
Keeps status history for longer (1 year default) for auditing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days_to_keep: Number of days of data to retain (default 365)
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.models import EventStatusHistory
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timezone.timedelta(days=days_to_keep)
|
||||||
|
|
||||||
|
deleted_count, _ = EventStatusHistory.objects.filter(
|
||||||
|
changed_at__lt=cutoff
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
logger.info(f"Deleted {deleted_count} old status history records")
|
||||||
|
return {'deleted': deleted_count}
|
||||||
63
smoothschedule/smoothschedule/field_mobile/urls.py
Normal file
63
smoothschedule/smoothschedule/field_mobile/urls.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Field Mobile URL Configuration
|
||||||
|
|
||||||
|
All endpoints are mounted under /api/mobile/
|
||||||
|
"""
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import (
|
||||||
|
# Employee profile
|
||||||
|
employee_profile_view,
|
||||||
|
logout_view,
|
||||||
|
# Job endpoints
|
||||||
|
job_list_view,
|
||||||
|
job_detail_view,
|
||||||
|
# Status management
|
||||||
|
set_status_view,
|
||||||
|
start_en_route_view,
|
||||||
|
reschedule_job_view,
|
||||||
|
# Location tracking
|
||||||
|
location_update_view,
|
||||||
|
location_route_view,
|
||||||
|
# Calling and SMS
|
||||||
|
call_customer_view,
|
||||||
|
send_sms_view,
|
||||||
|
call_history_view,
|
||||||
|
# Twilio webhooks
|
||||||
|
twilio_voice_webhook,
|
||||||
|
twilio_voice_status_webhook,
|
||||||
|
twilio_sms_webhook,
|
||||||
|
twilio_sms_status_webhook,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_name = 'field_mobile'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Employee profile & auth
|
||||||
|
path('me/', employee_profile_view, name='employee_profile'),
|
||||||
|
path('logout/', logout_view, name='logout'),
|
||||||
|
|
||||||
|
# Job management
|
||||||
|
path('jobs/', job_list_view, name='job_list'),
|
||||||
|
path('jobs/<int:job_id>/', job_detail_view, name='job_detail'),
|
||||||
|
|
||||||
|
# Status management
|
||||||
|
path('jobs/<int:job_id>/set_status/', set_status_view, name='set_status'),
|
||||||
|
path('jobs/<int:job_id>/start_en_route/', start_en_route_view, name='start_en_route'),
|
||||||
|
path('jobs/<int:job_id>/reschedule/', reschedule_job_view, name='reschedule_job'),
|
||||||
|
|
||||||
|
# Location tracking
|
||||||
|
path('jobs/<int:job_id>/location_update/', location_update_view, name='location_update'),
|
||||||
|
path('jobs/<int:job_id>/route/', location_route_view, name='location_route'),
|
||||||
|
|
||||||
|
# Calling and SMS
|
||||||
|
path('jobs/<int:job_id>/call_customer/', call_customer_view, name='call_customer'),
|
||||||
|
path('jobs/<int:job_id>/send_sms/', send_sms_view, name='send_sms'),
|
||||||
|
path('jobs/<int:job_id>/call_history/', call_history_view, name='call_history'),
|
||||||
|
|
||||||
|
# Twilio webhooks (public, no auth required)
|
||||||
|
path('twilio/voice/<int:session_id>/', twilio_voice_webhook, name='twilio_voice'),
|
||||||
|
path('twilio/voice-status/<int:session_id>/', twilio_voice_status_webhook, name='twilio_voice_status'),
|
||||||
|
path('twilio/sms/<int:session_id>/', twilio_sms_webhook, name='twilio_sms'),
|
||||||
|
path('twilio/sms-status/<int:session_id>/', twilio_sms_status_webhook, name='twilio_sms_status'),
|
||||||
|
]
|
||||||
887
smoothschedule/smoothschedule/field_mobile/views.py
Normal file
887
smoothschedule/smoothschedule/field_mobile/views.py
Normal file
@@ -0,0 +1,887 @@
|
|||||||
|
"""
|
||||||
|
Field Mobile API Views
|
||||||
|
|
||||||
|
REST API endpoints for the field employee mobile app.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
|
||||||
|
from schedule.models import Event, Participant, Resource
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
from smoothschedule.field_mobile.models import (
|
||||||
|
EventStatusHistory,
|
||||||
|
EmployeeLocationUpdate,
|
||||||
|
FieldCallLog,
|
||||||
|
)
|
||||||
|
from smoothschedule.field_mobile.serializers import (
|
||||||
|
JobListSerializer,
|
||||||
|
JobDetailSerializer,
|
||||||
|
SetStatusSerializer,
|
||||||
|
RescheduleJobSerializer,
|
||||||
|
StartEnRouteSerializer,
|
||||||
|
LocationUpdateSerializer,
|
||||||
|
LocationUpdateResponseSerializer,
|
||||||
|
InitiateCallSerializer,
|
||||||
|
InitiateCallResponseSerializer,
|
||||||
|
SendSMSSerializer,
|
||||||
|
SendSMSResponseSerializer,
|
||||||
|
CallHistorySerializer,
|
||||||
|
EmployeeProfileSerializer,
|
||||||
|
)
|
||||||
|
from smoothschedule.field_mobile.services import StatusMachine, TwilioFieldCallService
|
||||||
|
from smoothschedule.field_mobile.services.status_machine import StatusTransitionError
|
||||||
|
from smoothschedule.field_mobile.services.twilio_calls import TwilioFieldCallError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_from_user(user):
|
||||||
|
"""Get the tenant for an authenticated user."""
|
||||||
|
if not user.tenant:
|
||||||
|
return None
|
||||||
|
return user.tenant
|
||||||
|
|
||||||
|
|
||||||
|
def is_field_employee(user):
|
||||||
|
"""Check if user is a field employee (staff role)."""
|
||||||
|
return user.role in [
|
||||||
|
User.Role.TENANT_STAFF,
|
||||||
|
User.Role.TENANT_MANAGER,
|
||||||
|
User.Role.TENANT_OWNER,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_employee_jobs_queryset(user, tenant):
|
||||||
|
"""
|
||||||
|
Get the queryset of jobs assigned to an employee.
|
||||||
|
|
||||||
|
Returns events where the user is a participant with STAFF/RESOURCE role,
|
||||||
|
or where a Resource linked to the user is a participant.
|
||||||
|
"""
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
resource_ct = ContentType.objects.get_for_model(Resource)
|
||||||
|
|
||||||
|
# Get resource IDs linked to this user
|
||||||
|
user_resource_ids = list(
|
||||||
|
Resource.objects.filter(user=user).values_list('id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find events where user is directly a participant
|
||||||
|
user_event_ids = Participant.objects.filter(
|
||||||
|
content_type=user_ct,
|
||||||
|
object_id=user.id,
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE]
|
||||||
|
).values_list('event_id', flat=True)
|
||||||
|
|
||||||
|
# Find events where user's resource is a participant
|
||||||
|
resource_event_ids = []
|
||||||
|
if user_resource_ids:
|
||||||
|
resource_event_ids = Participant.objects.filter(
|
||||||
|
content_type=resource_ct,
|
||||||
|
object_id__in=user_resource_ids,
|
||||||
|
role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE]
|
||||||
|
).values_list('event_id', flat=True)
|
||||||
|
|
||||||
|
# Combine event IDs
|
||||||
|
all_event_ids = set(user_event_ids) | set(resource_event_ids)
|
||||||
|
|
||||||
|
return Event.objects.filter(id__in=all_event_ids)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Employee Profile Endpoint
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def employee_profile_view(request):
|
||||||
|
"""
|
||||||
|
Get the current employee's profile.
|
||||||
|
|
||||||
|
GET /api/mobile/me/
|
||||||
|
|
||||||
|
Returns employee info with business context and feature flags.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_field_employee(user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'This app is for field employees only'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = EmployeeProfileSerializer(user)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def logout_view(request):
|
||||||
|
"""
|
||||||
|
Logout from the mobile app.
|
||||||
|
|
||||||
|
POST /api/mobile/logout/
|
||||||
|
|
||||||
|
NOTE: We do NOT delete the token because DRF tokens are OneToOne with User,
|
||||||
|
meaning web and mobile share the same token. Deleting it here would log
|
||||||
|
the user out of the web app too.
|
||||||
|
|
||||||
|
The mobile app should clear its local token storage on logout.
|
||||||
|
"""
|
||||||
|
logger.info(f"User {request.user.id} logged out from mobile app (token preserved)")
|
||||||
|
return Response({'success': True, 'message': 'Logged out successfully'})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Job List and Detail Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def job_list_view(request):
|
||||||
|
"""
|
||||||
|
List jobs assigned to the current employee.
|
||||||
|
|
||||||
|
GET /api/mobile/jobs/
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- date: Filter by date (YYYY-MM-DD). Defaults to today.
|
||||||
|
- status: Filter by status (comma-separated)
|
||||||
|
- upcoming: If true, include future jobs
|
||||||
|
|
||||||
|
Returns jobs sorted by start time.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_field_employee(user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'This app is for field employees only'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
|
||||||
|
# Date filtering
|
||||||
|
date_str = request.query_params.get('date')
|
||||||
|
include_upcoming = request.query_params.get('upcoming', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
filter_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
queryset = queryset.filter(
|
||||||
|
start_time__date=filter_date
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid date format. Use YYYY-MM-DD'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
elif include_upcoming:
|
||||||
|
# Show today and future jobs (using business timezone)
|
||||||
|
import pytz
|
||||||
|
business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC
|
||||||
|
now_business = timezone.now().astimezone(business_tz)
|
||||||
|
today_start = now_business.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
queryset = queryset.filter(start_time__gte=today_start)
|
||||||
|
else:
|
||||||
|
# Default to today only (using business timezone)
|
||||||
|
import pytz
|
||||||
|
business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC
|
||||||
|
now_business = timezone.now().astimezone(business_tz)
|
||||||
|
today = now_business.date()
|
||||||
|
queryset = queryset.filter(start_time__date=today)
|
||||||
|
|
||||||
|
# Status filtering
|
||||||
|
status_filter = request.query_params.get('status')
|
||||||
|
if status_filter:
|
||||||
|
statuses = [s.strip().upper() for s in status_filter.split(',')]
|
||||||
|
queryset = queryset.filter(status__in=statuses)
|
||||||
|
|
||||||
|
# Order by start time
|
||||||
|
queryset = queryset.order_by('start_time')
|
||||||
|
|
||||||
|
# Prefetch for efficiency
|
||||||
|
queryset = queryset.select_related('service').prefetch_related('participants')
|
||||||
|
|
||||||
|
serializer = JobListSerializer(queryset, many=True)
|
||||||
|
return Response({
|
||||||
|
'jobs': serializer.data,
|
||||||
|
'count': queryset.count(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def job_detail_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Get details of a specific job.
|
||||||
|
|
||||||
|
GET /api/mobile/jobs/{job_id}/
|
||||||
|
|
||||||
|
Returns full job details including customer info, status history, etc.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
# Get the job and verify employee is assigned
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
serializer = JobDetailSerializer(job, context={'tenant': tenant, 'request': request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Status Management Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def set_status_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Update a job's status.
|
||||||
|
|
||||||
|
POST /api/mobile/jobs/{job_id}/set_status/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"status": "IN_PROGRESS",
|
||||||
|
"notes": "Optional notes",
|
||||||
|
"latitude": 40.7128,
|
||||||
|
"longitude": -74.0060
|
||||||
|
}
|
||||||
|
|
||||||
|
Validates the status transition and records in history.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = SetStatusSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
# Get the job
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
# Perform the transition
|
||||||
|
status_machine = StatusMachine(tenant, user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
job = status_machine.transition(
|
||||||
|
event=job,
|
||||||
|
new_status=serializer.validated_data['status'],
|
||||||
|
notes=serializer.validated_data.get('notes', ''),
|
||||||
|
latitude=serializer.validated_data.get('latitude'),
|
||||||
|
longitude=serializer.validated_data.get('longitude'),
|
||||||
|
source='mobile_app',
|
||||||
|
)
|
||||||
|
|
||||||
|
# If job is completed, close any masked call sessions
|
||||||
|
if job.status == Event.Status.COMPLETED:
|
||||||
|
try:
|
||||||
|
call_service = TwilioFieldCallService(tenant)
|
||||||
|
call_service.close_session(job.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing call session: {e}")
|
||||||
|
|
||||||
|
response_serializer = JobDetailSerializer(job, context={'tenant': tenant})
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'job': response_serializer.data,
|
||||||
|
})
|
||||||
|
|
||||||
|
except StatusTransitionError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def start_en_route_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Start traveling to a job.
|
||||||
|
|
||||||
|
POST /api/mobile/jobs/{job_id}/start_en_route/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"latitude": 40.7128,
|
||||||
|
"longitude": -74.0060,
|
||||||
|
"send_customer_notification": true
|
||||||
|
}
|
||||||
|
|
||||||
|
Changes status to EN_ROUTE and optionally notifies customer.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = StartEnRouteSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
status_machine = StatusMachine(tenant, user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
skip_notifications = not serializer.validated_data.get(
|
||||||
|
'send_customer_notification', True
|
||||||
|
)
|
||||||
|
|
||||||
|
job = status_machine.transition(
|
||||||
|
event=job,
|
||||||
|
new_status=Event.Status.EN_ROUTE,
|
||||||
|
latitude=serializer.validated_data.get('latitude'),
|
||||||
|
longitude=serializer.validated_data.get('longitude'),
|
||||||
|
source='mobile_app',
|
||||||
|
skip_notifications=skip_notifications,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_serializer = JobDetailSerializer(job, context={'tenant': tenant})
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'job': response_serializer.data,
|
||||||
|
'tracking_enabled': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
except StatusTransitionError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def reschedule_job_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Reschedule a job (change start time and/or duration).
|
||||||
|
|
||||||
|
POST /api/mobile/jobs/{job_id}/reschedule/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"start_time": "2024-01-15T10:30:00Z", // Optional: new start time
|
||||||
|
"end_time": "2024-01-15T11:30:00Z", // Optional: new end time
|
||||||
|
"duration_minutes": 60 // Optional: new duration
|
||||||
|
}
|
||||||
|
|
||||||
|
Requires the user's linked resource to have user_can_edit_schedule=True.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = RescheduleJobSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
# Get the job
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
# Check if user has permission to edit schedule
|
||||||
|
user_resources = Resource.objects.filter(user=user)
|
||||||
|
can_edit = any(r.user_can_edit_schedule for r in user_resources)
|
||||||
|
|
||||||
|
if not can_edit:
|
||||||
|
return Response(
|
||||||
|
{'error': 'You do not have permission to edit your schedule'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the job timing
|
||||||
|
start_time = serializer.validated_data.get('start_time')
|
||||||
|
end_time = serializer.validated_data.get('end_time')
|
||||||
|
duration_minutes = serializer.validated_data.get('duration_minutes')
|
||||||
|
|
||||||
|
if start_time:
|
||||||
|
job.start_time = start_time
|
||||||
|
|
||||||
|
if end_time:
|
||||||
|
job.end_time = end_time
|
||||||
|
elif duration_minutes and job.start_time:
|
||||||
|
# Calculate end_time from duration
|
||||||
|
job.end_time = job.start_time + timedelta(minutes=duration_minutes)
|
||||||
|
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Job {job_id} rescheduled by user {user.id}: "
|
||||||
|
f"start={job.start_time}, end={job.end_time}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response_serializer = JobDetailSerializer(job, context={'tenant': tenant, 'request': request})
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'job': response_serializer.data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Location Tracking Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def location_update_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Send a location update while en-route or in-progress.
|
||||||
|
|
||||||
|
POST /api/mobile/jobs/{job_id}/location_update/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"latitude": 40.7128,
|
||||||
|
"longitude": -74.0060,
|
||||||
|
"accuracy": 10.5,
|
||||||
|
"altitude": 50.0,
|
||||||
|
"heading": 180.0,
|
||||||
|
"speed": 15.5,
|
||||||
|
"timestamp": "2024-01-15T10:30:00Z",
|
||||||
|
"battery_level": 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns whether to continue tracking (stops on job completion).
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = LocationUpdateSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
# Verify job exists and user is assigned
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
# Check if tracking should continue
|
||||||
|
should_track = job.status in StatusMachine.TRACKING_STATUSES
|
||||||
|
|
||||||
|
if not should_track:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'should_continue_tracking': False,
|
||||||
|
'message': f'Location tracking not needed for status: {job.status}',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Record the location update
|
||||||
|
location = EmployeeLocationUpdate.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
|
employee=user,
|
||||||
|
event_id=job.id,
|
||||||
|
latitude=serializer.validated_data['latitude'],
|
||||||
|
longitude=serializer.validated_data['longitude'],
|
||||||
|
accuracy=serializer.validated_data.get('accuracy'),
|
||||||
|
altitude=serializer.validated_data.get('altitude'),
|
||||||
|
heading=serializer.validated_data.get('heading'),
|
||||||
|
speed=serializer.validated_data.get('speed'),
|
||||||
|
timestamp=serializer.validated_data['timestamp'],
|
||||||
|
battery_level=serializer.validated_data.get('battery_level'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast location update via WebSocket
|
||||||
|
# Find the resource linked to this user and broadcast to watchers
|
||||||
|
from schedule.models import Resource
|
||||||
|
from schedule.consumers import broadcast_resource_location_update
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
|
user_resources = Resource.objects.filter(user=user)
|
||||||
|
for resource in user_resources:
|
||||||
|
location_data = {
|
||||||
|
'latitude': float(location.latitude),
|
||||||
|
'longitude': float(location.longitude),
|
||||||
|
'accuracy': location.accuracy,
|
||||||
|
'heading': location.heading,
|
||||||
|
'speed': location.speed,
|
||||||
|
'timestamp': location.timestamp.isoformat(),
|
||||||
|
}
|
||||||
|
active_job_data = {
|
||||||
|
'id': job.id,
|
||||||
|
'title': job.title,
|
||||||
|
'status': job.status,
|
||||||
|
'status_display': job.get_status_display(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async_to_sync(broadcast_resource_location_update)(
|
||||||
|
resource_id=resource.id,
|
||||||
|
location_data=location_data,
|
||||||
|
active_job=active_job_data
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to broadcast location update: {e}")
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'should_continue_tracking': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def location_route_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Get the location history (route) for a job.
|
||||||
|
|
||||||
|
GET /api/mobile/jobs/{job_id}/route/
|
||||||
|
|
||||||
|
Returns a list of locations for drawing the route on a map.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
# Verify job exists
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
# Get route data
|
||||||
|
route = EmployeeLocationUpdate.get_route_for_event(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
event_id=job.id,
|
||||||
|
limit=200 # More points for detailed route
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert Decimal to float for JSON serialization
|
||||||
|
for point in route:
|
||||||
|
point['latitude'] = float(point['latitude'])
|
||||||
|
point['longitude'] = float(point['longitude'])
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'job_id': job.id,
|
||||||
|
'route': route,
|
||||||
|
'point_count': len(route),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Calling and SMS Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def call_customer_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Initiate a masked call to the customer.
|
||||||
|
|
||||||
|
POST /api/mobile/jobs/{job_id}/call_customer/
|
||||||
|
|
||||||
|
The employee's phone will ring. When answered, they'll be connected
|
||||||
|
to the customer. Both parties see only the proxy number.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
# Verify job exists and user is assigned
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
call_service = TwilioFieldCallService(tenant)
|
||||||
|
result = call_service.initiate_call(
|
||||||
|
event_id=job.id,
|
||||||
|
employee=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
except TwilioFieldCallError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error initiating call: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to initiate call. Please try again.'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def send_sms_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Send a masked SMS to the customer.
|
||||||
|
|
||||||
|
POST /api/mobile/jobs/{job_id}/send_sms/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"message": "I'll be there in 10 minutes!"
|
||||||
|
}
|
||||||
|
|
||||||
|
The customer sees the SMS from the proxy number.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = SendSMSSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
call_service = TwilioFieldCallService(tenant)
|
||||||
|
result = call_service.send_sms(
|
||||||
|
event_id=job.id,
|
||||||
|
employee=user,
|
||||||
|
message=serializer.validated_data['message'],
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
except TwilioFieldCallError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error sending SMS: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to send SMS. Please try again.'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def call_history_view(request, job_id):
|
||||||
|
"""
|
||||||
|
Get call and SMS history for a job.
|
||||||
|
|
||||||
|
GET /api/mobile/jobs/{job_id}/call_history/
|
||||||
|
|
||||||
|
Returns all calls and messages for this job.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
tenant = get_tenant_from_user(user)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No business associated with your account'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
queryset = get_employee_jobs_queryset(user, tenant)
|
||||||
|
job = get_object_or_404(queryset, id=job_id)
|
||||||
|
|
||||||
|
history = FieldCallLog.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
event_id=job.id
|
||||||
|
).select_related('employee').order_by('-initiated_at')[:50]
|
||||||
|
|
||||||
|
serializer = CallHistorySerializer(history, many=True)
|
||||||
|
return Response({
|
||||||
|
'job_id': job.id,
|
||||||
|
'history': serializer.data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Twilio Webhook Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def twilio_voice_webhook(request, session_id):
|
||||||
|
"""
|
||||||
|
Twilio webhook for handling voice calls.
|
||||||
|
|
||||||
|
POST /api/mobile/twilio/voice/{session_id}/
|
||||||
|
|
||||||
|
Called by Twilio when a call is initiated or received.
|
||||||
|
Returns TwiML to route the call.
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.services.twilio_calls import handle_incoming_call
|
||||||
|
|
||||||
|
from_number = request.data.get('From', '')
|
||||||
|
twiml = handle_incoming_call(session_id, from_number)
|
||||||
|
|
||||||
|
return HttpResponse(twiml, content_type='application/xml')
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def twilio_voice_status_webhook(request, session_id):
|
||||||
|
"""
|
||||||
|
Twilio webhook for call status updates.
|
||||||
|
|
||||||
|
POST /api/mobile/twilio/voice-status/{session_id}/
|
||||||
|
|
||||||
|
Updates the FieldCallLog with call status.
|
||||||
|
"""
|
||||||
|
call_sid = request.data.get('CallSid', '')
|
||||||
|
call_status = request.data.get('CallStatus', '')
|
||||||
|
duration = request.data.get('CallDuration', 0)
|
||||||
|
|
||||||
|
if call_sid:
|
||||||
|
try:
|
||||||
|
call_log = FieldCallLog.objects.get(twilio_call_sid=call_sid)
|
||||||
|
|
||||||
|
# Map Twilio status to our status
|
||||||
|
status_map = {
|
||||||
|
'queued': FieldCallLog.Status.INITIATED,
|
||||||
|
'ringing': FieldCallLog.Status.RINGING,
|
||||||
|
'in-progress': FieldCallLog.Status.IN_PROGRESS,
|
||||||
|
'completed': FieldCallLog.Status.COMPLETED,
|
||||||
|
'busy': FieldCallLog.Status.BUSY,
|
||||||
|
'no-answer': FieldCallLog.Status.NO_ANSWER,
|
||||||
|
'failed': FieldCallLog.Status.FAILED,
|
||||||
|
'canceled': FieldCallLog.Status.CANCELED,
|
||||||
|
}
|
||||||
|
|
||||||
|
call_log.status = status_map.get(call_status, FieldCallLog.Status.COMPLETED)
|
||||||
|
|
||||||
|
if call_status == 'completed':
|
||||||
|
call_log.ended_at = timezone.now()
|
||||||
|
call_log.duration_seconds = int(duration)
|
||||||
|
elif call_status == 'in-progress':
|
||||||
|
call_log.answered_at = timezone.now()
|
||||||
|
|
||||||
|
call_log.save()
|
||||||
|
|
||||||
|
# Update session voice usage
|
||||||
|
if call_log.masked_session and duration:
|
||||||
|
session = call_log.masked_session
|
||||||
|
session.voice_seconds += int(duration)
|
||||||
|
session.save(update_fields=['voice_seconds', 'updated_at'])
|
||||||
|
|
||||||
|
except FieldCallLog.DoesNotExist:
|
||||||
|
logger.warning(f"FieldCallLog not found for call SID: {call_sid}")
|
||||||
|
|
||||||
|
return HttpResponse('', status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def twilio_sms_webhook(request, session_id):
|
||||||
|
"""
|
||||||
|
Twilio webhook for incoming SMS.
|
||||||
|
|
||||||
|
POST /api/mobile/twilio/sms/{session_id}/
|
||||||
|
|
||||||
|
Forwards the SMS to the appropriate party.
|
||||||
|
"""
|
||||||
|
from smoothschedule.field_mobile.services.twilio_calls import handle_incoming_sms
|
||||||
|
|
||||||
|
from_number = request.data.get('From', '')
|
||||||
|
body = request.data.get('Body', '')
|
||||||
|
|
||||||
|
handle_incoming_sms(session_id, from_number, body)
|
||||||
|
|
||||||
|
# Return empty TwiML response
|
||||||
|
return HttpResponse('<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
content_type='application/xml')
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def twilio_sms_status_webhook(request, session_id):
|
||||||
|
"""
|
||||||
|
Twilio webhook for SMS status updates.
|
||||||
|
|
||||||
|
POST /api/mobile/twilio/sms-status/{session_id}/
|
||||||
|
"""
|
||||||
|
# SMS status updates are less critical, just log them
|
||||||
|
message_sid = request.data.get('MessageSid', '')
|
||||||
|
message_status = request.data.get('MessageStatus', '')
|
||||||
|
|
||||||
|
if message_sid and message_status:
|
||||||
|
logger.debug(f"SMS {message_sid} status: {message_status}")
|
||||||
|
|
||||||
|
return HttpResponse('', status=200)
|
||||||
@@ -83,9 +83,8 @@ def login_view(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# No MFA required or device is trusted - complete login
|
# No MFA required or device is trusted - complete login
|
||||||
# Create auth token
|
# Use get_or_create to allow multi-device logins with the same token
|
||||||
Token.objects.filter(user=user).delete()
|
token, created = Token.objects.get_or_create(user=user)
|
||||||
token = Token.objects.create(user=user)
|
|
||||||
|
|
||||||
# Update last login IP
|
# Update last login IP
|
||||||
client_ip = _get_client_ip(request)
|
client_ip = _get_client_ip(request)
|
||||||
@@ -154,6 +153,19 @@ def current_user_view(request):
|
|||||||
}
|
}
|
||||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||||
|
|
||||||
|
# Get linked resource info for staff users
|
||||||
|
linked_resource_id = None
|
||||||
|
can_edit_schedule = False
|
||||||
|
if user.tenant and user.role == User.Role.TENANT_STAFF:
|
||||||
|
try:
|
||||||
|
with schema_context(user.tenant.schema_name):
|
||||||
|
linked_resource = Resource.objects.filter(user=user).first()
|
||||||
|
if linked_resource:
|
||||||
|
linked_resource_id = linked_resource.id
|
||||||
|
can_edit_schedule = linked_resource.user_can_edit_schedule
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
@@ -171,6 +183,8 @@ def current_user_view(request):
|
|||||||
'permissions': user.permissions,
|
'permissions': user.permissions,
|
||||||
'can_invite_staff': user.can_invite_staff(),
|
'can_invite_staff': user.can_invite_staff(),
|
||||||
'can_access_tickets': user.can_access_tickets(),
|
'can_access_tickets': user.can_access_tickets(),
|
||||||
|
'can_edit_schedule': can_edit_schedule,
|
||||||
|
'linked_resource_id': linked_resource_id,
|
||||||
'quota_overages': quota_overages,
|
'quota_overages': quota_overages,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +313,19 @@ def _get_user_data(user):
|
|||||||
}
|
}
|
||||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||||
|
|
||||||
|
# Get linked resource info for staff users
|
||||||
|
linked_resource_id = None
|
||||||
|
can_edit_schedule = False
|
||||||
|
if user.tenant and user.role == User.Role.TENANT_STAFF:
|
||||||
|
try:
|
||||||
|
with schema_context(user.tenant.schema_name):
|
||||||
|
linked_resource = Resource.objects.filter(user=user).first()
|
||||||
|
if linked_resource:
|
||||||
|
linked_resource_id = linked_resource.id
|
||||||
|
can_edit_schedule = linked_resource.user_can_edit_schedule
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
@@ -316,6 +343,8 @@ def _get_user_data(user):
|
|||||||
'permissions': user.permissions,
|
'permissions': user.permissions,
|
||||||
'can_invite_staff': user.can_invite_staff(),
|
'can_invite_staff': user.can_invite_staff(),
|
||||||
'can_access_tickets': user.can_access_tickets(),
|
'can_access_tickets': user.can_access_tickets(),
|
||||||
|
'can_edit_schedule': can_edit_schedule,
|
||||||
|
'linked_resource_id': linked_resource_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -773,8 +802,7 @@ def accept_invitation_view(request, token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create auth token for immediate login
|
# Create auth token for immediate login
|
||||||
Token.objects.filter(user=user).delete()
|
auth_token, _ = Token.objects.get_or_create(user=user)
|
||||||
auth_token = Token.objects.create(user=user)
|
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'access': auth_token.key,
|
'access': auth_token.key,
|
||||||
@@ -1012,7 +1040,7 @@ def signup_view(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 6. Generate Token
|
# 6. Generate Token
|
||||||
token = Token.objects.create(user=user)
|
token, _ = Token.objects.get_or_create(user=user)
|
||||||
|
|
||||||
# 7. Send Verification Email (optional, but good practice)
|
# 7. Send Verification Email (optional, but good practice)
|
||||||
# We can reuse send_verification_email logic or call it here
|
# We can reuse send_verification_email logic or call it here
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from django.db import close_old_connections
|
from django.db import close_old_connections, connection
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def get_user(token_key):
|
def get_user(token_key):
|
||||||
|
"""
|
||||||
|
Look up user by token key.
|
||||||
|
|
||||||
|
IMPORTANT: Tokens are stored in the public schema, so we need to
|
||||||
|
explicitly query from public schema in multi-tenant setup.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
token = Token.objects.select_related('user').get(key=token_key)
|
# Use public schema for token lookup (tokens are in shared apps)
|
||||||
return token.user
|
from django_tenants.utils import schema_context
|
||||||
|
with schema_context('public'):
|
||||||
|
token = Token.objects.select_related('user').get(key=token_key)
|
||||||
|
return token.user
|
||||||
except Token.DoesNotExist:
|
except Token.DoesNotExist:
|
||||||
return AnonymousUser()
|
return AnonymousUser()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"TokenAuthMiddleware: Error looking up token: {e}", flush=True)
|
||||||
|
return AnonymousUser()
|
||||||
|
|
||||||
class TokenAuthMiddleware:
|
class TokenAuthMiddleware:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user