From 61882b300f2a089d18b4d561602c9e5739cfb2fa Mon Sep 17 00:00:00 2001 From: poduck Date: Sun, 7 Dec 2025 01:23:24 -0500 Subject: [PATCH] feat(mobile): Add field app with date range navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add React Native Expo field app for mobile staff - Use main /appointments/ endpoint with date range support - Add X-Business-Subdomain header for tenant context - Support day/week view navigation - Remove WebSocket console logging from frontend - Update AppointmentStatus type to include all backend statuses - Add responsive status legend to scheduler header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/hooks/useAppointmentWebSocket.ts | 145 +- frontend/src/pages/OwnerScheduler.tsx | 157 +- frontend/src/types.ts | 8 +- mobile/field-app/.env.example | 10 + mobile/field-app/.gitignore | 40 + mobile/field-app/app.json | 58 + mobile/field-app/app/(auth)/_layout.tsx | 41 + mobile/field-app/app/(auth)/job/[id].tsx | 1083 ++ mobile/field-app/app/(auth)/jobs.tsx | 924 ++ mobile/field-app/app/_layout.tsx | 38 + mobile/field-app/app/index.tsx | 20 + mobile/field-app/app/login.tsx | 234 + mobile/field-app/assets/adaptive-icon.png | Bin 0 -> 9242 bytes mobile/field-app/assets/icon.png | Bin 0 -> 9242 bytes mobile/field-app/assets/logo.png | Bin 0 -> 9242 bytes mobile/field-app/assets/splash.png | Bin 0 -> 9242 bytes mobile/field-app/babel.config.js | 6 + mobile/field-app/package-lock.json | 12571 ++++++++++++++++ mobile/field-app/package.json | 43 + mobile/field-app/src/api/auth.ts | 35 + mobile/field-app/src/api/client.ts | 45 + mobile/field-app/src/api/jobs.ts | 187 + mobile/field-app/src/config/api.ts | 23 + mobile/field-app/src/hooks/useAuth.tsx | 109 + mobile/field-app/src/hooks/useJobs.tsx | 223 + mobile/field-app/src/services/location.ts | 132 + mobile/field-app/src/services/storage.ts | 67 + mobile/field-app/src/services/websocket.ts | 241 + mobile/field-app/src/types/index.ts | 164 + mobile/field-app/tsconfig.json | 16 + 30 files changed, 16529 insertions(+), 91 deletions(-) create mode 100644 mobile/field-app/.env.example create mode 100644 mobile/field-app/.gitignore create mode 100644 mobile/field-app/app.json create mode 100644 mobile/field-app/app/(auth)/_layout.tsx create mode 100644 mobile/field-app/app/(auth)/job/[id].tsx create mode 100644 mobile/field-app/app/(auth)/jobs.tsx create mode 100644 mobile/field-app/app/_layout.tsx create mode 100644 mobile/field-app/app/index.tsx create mode 100644 mobile/field-app/app/login.tsx create mode 100644 mobile/field-app/assets/adaptive-icon.png create mode 100644 mobile/field-app/assets/icon.png create mode 100644 mobile/field-app/assets/logo.png create mode 100644 mobile/field-app/assets/splash.png create mode 100644 mobile/field-app/babel.config.js create mode 100644 mobile/field-app/package-lock.json create mode 100644 mobile/field-app/package.json create mode 100644 mobile/field-app/src/api/auth.ts create mode 100644 mobile/field-app/src/api/client.ts create mode 100644 mobile/field-app/src/api/jobs.ts create mode 100644 mobile/field-app/src/config/api.ts create mode 100644 mobile/field-app/src/hooks/useAuth.tsx create mode 100644 mobile/field-app/src/hooks/useJobs.tsx create mode 100644 mobile/field-app/src/services/location.ts create mode 100644 mobile/field-app/src/services/storage.ts create mode 100644 mobile/field-app/src/services/websocket.ts create mode 100644 mobile/field-app/src/types/index.ts create mode 100644 mobile/field-app/tsconfig.json diff --git a/frontend/src/hooks/useAppointmentWebSocket.ts b/frontend/src/hooks/useAppointmentWebSocket.ts index 4800738..5e7e741 100644 --- a/frontend/src/hooks/useAppointmentWebSocket.ts +++ b/frontend/src/hooks/useAppointmentWebSocket.ts @@ -10,23 +10,31 @@ import { getSubdomain } from '../api/config'; import { getWebSocketUrl } from '../utils/domain'; import { Appointment } from '../types'; -interface WebSocketMessage { - type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong'; - appointment?: { - id: string; - business_id: string; - service_id: string; - resource_id: string | null; - customer_id: string; - customer_name: string; - start_time: string; - end_time: string; - duration_minutes: number; - status: string; - notes: string; +interface WebSocketEvent { + id: number; + title: string; + start_time: string; + end_time: string; + status: string; + notes: string; + service?: { + id: number; + name: string; + duration: number; + price: string; }; - appointment_id?: string; +} + +interface WebSocketMessage { + type: 'connection_established' | 'event_created' | 'event_updated' | 'event_deleted' | 'event_status_changed' | 'pong'; + event?: WebSocketEvent; + event_id?: number; + old_status?: string; + new_status?: string; + changed_fields?: string[]; message?: string; + user_id?: number; + groups?: string[]; } interface UseAppointmentWebSocketOptions { @@ -36,25 +44,26 @@ interface UseAppointmentWebSocketOptions { onError?: (error: Event) => void; } -// WebSocket is not yet implemented in the backend - disable for now -const WEBSOCKET_ENABLED = false; +// Enable WebSocket for real-time calendar updates +const WEBSOCKET_ENABLED = true; /** - * Transform backend appointment format to frontend format + * Transform backend event format to frontend appointment format */ -function transformAppointment(data: WebSocketMessage['appointment']): Appointment | null { +function transformEvent(data: WebSocketEvent): Partial | null { if (!data) return null; + const startTime = new Date(data.start_time); + const endTime = new Date(data.end_time); + const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000); + return { - id: data.id, - resourceId: data.resource_id, - customerId: data.customer_id, - customerName: data.customer_name, - serviceId: data.service_id, - startTime: new Date(data.start_time), - durationMinutes: data.duration_minutes, + id: String(data.id), + startTime, + durationMinutes, status: data.status as Appointment['status'], notes: data.notes, + serviceId: data.service ? String(data.service.id) : undefined, }; } @@ -92,12 +101,18 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions const token = getCookie('access_token'); const subdomain = getSubdomain(); - if (!token || !subdomain) { + if (!token) { return null; } // Use the getWebSocketUrl utility from domain.ts - return `${getWebSocketUrl()}appointments/?token=${token}&subdomain=${subdomain}`; + // Backend uses /ws/calendar/ endpoint with token query param + const baseUrl = getWebSocketUrl(); + let url = `${baseUrl}calendar/?token=${token}`; + if (subdomain) { + url += `&subdomain=${subdomain}`; + } + return url; }; const updateQueryCache = useCallback((message: WebSocketMessage) => { @@ -109,27 +124,46 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions if (!old) return old; switch (message.type) { - case 'appointment_created': { - const newAppointment = transformAppointment(message.appointment); - if (!newAppointment) return old; + case 'event_created': { + if (!message.event) return old; + const eventUpdate = transformEvent(message.event); + if (!eventUpdate) return old; + const eventId = String(message.event.id); // Check if appointment already exists (avoid duplicates) - if (old.some(apt => apt.id === newAppointment.id)) { + if (old.some(apt => apt.id === eventId)) { return old; } - return [...old, newAppointment]; + // Note: We only have partial data from WebSocket, so we trigger a refetch + // to get full appointment data (customer info, etc.) + queryClient.invalidateQueries({ queryKey: ['appointments'] }); + return old; } - case 'appointment_updated': { - const updatedAppointment = transformAppointment(message.appointment); - if (!updatedAppointment) return old; - return old.map(apt => - apt.id === updatedAppointment.id ? updatedAppointment : apt - ); + case 'event_updated': + case 'event_status_changed': { + if (!message.event) return old; + const eventUpdate = transformEvent(message.event); + if (!eventUpdate) return old; + const eventId = String(message.event.id); + + // Update the matching appointment with new data + const updated = old.map(apt => { + if (apt.id === eventId) { + return { + ...apt, + ...eventUpdate, + }; + } + return apt; + }); + + return updated; } - case 'appointment_deleted': { - if (!message.appointment_id) return old; - return old.filter(apt => apt.id !== message.appointment_id); + case 'event_deleted': { + if (!message.event_id) return old; + const eventId = String(message.event_id); + return old.filter(apt => apt.id !== eventId); } default: @@ -160,7 +194,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions const url = getWsUrl(); if (!url) { - console.log('WebSocket: Missing token or subdomain, skipping connection'); return; } @@ -169,7 +202,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions wsRef.current.close(); } - console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***')); const ws = new WebSocket(url); ws.onopen = () => { @@ -179,7 +211,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions return; } - console.log('WebSocket: Connected'); reconnectAttemptsRef.current = 0; setIsConnected(true); onConnectedRef.current?.(); @@ -204,40 +235,37 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions switch (message.type) { case 'connection_established': - console.log('WebSocket: Connection confirmed -', message.message); - break; case 'pong': - // Heartbeat response, ignore + // Heartbeat/connection responses, ignore break; - case 'appointment_created': - case 'appointment_updated': - case 'appointment_deleted': - console.log('WebSocket: Received', message.type); + case 'event_created': + case 'event_updated': + case 'event_deleted': + case 'event_status_changed': updateQueryCache(message); break; default: - console.log('WebSocket: Unknown message type', message); + // Unknown message type, ignore + break; } } catch (err) { - console.error('WebSocket: Failed to parse message', err); + // Silently ignore parse errors } }; ws.onerror = (error) => { - // Only log error if not aborted (StrictMode cleanup causes expected errors) + // Only call error callback if not aborted if (!effectAborted) { - console.error('WebSocket: Error', error); onErrorRef.current?.(error); } }; ws.onclose = (event) => { - // Don't log or handle if effect was aborted (expected during StrictMode) + // Don't handle if effect was aborted (expected during StrictMode) if (effectAborted) { return; } - console.log('WebSocket: Disconnected', event.code, event.reason); setIsConnected(false); onDisconnectedRef.current?.(); @@ -250,7 +278,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions // Only attempt reconnection if not cleaning up if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) { const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); - console.log(`WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`); reconnectTimeoutRef.current = setTimeout(() => { reconnectAttemptsRef.current++; diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx index 82058a2..4bfd3c1 100644 --- a/frontend/src/pages/OwnerScheduler.tsx +++ b/frontend/src/pages/OwnerScheduler.tsx @@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Appointment, AppointmentStatus, User, Business, Resource } from '../types'; -import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight, Check, AlertTriangle } from 'lucide-react'; +import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight, ChevronDown, Check, AlertTriangle } from 'lucide-react'; import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments'; import { useResources } from '../hooks/useResources'; import { useServices } from '../hooks/useServices'; @@ -119,14 +119,18 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const [editDateTime, setEditDateTime] = useState(''); const [editResource, setEditResource] = useState(''); const [editDuration, setEditDuration] = useState(0); - const [editStatus, setEditStatus] = useState('CONFIRMED'); + const [editStatus, setEditStatus] = useState('SCHEDULED'); // Filter state const [showFilterMenu, setShowFilterMenu] = useState(false); - const [filterStatuses, setFilterStatuses] = useState>(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'])); + const [showStatusLegend, setShowStatusLegend] = useState(false); + const [filterStatuses, setFilterStatuses] = useState>(new Set([ + 'PENDING', 'CONFIRMED', 'SCHEDULED', 'EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'CANCELED', 'NO_SHOW', 'NOSHOW' + ])); const [filterResources, setFilterResources] = useState>(new Set()); // Empty means all const [filterServices, setFilterServices] = useState>(new Set()); // Empty means all const filterMenuRef = useRef(null); + const statusLegendRef = useRef(null); // Update edit state when selected appointment changes useEffect(() => { @@ -204,19 +208,22 @@ const OwnerScheduler: React.FC = ({ user, business }) => { } }, [overlayWheelHandler]); - // Close filter menu when clicking outside + // Close dropdowns when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) { setShowFilterMenu(false); } + if (statusLegendRef.current && !statusLegendRef.current.contains(e.target as Node)) { + setShowStatusLegend(false); + } }; - if (showFilterMenu) { + if (showFilterMenu || showStatusLegend) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } - }, [showFilterMenu]); + }, [showFilterMenu, showStatusLegend]); // Filter toggle helpers const toggleStatusFilter = (status: AppointmentStatus) => { @@ -249,13 +256,18 @@ const OwnerScheduler: React.FC = ({ user, business }) => { setFilterServices(newSet); }; + // All statuses that should be shown by default + const DEFAULT_STATUSES: AppointmentStatus[] = [ + 'PENDING', 'CONFIRMED', 'SCHEDULED', 'EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'CANCELED', 'NO_SHOW', 'NOSHOW' + ]; + const clearAllFilters = () => { - setFilterStatuses(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'])); + setFilterStatuses(new Set(DEFAULT_STATUSES)); setFilterResources(new Set()); setFilterServices(new Set()); }; - const hasActiveFilters = filterStatuses.size < 5 || filterResources.size > 0 || filterServices.size > 0; + const hasActiveFilters = filterStatuses.size < DEFAULT_STATUSES.length || filterResources.size > 0 || filterServices.size > 0; // Scroll to current time on mount (centered in view) useEffect(() => { @@ -576,9 +588,19 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel); const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => { - if (status === 'COMPLETED') return 'bg-green-100 border-green-500 text-green-900 dark:bg-green-900/50 dark:border-green-500 dark:text-green-200'; - if (status === 'NO_SHOW') return 'bg-orange-100 border-orange-500 text-orange-900 dark:bg-orange-900/50 dark:border-orange-500 dark:text-orange-200'; - if (status === 'CANCELLED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'; + // Completed statuses + if (status === 'COMPLETED' || status === 'PAID') return 'bg-green-100 border-green-500 text-green-900 dark:bg-green-900/50 dark:border-green-500 dark:text-green-200'; + // No-show + if (status === 'NO_SHOW' || status === 'NOSHOW') return 'bg-orange-100 border-orange-500 text-orange-900 dark:bg-orange-900/50 dark:border-orange-500 dark:text-orange-200'; + // Cancelled + if (status === 'CANCELLED' || status === 'CANCELED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'; + // En route - yellow/amber to show staff is traveling + if (status === 'EN_ROUTE') return 'bg-amber-100 border-amber-500 text-amber-900 dark:bg-amber-900/50 dark:border-amber-500 dark:text-amber-200'; + // In progress - teal/cyan to show work is happening + if (status === 'IN_PROGRESS') return 'bg-teal-100 border-teal-500 text-teal-900 dark:bg-teal-900/50 dark:border-teal-500 dark:text-teal-200'; + // Awaiting payment + if (status === 'AWAITING_PAYMENT') return 'bg-purple-100 border-purple-500 text-purple-900 dark:bg-purple-900/50 dark:border-purple-500 dark:text-purple-200'; + // Time-based colors for scheduled appointments const now = new Date(); if (now > endTime) return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200'; if (now >= startTime && now <= endTime) return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200'; @@ -587,9 +609,19 @@ const OwnerScheduler: React.FC = ({ user, business }) => { // Simplified status colors for month view (no border classes) const getMonthStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => { - if (status === 'COMPLETED') return 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800/50'; - if (status === 'NO_SHOW') return 'bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-200 hover:bg-orange-200 dark:hover:bg-orange-800/50'; - if (status === 'CANCELLED') return 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-75 hover:bg-gray-200 dark:hover:bg-gray-600'; + // Completed statuses + if (status === 'COMPLETED' || status === 'PAID') return 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800/50'; + // No-show + if (status === 'NO_SHOW' || status === 'NOSHOW') return 'bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-200 hover:bg-orange-200 dark:hover:bg-orange-800/50'; + // Cancelled + if (status === 'CANCELLED' || status === 'CANCELED') return 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-75 hover:bg-gray-200 dark:hover:bg-gray-600'; + // En route - amber + if (status === 'EN_ROUTE') return 'bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-200 hover:bg-amber-200 dark:hover:bg-amber-800/50'; + // In progress - teal + if (status === 'IN_PROGRESS') return 'bg-teal-100 dark:bg-teal-900/50 text-teal-800 dark:text-teal-200 hover:bg-teal-200 dark:hover:bg-teal-800/50'; + // Awaiting payment + if (status === 'AWAITING_PAYMENT') return 'bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800/50'; + // Time-based colors const now = new Date(); if (now > endTime) return 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800/50'; if (now >= startTime && now <= endTime) return 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200 hover:bg-yellow-200 dark:hover:bg-yellow-800/50'; @@ -1056,32 +1088,90 @@ const OwnerScheduler: React.FC = ({ user, business }) => { - {/* Status Legend */} -
+ {/* Status Legend - Hidden on smaller screens, shown on 2xl+ (1536px) */} +
Status: -
+
- Upcoming + Scheduled
-
+
+ En Route +
+
+
In Progress
-
-
- Overdue -
Completed
+
+
+ Awaiting Payment +
Cancelled
+
+
+ No Show +
+ {/* Status Legend Dropdown - Shown on screens smaller than 2xl */} +
+ + {showStatusLegend && ( +
+
+
+
+ Scheduled +
+
+
+ En Route +
+
+
+ In Progress +
+
+
+ Completed +
+
+
+ Awaiting Payment +
+
+
+ Cancelled +
+
+
+ No Show +
+
+
+ )} +