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_API_URL=http://api.lvh.me:8000
|
||||
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": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@react-google-maps/api": "^2.20.7",
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
@@ -984,6 +985,22 @@
|
||||
"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": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -1098,6 +1115,36 @@
|
||||
"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": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz",
|
||||
@@ -1912,6 +1959,12 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -3480,6 +3533,15 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
@@ -3623,6 +3685,12 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -4797,6 +4865,15 @@
|
||||
"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": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@react-google-maps/api": "^2.20.7",
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
|
||||
@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
||||
|
||||
// Import pages
|
||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
||||
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||
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 HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
|
||||
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 PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||
@@ -667,9 +669,29 @@ const AppContent: React.FC = () => {
|
||||
path="/"
|
||||
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="/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/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
@@ -752,7 +774,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/customers"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
@@ -762,7 +784,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/services"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Services />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
@@ -772,7 +794,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/resources"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<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 { 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 {
|
||||
id: number;
|
||||
|
||||
@@ -42,7 +42,8 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
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 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}
|
||||
exact
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/scheduler"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.scheduler')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/tasks"
|
||||
icon={Clock}
|
||||
label={t('nav.tasks', 'Tasks')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins') || !canUse('tasks')}
|
||||
/>
|
||||
{!isStaff && (
|
||||
<SidebarItem
|
||||
to="/scheduler"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.scheduler')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{!isStaff && (
|
||||
<SidebarItem
|
||||
to="/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') && (
|
||||
<SidebarItem
|
||||
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,
|
||||
maxConcurrentEvents: r.max_concurrent_events ?? 1,
|
||||
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,
|
||||
maxConcurrentEvents: data.max_concurrent_events ?? 1,
|
||||
savedLaneCount: data.saved_lane_count,
|
||||
userCanEditSchedule: data.user_can_edit_schedule ?? false,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
@@ -65,13 +67,23 @@ export const useCreateResource = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
|
||||
const backendData = {
|
||||
const backendData: any = {
|
||||
name: resourceData.name,
|
||||
type: resourceData.type,
|
||||
user: resourceData.userId ? parseInt(resourceData.userId) : null,
|
||||
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);
|
||||
return data;
|
||||
},
|
||||
@@ -101,6 +113,9 @@ export const useUpdateResource = () => {
|
||||
if (updates.savedLaneCount !== undefined) {
|
||||
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);
|
||||
return data;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useResources, useCreateResource, useUpdateResource } from '../hooks/use
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useStaff, StaffMember } from '../hooks/useStaff';
|
||||
import ResourceCalendar from '../components/ResourceCalendar';
|
||||
import ResourceDetailModal from '../components/ResourceDetailModal';
|
||||
import Portal from '../components/Portal';
|
||||
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
||||
import {
|
||||
@@ -18,7 +19,8 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Pencil,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
MapPin
|
||||
} from 'lucide-react';
|
||||
|
||||
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 [editingResource, setEditingResource] = React.useState<Resource | 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)
|
||||
const overQuotaResourceIds = useMemo(
|
||||
@@ -60,6 +63,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
const [formMaxConcurrent, setFormMaxConcurrent] = React.useState(1);
|
||||
const [formMultilaneEnabled, setFormMultilaneEnabled] = React.useState(false);
|
||||
const [formSavedLaneCount, setFormSavedLaneCount] = React.useState<number | undefined>(undefined);
|
||||
const [formUserCanEditSchedule, setFormUserCanEditSchedule] = React.useState(false);
|
||||
|
||||
// Staff selection state
|
||||
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
|
||||
@@ -181,6 +185,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
setFormMaxConcurrent(editingResource.maxConcurrentEvents);
|
||||
setFormMultilaneEnabled(editingResource.maxConcurrentEvents > 1);
|
||||
setFormSavedLaneCount(editingResource.savedLaneCount);
|
||||
setFormUserCanEditSchedule(editingResource.userCanEditSchedule ?? false);
|
||||
// Pre-fill staff if editing a STAFF resource
|
||||
if (editingResource.type === 'STAFF' && editingResource.userId) {
|
||||
setSelectedStaffId(editingResource.userId);
|
||||
@@ -197,6 +202,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
setFormMaxConcurrent(1);
|
||||
setFormMultilaneEnabled(false);
|
||||
setFormSavedLaneCount(undefined);
|
||||
setFormUserCanEditSchedule(false);
|
||||
setSelectedStaffId(null);
|
||||
setStaffSearchQuery('');
|
||||
setDebouncedSearchQuery('');
|
||||
@@ -258,6 +264,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
maxConcurrentEvents: number;
|
||||
savedLaneCount: number | undefined;
|
||||
userId?: string;
|
||||
userCanEditSchedule?: boolean;
|
||||
} = {
|
||||
name: formName,
|
||||
type: formType,
|
||||
@@ -267,6 +274,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
|
||||
if (formType === 'STAFF' && selectedStaffId) {
|
||||
resourceData.userId = selectedStaffId;
|
||||
resourceData.userCanEditSchedule = formUserCanEditSchedule;
|
||||
}
|
||||
|
||||
if (editingResource) {
|
||||
@@ -409,6 +417,15 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<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
|
||||
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"
|
||||
@@ -646,6 +663,35 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
</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 */}
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@@ -682,6 +728,14 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
onClose={() => setCalendarResource(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Resource Detail Modal (with location tracking) */}
|
||||
{detailResource && (
|
||||
<ResourceDetailModal
|
||||
resource={detailResource}
|
||||
onClose={() => setDetailResource(null)}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
can_edit_schedule?: boolean;
|
||||
linked_resource_id?: number;
|
||||
permissions?: Record<string, boolean>;
|
||||
quota_overages?: QuotaOverage[];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Jobs List Screen
|
||||
*
|
||||
* 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';
|
||||
@@ -16,12 +17,15 @@ import {
|
||||
Alert,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
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 { useJobs } from '../../src/hooks/useJobs';
|
||||
import { DraggableJobBlock } from '../../src/components/DraggableJobBlock';
|
||||
import type { JobListItem, JobStatus } from '../../src/types';
|
||||
|
||||
const HOUR_HEIGHT = 60;
|
||||
@@ -149,91 +153,8 @@ function getWeekDays(baseDate: Date): Date[] {
|
||||
return days;
|
||||
}
|
||||
|
||||
function JobBlock({
|
||||
job,
|
||||
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>
|
||||
);
|
||||
}
|
||||
// Note: JobBlock functionality moved to DraggableJobBlock component
|
||||
// which supports drag-and-drop rescheduling and resize
|
||||
|
||||
function TimelineGrid({ viewMode }: { viewMode: ViewMode }) {
|
||||
const hours = [];
|
||||
@@ -302,12 +223,16 @@ function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) {
|
||||
export default function JobsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { user, logout } = useAuth();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
|
||||
// Check if user can edit schedule
|
||||
const canEditSchedule = user?.can_edit_schedule ?? false;
|
||||
|
||||
const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]);
|
||||
|
||||
// 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
|
||||
const scrollToNow = useCallback(() => {
|
||||
const now = new Date();
|
||||
@@ -409,23 +352,25 @@ export default function JobsScreen() {
|
||||
const jobsByDay = useMemo(() => {
|
||||
if (viewMode !== 'week') return {};
|
||||
|
||||
const grouped: Record<number, JobWithLane[]> = {};
|
||||
// First group raw jobs by day
|
||||
const tempGrouped: Record<number, JobListItem[]> = {};
|
||||
weekDays.forEach((_, index) => {
|
||||
grouped[index] = [];
|
||||
tempGrouped[index] = [];
|
||||
});
|
||||
|
||||
filteredJobs.forEach(job => {
|
||||
const jobDate = new Date(job.start_time);
|
||||
const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate));
|
||||
if (dayIndex !== -1) {
|
||||
grouped[dayIndex].push(job);
|
||||
tempGrouped[dayIndex].push(job);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate lane layout for each day
|
||||
Object.keys(grouped).forEach(key => {
|
||||
// Then calculate lane layout for each day
|
||||
const grouped: Record<number, JobWithLane[]> = {};
|
||||
Object.keys(tempGrouped).forEach(key => {
|
||||
const dayIndex = parseInt(key);
|
||||
grouped[dayIndex] = calculateLaneLayout(grouped[dayIndex]);
|
||||
grouped[dayIndex] = calculateLaneLayout(tempGrouped[dayIndex]);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
@@ -459,6 +404,7 @@ export default function JobsScreen() {
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
@@ -577,10 +523,12 @@ export default function JobsScreen() {
|
||||
]}>
|
||||
{viewMode === 'day' ? (
|
||||
dayJobsWithLanes.map((job) => (
|
||||
<JobBlock
|
||||
<DraggableJobBlock
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPress={() => handleJobPress(job.id)}
|
||||
onTimeChange={handleTimeChange}
|
||||
canEdit={canEditSchedule}
|
||||
viewMode={viewMode}
|
||||
laneIndex={job.laneIndex}
|
||||
totalLanes={job.totalLanes}
|
||||
@@ -589,10 +537,12 @@ export default function JobsScreen() {
|
||||
) : (
|
||||
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
|
||||
jobs.map((job) => (
|
||||
<JobBlock
|
||||
<DraggableJobBlock
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPress={() => handleJobPress(job.id)}
|
||||
onTimeChange={handleTimeChange}
|
||||
canEdit={false}
|
||||
viewMode={viewMode}
|
||||
dayIndex={parseInt(dayIndex)}
|
||||
laneIndex={job.laneIndex}
|
||||
@@ -616,6 +566,7 @@ export default function JobsScreen() {
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@ module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
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",
|
||||
"react": "^19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-maps": "1.20.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
},
|
||||
@@ -1366,6 +1368,22 @@
|
||||
"@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": {
|
||||
"version": "7.28.5",
|
||||
"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_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": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
||||
@@ -3151,6 +3181,12 @@
|
||||
"@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": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -3202,7 +3238,7 @@
|
||||
"version": "19.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@@ -5020,7 +5056,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
@@ -7292,6 +7328,21 @@
|
||||
"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": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
||||
@@ -10022,9 +10073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10040,6 +10091,19 @@
|
||||
"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": {
|
||||
"version": "3.2.2",
|
||||
"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": {
|
||||
"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",
|
||||
@@ -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": {
|
||||
"version": "5.6.2",
|
||||
"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": "*"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.81.5",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"react": "^19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-maps": "1.20.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
},
|
||||
|
||||
@@ -185,3 +185,44 @@ export async function rescheduleJob(
|
||||
);
|
||||
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_subdomain?: string;
|
||||
can_use_masked_calls?: boolean;
|
||||
can_edit_schedule?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# General
|
||||
# ------------------------------------------------------------------------------
|
||||
USE_DOCKER=yes
|
||||
DJANGO_CORS_ALLOW_ALL_ORIGINS=True
|
||||
IPYTHONDIR=/app/.ipython
|
||||
# Redis
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
@@ -10,18 +10,19 @@ django_asgi_app = get_asgi_application()
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
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
|
||||
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
"http": django_asgi_app,
|
||||
# Just HTTP for now. (We can add other protocols later.)
|
||||
"websocket": AuthMiddlewareStack(
|
||||
TokenAuthMiddleware(
|
||||
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
|
||||
"tickets", # New: Support tickets app
|
||||
"smoothschedule.comms_credits", # Communication credits and SMS/calling
|
||||
"smoothschedule.field_mobile", # Field employee mobile app
|
||||
# Your stuff: custom apps go here
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
|
||||
@@ -56,7 +56,7 @@ SECRET_KEY = env(
|
||||
default="JETIHIJaLl2niIyj134Crg2S2dTURSzyXtd02XPicYcjaK5lJb1otLmNHqs6ZVs0",
|
||||
)
|
||||
# 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
|
||||
# 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
|
||||
'smoothschedule.public_api', # Public API v1 for third-party integrations
|
||||
'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
|
||||
|
||||
@@ -68,6 +68,13 @@ urlpatterns = [
|
||||
|
||||
# API URLS
|
||||
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)
|
||||
path("stripe/", include("djstripe.urls", namespace="djstripe")),
|
||||
# Public API v1 (for third-party integrations)
|
||||
@@ -82,6 +89,8 @@ urlpatterns += [
|
||||
path("contracts/", include("contracts.urls")),
|
||||
# Communication Credits API
|
||||
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
|
||||
path("tickets/", include("tickets.urls")),
|
||||
# Notifications API
|
||||
@@ -103,13 +112,6 @@ urlpatterns += [
|
||||
# Hijack (masquerade) API
|
||||
path("auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
||||
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
|
||||
path("business/current/", current_business_view, name="current_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')
|
||||
if subdomain:
|
||||
Tenant = get_tenant_model()
|
||||
tenant = None
|
||||
|
||||
# First try by schema_name (for backwards compatibility)
|
||||
try:
|
||||
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:
|
||||
# Invalid subdomain in header - ignore or could raise 400
|
||||
pass
|
||||
# Try looking up by domain (subdomain matching)
|
||||
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):
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
# 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:
|
||||
ordering = ['name']
|
||||
indexes = [models.Index(fields=['is_active', 'name'])]
|
||||
@@ -246,6 +252,8 @@ class Event(models.Model):
|
||||
"""
|
||||
class Status(models.TextChoices):
|
||||
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'
|
||||
COMPLETED = 'COMPLETED', 'Completed'
|
||||
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',
|
||||
'buffer_duration', 'is_active', 'capacity_description',
|
||||
'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']
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Handles:
|
||||
2. Rescheduling Celery tasks when events are modified (time/duration changes)
|
||||
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
|
||||
4. Cancelling tasks when Events are deleted or cancelled
|
||||
5. Broadcasting real-time updates via WebSocket for calendar sync
|
||||
"""
|
||||
import logging
|
||||
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__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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')
|
||||
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
|
||||
logger.info(f"Event '{instance}' was cancelled, cancelling all plugin tasks")
|
||||
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:
|
||||
- Must be authenticated
|
||||
- Staff members cannot access resources (owners/managers only)
|
||||
- Subject to MAX_RESOURCES quota (hard block on creation)
|
||||
|
||||
The HasQuota permission prevents creating resources when tenant
|
||||
@@ -91,6 +92,7 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
||||
Return resources for the current tenant.
|
||||
|
||||
CRITICAL: Validates user belongs to the current tenant.
|
||||
Staff members are denied access to resources.
|
||||
"""
|
||||
queryset = Resource.objects.all()
|
||||
|
||||
@@ -98,6 +100,10 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
||||
if not user.is_authenticated:
|
||||
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
|
||||
request_tenant = getattr(self.request, 'tenant', None)
|
||||
if user.tenant and request_tenant:
|
||||
@@ -106,6 +112,32 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
||||
|
||||
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):
|
||||
"""Create resource (quota-checked by HasQuota permission)"""
|
||||
serializer.save()
|
||||
@@ -114,6 +146,108 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
||||
"""Update resource"""
|
||||
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):
|
||||
"""
|
||||
@@ -257,6 +391,10 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
||||
API endpoint for managing Customers.
|
||||
|
||||
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
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -266,6 +404,56 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ['email', 'created_at']
|
||||
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):
|
||||
"""
|
||||
Return customers for the current tenant, filtered by sandbox mode.
|
||||
@@ -343,6 +531,7 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
|
||||
Permissions:
|
||||
- Must be authenticated
|
||||
- Staff members cannot access services
|
||||
- Subject to MAX_SERVICES quota (hard block on creation)
|
||||
"""
|
||||
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 = ['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):
|
||||
"""Return services for the current tenant, optionally including inactive ones."""
|
||||
queryset = Service.objects.all()
|
||||
@@ -362,6 +587,10 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
if not user.is_authenticated:
|
||||
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
|
||||
request_tenant = getattr(self.request, 'tenant', None)
|
||||
if user.tenant and request_tenant:
|
||||
@@ -384,6 +613,7 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
Expects: { "order": [1, 3, 2, 5, 4] }
|
||||
Where the list contains service IDs in the desired display order.
|
||||
"""
|
||||
self._check_staff_permission()
|
||||
order = request.data.get('order', [])
|
||||
|
||||
if not isinstance(order, list):
|
||||
@@ -560,6 +790,11 @@ class ScheduledTaskViewSet(viewsets.ModelViewSet):
|
||||
"""Check if tenant has permission to access scheduled tasks."""
|
||||
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)
|
||||
if tenant:
|
||||
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
|
||||
# Create auth token
|
||||
Token.objects.filter(user=user).delete()
|
||||
token = Token.objects.create(user=user)
|
||||
# Use get_or_create to allow multi-device logins with the same token
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
# Update last login IP
|
||||
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())
|
||||
|
||||
# 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 = {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
@@ -171,6 +183,8 @@ def current_user_view(request):
|
||||
'permissions': user.permissions,
|
||||
'can_invite_staff': user.can_invite_staff(),
|
||||
'can_access_tickets': user.can_access_tickets(),
|
||||
'can_edit_schedule': can_edit_schedule,
|
||||
'linked_resource_id': linked_resource_id,
|
||||
'quota_overages': quota_overages,
|
||||
}
|
||||
|
||||
@@ -299,6 +313,19 @@ def _get_user_data(user):
|
||||
}
|
||||
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 {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
@@ -316,6 +343,8 @@ def _get_user_data(user):
|
||||
'permissions': user.permissions,
|
||||
'can_invite_staff': user.can_invite_staff(),
|
||||
'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
|
||||
Token.objects.filter(user=user).delete()
|
||||
auth_token = Token.objects.create(user=user)
|
||||
auth_token, _ = Token.objects.get_or_create(user=user)
|
||||
|
||||
response_data = {
|
||||
'access': auth_token.key,
|
||||
@@ -1012,7 +1040,7 @@ def signup_view(request):
|
||||
)
|
||||
|
||||
# 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)
|
||||
# We can reuse send_verification_email logic or call it here
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
from channels.db import database_sync_to_async
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
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
|
||||
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:
|
||||
token = Token.objects.select_related('user').get(key=token_key)
|
||||
return token.user
|
||||
# Use public schema for token lookup (tokens are in shared apps)
|
||||
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:
|
||||
return AnonymousUser()
|
||||
except Exception as e:
|
||||
print(f"TokenAuthMiddleware: Error looking up token: {e}", flush=True)
|
||||
return AnonymousUser()
|
||||
|
||||
class TokenAuthMiddleware:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user