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:
poduck
2025-12-07 02:23:00 -05:00
parent 61882b300f
commit 01020861c7
48 changed files with 6156 additions and 148 deletions

View File

@@ -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=

View File

@@ -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",

View File

@@ -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",

View File

@@ -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="/" />

View 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;

View File

@@ -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;

View File

@@ -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"

View 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] }),
};
};

View File

@@ -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;

View File

@@ -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>
);
};

View 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;

View 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;

View File

@@ -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[];
}

View File

@@ -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>
);
}

View File

@@ -2,5 +2,6 @@ module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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,
};
}

View 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;

View File

@@ -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 {

View File

@@ -1,6 +1,7 @@
# General
# ------------------------------------------------------------------------------
USE_DOCKER=yes
DJANGO_CORS_ALLOW_ALL_ORIGINS=True
IPYTHONDIR=/app/.ipython
# Redis
# ------------------------------------------------------------------------------

View File

@@ -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
)
)
),

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"),

View File

@@ -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):

View 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}")

View File

@@ -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),
),
]

View File

@@ -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

View 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()),
]

View File

@@ -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']

View File

@@ -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}")

View File

@@ -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'):

View File

@@ -0,0 +1 @@
# Field Mobile App - Backend API for field employee mobile app

View 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

View File

@@ -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')],
},
),
]

View 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})"

View 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

View File

@@ -0,0 +1,5 @@
# Field Mobile Services
from .status_machine import StatusMachine
from .twilio_calls import TwilioFieldCallService
__all__ = ['StatusMachine', 'TwilioFieldCallService']

View File

@@ -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]
)

View File

@@ -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 ""

View 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}

View 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'),
]

View 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)

View File

@@ -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

View File

@@ -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:
"""