feat(mobile): Add field app with date range navigation

- 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 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-07 01:23:24 -05:00
parent 46b154e957
commit 61882b300f
30 changed files with 16529 additions and 91 deletions

View File

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

View File

@@ -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<OwnerSchedulerProps> = ({ user, business }) => {
const [editDateTime, setEditDateTime] = useState('');
const [editResource, setEditResource] = useState('');
const [editDuration, setEditDuration] = useState(0);
const [editStatus, setEditStatus] = useState<AppointmentStatus>('CONFIRMED');
const [editStatus, setEditStatus] = useState<AppointmentStatus>('SCHEDULED');
// Filter state
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [filterStatuses, setFilterStatuses] = useState<Set<AppointmentStatus>>(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW']));
const [showStatusLegend, setShowStatusLegend] = useState(false);
const [filterStatuses, setFilterStatuses] = useState<Set<AppointmentStatus>>(new Set([
'PENDING', 'CONFIRMED', 'SCHEDULED', 'EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'CANCELED', 'NO_SHOW', 'NOSHOW'
]));
const [filterResources, setFilterResources] = useState<Set<string>>(new Set()); // Empty means all
const [filterServices, setFilterServices] = useState<Set<string>>(new Set()); // Empty means all
const filterMenuRef = useRef<HTMLDivElement>(null);
const statusLegendRef = useRef<HTMLDivElement>(null);
// Update edit state when selected appointment changes
useEffect(() => {
@@ -204,19 +208,22 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ 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<OwnerSchedulerProps> = ({ 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<OwnerSchedulerProps> = ({ 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<OwnerSchedulerProps> = ({ 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<OwnerSchedulerProps> = ({ user, business }) => {
<Redo size={18} />
</button>
</div>
{/* Status Legend */}
<div className="flex items-center gap-3 border-l border-gray-300 dark:border-gray-600 pl-4">
{/* Status Legend - Hidden on smaller screens, shown on 2xl+ (1536px) */}
<div className="hidden 2xl:flex items-center gap-3 border-l border-gray-300 dark:border-gray-600 pl-4">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Status:</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-blue-100 border border-blue-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Upcoming</span>
<span className="text-xs text-gray-600 dark:text-gray-400">Scheduled</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-yellow-100 border border-yellow-500"></div>
<div className="w-3 h-3 rounded bg-amber-100 border border-amber-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">En Route</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-teal-100 border border-teal-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">In Progress</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-red-100 border border-red-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Overdue</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-green-100 border border-green-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Completed</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-purple-100 border border-purple-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Awaiting Payment</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-100 border border-gray-400"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Cancelled</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-orange-100 border border-orange-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">No Show</span>
</div>
</div>
</div>
{/* Status Legend Dropdown - Shown on screens smaller than 2xl */}
<div className="2xl:hidden relative" ref={statusLegendRef}>
<button
onClick={() => setShowStatusLegend(!showStatusLegend)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
>
<div className="flex gap-0.5">
<div className="w-2 h-2 rounded bg-blue-500"></div>
<div className="w-2 h-2 rounded bg-amber-500"></div>
<div className="w-2 h-2 rounded bg-teal-500"></div>
<div className="w-2 h-2 rounded bg-green-500"></div>
</div>
<span className="text-xs font-medium">Legend</span>
<ChevronDown size={14} className={`transition-transform ${showStatusLegend ? 'rotate-180' : ''}`} />
</button>
{showStatusLegend && (
<div className="absolute right-0 top-full mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-50 p-3 min-w-[180px]">
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-blue-100 border border-blue-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Scheduled</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-amber-100 border border-amber-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">En Route</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-teal-100 border border-teal-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">In Progress</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-green-100 border border-green-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Completed</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-purple-100 border border-purple-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Awaiting Payment</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-gray-100 border border-gray-400"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Cancelled</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-orange-100 border border-orange-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">No Show</span>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
@@ -1120,7 +1210,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Status</h4>
<div className="space-y-1">
{(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as AppointmentStatus[]).map(status => (
{(['SCHEDULED', 'EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW', 'AWAITING_PAYMENT'] as AppointmentStatus[]).map(status => (
<div
key={status}
onClick={() => toggleStatusFilter(status)}
@@ -1134,13 +1224,16 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
{filterStatuses.has(status) && <Check size={12} className="text-white" />}
</div>
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">
{status.toLowerCase().replace('_', ' ')}
{status.toLowerCase().replace(/_/g, ' ')}
</span>
<div className={`w-2 h-2 rounded-full ml-auto ${
status === 'COMPLETED' ? 'bg-green-500' :
status === 'CANCELLED' ? 'bg-gray-400' :
status === 'NO_SHOW' ? 'bg-orange-500' :
status === 'CONFIRMED' ? 'bg-blue-500' :
status === 'COMPLETED' || status === 'PAID' ? 'bg-green-500' :
status === 'CANCELLED' || status === 'CANCELED' ? 'bg-gray-400' :
status === 'NO_SHOW' || status === 'NOSHOW' ? 'bg-orange-500' :
status === 'EN_ROUTE' ? 'bg-amber-500' :
status === 'IN_PROGRESS' ? 'bg-teal-500' :
status === 'AWAITING_PAYMENT' ? 'bg-purple-500' :
status === 'SCHEDULED' ? 'bg-blue-500' :
'bg-yellow-400'
}`}></div>
</div>
@@ -1897,9 +1990,11 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
onChange={(e) => setEditStatus(e.target.value as AppointmentStatus)}
className="w-full px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-sm font-semibold text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="PENDING">Pending</option>
<option value="CONFIRMED">Confirmed</option>
<option value="SCHEDULED">Scheduled</option>
<option value="EN_ROUTE">En Route</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
<option value="AWAITING_PAYMENT">Awaiting Payment</option>
<option value="CANCELLED">Cancelled</option>
<option value="NO_SHOW">No Show</option>
</select>

View File

@@ -151,9 +151,15 @@ export interface Resource {
savedLaneCount?: number; // Remembered lane count when multilane is disabled
created_at?: string; // Used for quota overage calculation (oldest archived first)
is_archived_by_quota?: boolean; // True if archived due to quota overage
userCanEditSchedule?: boolean; // Allow linked user to edit their schedule regardless of role
}
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
// Backend uses: SCHEDULED, EN_ROUTE, IN_PROGRESS, CANCELED, COMPLETED, AWAITING_PAYMENT, PAID, NOSHOW
// Frontend aliases: PENDING (for SCHEDULED), CONFIRMED (for SCHEDULED), CANCELLED (for CANCELED), NO_SHOW (for NOSHOW)
export type AppointmentStatus =
| 'SCHEDULED' | 'EN_ROUTE' | 'IN_PROGRESS' | 'CANCELED' | 'COMPLETED' | 'AWAITING_PAYMENT' | 'PAID' | 'NOSHOW'
// Legacy aliases for frontend compatibility
| 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'NO_SHOW';
export interface Appointment {
id: string;

View File

@@ -0,0 +1,10 @@
# API Configuration
# For development, use your local backend URL
# For Android emulator, use 10.0.2.2 instead of localhost
EXPO_PUBLIC_API_BASE_URL=http://10.0.2.2:8000
# For iOS simulator, use localhost
# EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
# For production
# EXPO_PUBLIC_API_BASE_URL=https://api.smoothschedule.com

40
mobile/field-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Dependencies
node_modules/
# Expo
.expo/
dist/
expo-env.d.ts
# Native builds
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
ios/
android/
# Environment files
.env
.env.local
.env.*.local
# Editor
.idea/
.vscode/
# OS
.DS_Store
*.pem
# Debug
npm-debug.*
yarn-debug.*
yarn-error.*
# TypeScript
*.tsbuildinfo

58
mobile/field-app/app.json Normal file
View File

@@ -0,0 +1,58 @@
{
"expo": {
"name": "SmoothSchedule Field",
"slug": "smoothschedule-field-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "smoothschedule-field",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2563eb"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.smoothschedule.field",
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "We need your location to show your position on the map and update customers about your arrival.",
"NSLocationAlwaysAndWhenInUseUsageDescription": "We need your location in the background to update customers about your arrival while you're driving.",
"UIBackgroundModes": [
"location"
]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#2563eb"
},
"package": "com.smoothschedule.field",
"permissions": [
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION",
"ACCESS_BACKGROUND_LOCATION"
],
"usesCleartextTraffic": true
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow SmoothSchedule Field to use your location to update customers about your arrival."
}
],
"expo-asset",
"expo-font"
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@@ -0,0 +1,41 @@
/**
* Auth Group Layout
*
* Layout for authenticated screens with header navigation.
*/
import { Stack } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function AuthLayout() {
const insets = useSafeAreaInsets();
return (
<Stack
screenOptions={{
headerStyle: {
backgroundColor: '#2563eb',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
// Ensure header accounts for status bar on Android
headerStatusBarHeight: insets.top,
}}
>
<Stack.Screen
name="jobs"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="job/[id]"
options={{
title: 'Job Details',
}}
/>
</Stack>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,924 @@
/**
* Jobs List Screen
*
* Displays jobs in a timeline view with day/week toggle.
*/
import { useCallback, useState, useRef, useMemo } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
RefreshControl,
ActivityIndicator,
Alert,
Dimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { getJobColor, jobStatusColors } from '../../src/api/jobs';
import { useAuth } from '../../src/hooks/useAuth';
import { useJobs } from '../../src/hooks/useJobs';
import type { JobListItem, JobStatus } from '../../src/types';
const HOUR_HEIGHT = 60;
const SCREEN_WIDTH = Dimensions.get('window').width;
const DAY_COLUMN_WIDTH = (SCREEN_WIDTH - 50) / 7; // For week view
const DAY_VIEW_WIDTH = SCREEN_WIDTH - 70; // Width available for job blocks in day view
type ViewMode = 'day' | 'week';
// Lane layout calculation for overlapping appointments
interface JobWithLane extends JobListItem {
laneIndex: number;
totalLanes: number;
}
function calculateLaneLayout(jobs: JobListItem[]): JobWithLane[] {
if (jobs.length === 0) return [];
// Sort by start time
const sortedJobs = [...jobs].sort((a, b) =>
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
// Find overlapping groups and assign lanes
const result: JobWithLane[] = [];
const laneEndTimes: number[] = [];
// First pass: assign lane indices
const jobsWithLanes = sortedJobs.map((job) => {
const startTime = new Date(job.start_time).getTime();
const endTime = new Date(job.end_time).getTime();
// Find the first available lane
let laneIndex = 0;
while (laneIndex < laneEndTimes.length && laneEndTimes[laneIndex] > startTime) {
laneIndex++;
}
// Assign end time to this lane
laneEndTimes[laneIndex] = endTime;
return { ...job, laneIndex, totalLanes: 1 };
});
// Second pass: find overlapping groups and set totalLanes
// Group overlapping jobs together
const groups: JobWithLane[][] = [];
let currentGroup: JobWithLane[] = [];
let groupEndTime = 0;
jobsWithLanes.forEach((job) => {
const startTime = new Date(job.start_time).getTime();
const endTime = new Date(job.end_time).getTime();
if (currentGroup.length === 0 || startTime < groupEndTime) {
// Job overlaps with current group
currentGroup.push(job);
groupEndTime = Math.max(groupEndTime, endTime);
} else {
// Start a new group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [job];
groupEndTime = endTime;
}
});
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
// Set totalLanes for each job in its group
groups.forEach((group) => {
const maxLane = Math.max(...group.map(j => j.laneIndex)) + 1;
group.forEach((job) => {
job.totalLanes = maxLane;
result.push(job);
});
});
return result;
}
function formatTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
function getHourLabel(hour: number): string {
if (hour === 0) return '12 AM';
if (hour === 12) return '12 PM';
if (hour < 12) return `${hour} AM`;
return `${hour - 12} PM`;
}
function isSameDay(date1: Date, date2: Date): boolean {
// Compare using local date parts
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}
function parseJobDate(dateString: string): Date {
// Parse ISO date string and return a Date object
return new Date(dateString);
}
function getWeekDays(baseDate: Date): Date[] {
const days: Date[] = [];
const startOfWeek = new Date(baseDate);
startOfWeek.setDate(baseDate.getDate() - baseDate.getDay()); // Start on Sunday
for (let i = 0; i < 7; i++) {
const day = new Date(startOfWeek);
day.setDate(startOfWeek.getDate() + i);
days.push(day);
}
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>
);
}
function TimelineGrid({ viewMode }: { viewMode: ViewMode }) {
const hours = [];
for (let hour = 0; hour < 24; hour++) {
hours.push(hour);
}
return (
<View style={styles.timelineGrid}>
{hours.map((hour) => (
<View key={hour} style={styles.hourRow}>
<Text style={styles.hourLabel}>{getHourLabel(hour)}</Text>
<View style={styles.hourLine} />
</View>
))}
</View>
);
}
function WeekHeader({ weekDays, selectedDate }: { weekDays: Date[]; selectedDate: Date }) {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const today = new Date();
return (
<View style={styles.weekHeader}>
<View style={styles.weekHeaderSpacer} />
{weekDays.map((day, index) => {
const isToday = isSameDay(day, today);
const isSelected = isSameDay(day, selectedDate);
return (
<View key={index} style={styles.weekDayHeader}>
<Text style={[styles.weekDayName, isToday && styles.weekDayNameToday]}>
{dayNames[index]}
</Text>
<View style={[
styles.weekDayNumber,
isToday && styles.weekDayNumberToday,
]}>
<Text style={[
styles.weekDayNumberText,
isToday && styles.weekDayNumberTextToday,
]}>
{day.getDate()}
</Text>
</View>
</View>
);
})}
</View>
);
}
function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) {
const now = new Date();
const currentHour = now.getHours() + now.getMinutes() / 60;
const top = currentHour * HOUR_HEIGHT;
return (
<View style={[styles.currentTimeLine, { top }]}>
<View style={styles.currentTimeDot} />
<View style={styles.currentTimeLineBar} />
</View>
);
}
export default function JobsScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
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);
const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]);
// Calculate the date range to fetch based on view mode
const jobsQueryParams = useMemo(() => {
if (viewMode === 'day') {
// For day view, fetch just that day
const startOfDay = new Date(selectedDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(selectedDate);
endOfDay.setHours(23, 59, 59, 999);
return {
startDate: startOfDay.toISOString(),
endDate: endOfDay.toISOString(),
};
} else {
// For week view, fetch the entire week
const weekStart = new Date(weekDays[0]);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekDays[6]);
weekEnd.setHours(23, 59, 59, 999);
return {
startDate: weekStart.toISOString(),
endDate: weekEnd.toISOString(),
};
}
}, [selectedDate, viewMode, weekDays]);
// Use the jobs hook with WebSocket integration for real-time updates
const {
data,
isLoading,
error,
refresh,
isConnected,
} = useJobs(jobsQueryParams);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await refresh();
setRefreshing(false);
}, [refresh]);
const handleJobPress = (jobId: number) => {
router.push(`/(auth)/job/${jobId}`);
};
const handleLogout = () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
await logout();
router.replace('/login');
},
},
]
);
};
// Scroll to current time on mount
const scrollToNow = useCallback(() => {
const now = new Date();
const currentHour = now.getHours();
const scrollPosition = Math.max(0, (currentHour - 1) * HOUR_HEIGHT);
scrollRef.current?.scrollTo({ y: scrollPosition, animated: false });
}, []);
// Filter jobs based on view mode
const filteredJobs = useMemo(() => {
const jobs = data?.jobs || [];
if (viewMode === 'day') {
return jobs.filter(job => isSameDay(new Date(job.start_time), selectedDate));
}
// Week view - return all jobs for the week
const weekStart = weekDays[0];
const weekEnd = new Date(weekDays[6]);
weekEnd.setHours(23, 59, 59, 999);
return jobs.filter(job => {
const jobDate = new Date(job.start_time);
return jobDate >= weekStart && jobDate <= weekEnd;
});
}, [data?.jobs, viewMode, selectedDate, weekDays]);
// Calculate lane layout for day view (overlapping appointments)
const dayJobsWithLanes = useMemo(() => {
if (viewMode !== 'day') return [];
return calculateLaneLayout(filteredJobs);
}, [filteredJobs, viewMode]);
// Group jobs by day for week view with lane layout
const jobsByDay = useMemo(() => {
if (viewMode !== 'week') return {};
const grouped: Record<number, JobWithLane[]> = {};
weekDays.forEach((_, index) => {
grouped[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);
}
});
// Calculate lane layout for each day
Object.keys(grouped).forEach(key => {
const dayIndex = parseInt(key);
grouped[dayIndex] = calculateLaneLayout(grouped[dayIndex]);
});
return grouped;
}, [filteredJobs, viewMode, weekDays]);
if (isLoading && !refreshing) {
return (
<View style={[styles.centerContainer, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Loading jobs...</Text>
</View>
);
}
if (error) {
return (
<View style={[styles.centerContainer, { paddingTop: insets.top }]}>
<Ionicons name="alert-circle-outline" size={48} color="#ef4444" />
<Text style={styles.errorText}>Failed to load jobs</Text>
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
const today = selectedDate.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<View>
<Text style={styles.greeting}>Hello, {user?.name || 'there'}!</Text>
<Text style={styles.dateText}>{today}</Text>
</View>
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
<Ionicons name="log-out-outline" size={24} color="#fff" />
</TouchableOpacity>
</View>
{/* View Mode Toggle */}
{/* Date Navigation */}
<View style={styles.dateNavContainer}>
<TouchableOpacity
style={styles.navButton}
onPress={() => {
const newDate = new Date(selectedDate);
if (viewMode === 'day') {
newDate.setDate(newDate.getDate() - 1);
} else {
newDate.setDate(newDate.getDate() - 7);
}
setSelectedDate(newDate);
}}
>
<Ionicons name="chevron-back" size={24} color="#2563eb" />
</TouchableOpacity>
<TouchableOpacity
style={styles.todayButton}
onPress={() => setSelectedDate(new Date())}
>
<Text style={styles.todayButtonText}>Today</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.navButton}
onPress={() => {
const newDate = new Date(selectedDate);
if (viewMode === 'day') {
newDate.setDate(newDate.getDate() + 1);
} else {
newDate.setDate(newDate.getDate() + 7);
}
setSelectedDate(newDate);
}}
>
<Ionicons name="chevron-forward" size={24} color="#2563eb" />
</TouchableOpacity>
</View>
<View style={styles.viewModeContainer}>
<View style={styles.viewModeToggle}>
<TouchableOpacity
style={[styles.viewModeButton, viewMode === 'day' && styles.viewModeButtonActive]}
onPress={() => setViewMode('day')}
>
<Ionicons
name="today-outline"
size={18}
color={viewMode === 'day' ? '#fff' : '#6b7280'}
/>
<Text style={[styles.viewModeText, viewMode === 'day' && styles.viewModeTextActive]}>
Day
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.viewModeButton, viewMode === 'week' && styles.viewModeButtonActive]}
onPress={() => setViewMode('week')}
>
<Ionicons
name="calendar-outline"
size={18}
color={viewMode === 'week' ? '#fff' : '#6b7280'}
/>
<Text style={[styles.viewModeText, viewMode === 'week' && styles.viewModeTextActive]}>
Week
</Text>
</TouchableOpacity>
</View>
<Text style={styles.jobsCount}>
{filteredJobs.length} job{filteredJobs.length !== 1 ? 's' : ''}
</Text>
</View>
{/* Week Header (only in week view) */}
{viewMode === 'week' && (
<WeekHeader weekDays={weekDays} selectedDate={selectedDate} />
)}
<ScrollView
ref={scrollRef}
style={styles.timeline}
contentContainerStyle={styles.timelineContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#2563eb']}
tintColor="#2563eb"
/>
}
onLayout={scrollToNow}
showsVerticalScrollIndicator={true}
>
<TimelineGrid viewMode={viewMode} />
{viewMode === 'day' && isSameDay(selectedDate, new Date()) && (
<CurrentTimeLine viewMode={viewMode} />
)}
<View style={[
styles.jobsContainer,
viewMode === 'week' && styles.jobsContainerWeek,
]}>
{viewMode === 'day' ? (
dayJobsWithLanes.map((job) => (
<JobBlock
key={job.id}
job={job}
onPress={() => handleJobPress(job.id)}
viewMode={viewMode}
laneIndex={job.laneIndex}
totalLanes={job.totalLanes}
/>
))
) : (
Object.entries(jobsByDay).map(([dayIndex, jobs]) =>
jobs.map((job) => (
<JobBlock
key={job.id}
job={job}
onPress={() => handleJobPress(job.id)}
viewMode={viewMode}
dayIndex={parseInt(dayIndex)}
laneIndex={job.laneIndex}
totalLanes={job.totalLanes}
/>
))
)
)}
</View>
{filteredJobs.length === 0 && (
<View style={styles.emptyOverlay}>
<View style={styles.emptyContainer}>
<Ionicons name="calendar-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyTitle}>No Jobs Scheduled</Text>
<Text style={styles.emptyText}>
Pull down to refresh
</Text>
</View>
</View>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2563eb',
paddingHorizontal: 16,
paddingVertical: 16,
},
greeting: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
},
dateText: {
fontSize: 14,
color: 'rgba(255,255,255,0.8)',
marginTop: 2,
},
logoutButton: {
padding: 8,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 8,
},
dateNavContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#fff',
paddingVertical: 8,
gap: 16,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
navButton: {
padding: 8,
borderRadius: 8,
backgroundColor: '#f3f4f6',
},
todayButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: '#eff6ff',
},
todayButtonText: {
color: '#2563eb',
fontWeight: '600',
fontSize: 14,
},
viewModeContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fff',
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
viewModeToggle: {
flexDirection: 'row',
backgroundColor: '#f3f4f6',
borderRadius: 8,
padding: 2,
},
viewModeButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
gap: 4,
},
viewModeButtonActive: {
backgroundColor: '#2563eb',
},
viewModeText: {
fontSize: 14,
fontWeight: '500',
color: '#6b7280',
},
viewModeTextActive: {
color: '#fff',
},
jobsCount: {
fontSize: 14,
color: '#6b7280',
},
weekHeader: {
flexDirection: 'row',
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
paddingVertical: 8,
},
weekHeaderSpacer: {
width: 50,
},
weekDayHeader: {
width: DAY_COLUMN_WIDTH,
alignItems: 'center',
},
weekDayName: {
fontSize: 11,
color: '#9ca3af',
fontWeight: '500',
},
weekDayNameToday: {
color: '#2563eb',
},
weekDayNumber: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
},
weekDayNumberToday: {
backgroundColor: '#2563eb',
},
weekDayNumberText: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
},
weekDayNumberTextToday: {
color: '#fff',
},
timeline: {
flex: 1,
},
timelineContent: {
position: 'relative',
minHeight: 24 * HOUR_HEIGHT,
paddingBottom: 40,
},
timelineGrid: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
hourRow: {
flexDirection: 'row',
alignItems: 'flex-start',
height: HOUR_HEIGHT,
},
hourLabel: {
width: 50,
fontSize: 11,
color: '#9ca3af',
textAlign: 'right',
paddingRight: 8,
paddingTop: 2,
},
hourLine: {
flex: 1,
height: 1,
backgroundColor: '#e5e7eb',
marginTop: 8,
},
currentTimeLine: {
position: 'absolute',
left: 42,
right: 0,
flexDirection: 'row',
alignItems: 'center',
zIndex: 10,
},
currentTimeDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#ef4444',
},
currentTimeLineBar: {
flex: 1,
height: 2,
backgroundColor: '#ef4444',
},
jobsContainer: {
position: 'absolute',
top: 0,
left: 58,
right: 12,
},
jobsContainerWeek: {
left: 50,
right: 0,
},
jobBlock: {
position: 'absolute',
backgroundColor: '#fff',
borderRadius: 6,
borderLeftWidth: 3,
padding: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
overflow: 'hidden',
},
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,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
backgroundColor: '#f9fafb',
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: '#6b7280',
},
errorText: {
marginTop: 12,
fontSize: 16,
color: '#ef4444',
textAlign: 'center',
},
retryButton: {
marginTop: 16,
backgroundColor: '#2563eb',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
emptyOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainer: {
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.95)',
padding: 32,
borderRadius: 16,
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
color: '#6b7280',
marginTop: 12,
},
emptyText: {
fontSize: 14,
color: '#9ca3af',
marginTop: 4,
},
});

View File

@@ -0,0 +1,38 @@
/**
* Root Layout
*
* Sets up providers and global configuration.
*/
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from '../src/hooks/useAuth';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
export default function RootLayout() {
return (
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
{/* translucent={false} ensures content doesn't render behind status bar on Android */}
<StatusBar style="light" translucent={false} backgroundColor="#2563eb" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="(auth)" />
</Stack>
</AuthProvider>
</QueryClientProvider>
</SafeAreaProvider>
);
}

View File

@@ -0,0 +1,20 @@
/**
* Index - Redirects to the appropriate screen
*/
import { Redirect } from 'expo-router';
import { useAuth } from '../src/hooks/useAuth';
export default function Index() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return null;
}
if (isAuthenticated) {
return <Redirect href="/(auth)/jobs" />;
}
return <Redirect href="/login" />;
}

View File

@@ -0,0 +1,234 @@
/**
* Login Screen
*
* Handles employee authentication.
*/
import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
Image,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useAuth } from '../src/hooks/useAuth';
export default function LoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleLogin = async () => {
if (!email.trim()) {
Alert.alert('Error', 'Please enter your email');
return;
}
if (!password) {
Alert.alert('Error', 'Please enter your password');
return;
}
setIsSubmitting(true);
try {
await login({ email: email.trim().toLowerCase(), password });
router.replace('/(auth)/jobs');
} catch (error: any) {
const message = error?.response?.data?.error || error?.message || 'Login failed. Please try again.';
Alert.alert('Login Failed', message);
} finally {
setIsSubmitting(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<View style={styles.content}>
<View style={styles.header}>
<Image
source={require('../assets/logo.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.title}>SmoothSchedule</Text>
<Text style={styles.subtitle}>Field Employee App</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
placeholder="your.email@company.com"
placeholderTextColor="#9ca3af"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
editable={!isSubmitting}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="Enter your password"
placeholderTextColor="#9ca3af"
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
autoComplete="password"
editable={!isSubmitting}
/>
</View>
<TouchableOpacity
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleLogin}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>
Contact your manager if you need account access
</Text>
</View>
{/* Dev Quick Login */}
{__DEV__ && (
<TouchableOpacity
style={styles.devButton}
onPress={() => {
setEmail('timm50@hotmail.com');
setPassword('starry12');
}}
>
<Text style={styles.devButtonText}>Dev: Fill Test Credentials</Text>
</TouchableOpacity>
)}
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 24,
},
header: {
alignItems: 'center',
marginBottom: 48,
},
logo: {
width: 80,
height: 80,
marginBottom: 16,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#2563eb',
},
subtitle: {
fontSize: 16,
color: '#6b7280',
marginTop: 8,
},
form: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
inputContainer: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
marginBottom: 8,
},
input: {
backgroundColor: '#f9fafb',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: '#111827',
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
backgroundColor: '#93c5fd',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
footer: {
marginTop: 32,
alignItems: 'center',
},
footerText: {
color: '#9ca3af',
fontSize: 14,
textAlign: 'center',
},
devButton: {
marginTop: 24,
backgroundColor: '#fbbf24',
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
alignSelf: 'center',
},
devButtonText: {
color: '#78350f',
fontSize: 14,
fontWeight: '600',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

12571
mobile/field-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"name": "smoothschedule-field-app",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-navigation/native": "^7.0.14",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"babel-preset-expo": "^54.0.8",
"expo": "^54.0.0",
"expo-asset": "~12.0.11",
"expo-constants": "~18.0.11",
"expo-font": "~14.0.10",
"expo-linking": "~8.0.10",
"expo-location": "~19.0.8",
"expo-router": "~6.0.17",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"expo-task-manager": "~14.0.9",
"react": "^19.1.0",
"react-native": "0.81.5",
"react-native-maps": "1.20.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/react": "~19.1.10",
"eslint": "^9.0.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.8.3"
},
"private": true
}

View File

@@ -0,0 +1,35 @@
/**
* Authentication API
*/
import axios from 'axios';
import { API_BASE_URL } from '../config/api';
import type { LoginCredentials, AuthResponse, User } from '../types';
import apiClient from './client';
// Login uses the main auth endpoint, not the mobile API
const authAxios = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
export async function loginApi(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await authAxios.post<AuthResponse>('/auth/login/', credentials);
return response.data;
}
export async function getProfile(): Promise<User> {
const response = await apiClient.get<User>('/me/');
return response.data;
}
export async function logoutApi(): Promise<void> {
try {
await apiClient.post('/logout/');
} catch (error) {
// Ignore logout errors
}
}

View File

@@ -0,0 +1,45 @@
/**
* API Client
*
* Axios instance with authentication interceptors.
*/
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { API_MOBILE_URL } from '../config/api';
import { getAuthToken, clearAllAuthData } from '../services/storage';
const apiClient = axios.create({
baseURL: API_MOBILE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const token = await getAuthToken();
if (token) {
config.headers.Authorization = `Token ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Clear auth data on unauthorized
await clearAllAuthData();
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,187 @@
/**
* Jobs API
*/
import axios from 'axios';
import apiClient from './client';
import { API_BASE_URL } from '../config/api';
import { getAuthToken, getUserData } from '../services/storage';
import type {
JobsResponse,
JobDetail,
StatusChangeRequest,
LocationUpdateRequest,
RouteResponse,
CallResponse,
SMSRequest,
SMSResponse,
CallHistoryItem,
JobStatus,
JobListItem,
RescheduleRequest,
} from '../types';
// Colors matching web app OwnerScheduler.tsx
// Uses time-based logic for scheduled appointments
export const jobStatusColors: Record<JobStatus, string> = {
SCHEDULED: '#3b82f6', // blue - future scheduled (matches web default)
EN_ROUTE: '#facc15', // yellow - in transit (matches web in-progress)
IN_PROGRESS: '#facc15', // yellow - currently happening (matches web)
COMPLETED: '#22c55e', // green - finished (matches web)
CANCELED: '#9ca3af', // gray - cancelled (matches web)
NOSHOW: '#f97316', // orange - no show (matches web)
AWAITING_PAYMENT: '#f97316', // orange - awaiting payment
PAID: '#22c55e', // green - paid
};
// Returns time-aware color (like web app does for overdue appointments)
export function getJobColor(job: { status: JobStatus; start_time: string; end_time: string }): string {
const now = new Date();
const startTime = new Date(job.start_time);
const endTime = new Date(job.end_time);
// Terminal statuses have fixed colors
if (job.status === 'COMPLETED' || job.status === 'PAID') return '#22c55e'; // green
if (job.status === 'NOSHOW') return '#f97316'; // orange
if (job.status === 'CANCELED') return '#9ca3af'; // gray
// For active statuses, check timing
if (job.status === 'SCHEDULED') {
if (now > endTime) return '#ef4444'; // red - overdue (past end time)
if (now >= startTime && now <= endTime) return '#facc15'; // yellow - in progress window
return '#3b82f6'; // blue - future
}
// EN_ROUTE and IN_PROGRESS are active
return '#facc15'; // yellow
}
export const jobStatusLabels: Record<JobStatus, string> = {
SCHEDULED: 'Scheduled',
EN_ROUTE: 'En Route',
IN_PROGRESS: 'In Progress',
COMPLETED: 'Completed',
CANCELED: 'Canceled',
NOSHOW: 'No Show',
AWAITING_PAYMENT: 'Awaiting Payment',
PAID: 'Paid',
};
// Get the API URL for appointments endpoint
// Uses api.lvh.me in dev (resolves to local machine), api.smoothschedule.com in prod
function getAppointmentsApiUrl(): string {
if (__DEV__) {
return 'http://api.lvh.me:8000';
}
return 'https://api.smoothschedule.com';
}
export async function getJobs(params?: { startDate?: string; endDate?: string }): Promise<JobsResponse> {
// Use the main appointments endpoint with date range support
const queryParams: Record<string, string> = {};
if (params?.startDate) queryParams.start_date = params.startDate;
if (params?.endDate) queryParams.end_date = params.endDate;
const token = await getAuthToken();
const userData = await getUserData();
const subdomain = userData?.business_subdomain;
const apiUrl = getAppointmentsApiUrl();
const response = await axios.get<any[]>(`${apiUrl}/appointments/`, {
params: queryParams,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Token ${token}` }),
...(subdomain && { 'X-Business-Subdomain': subdomain }),
},
});
// Transform the response to match JobsResponse format
const jobs: JobListItem[] = response.data.map((apt: any) => {
// Calculate duration in minutes from start/end times
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,
};
});
return {
jobs,
count: jobs.length,
};
}
export async function getJobDetail(jobId: number): Promise<JobDetail> {
const response = await apiClient.get<JobDetail>(`/jobs/${jobId}/`);
return response.data;
}
export async function setJobStatus(
jobId: number,
data: StatusChangeRequest
): Promise<JobDetail> {
const response = await apiClient.post<JobDetail>(`/jobs/${jobId}/set_status/`, data);
return response.data;
}
export async function startEnRoute(
jobId: number,
data?: { latitude?: number; longitude?: number }
): Promise<JobDetail> {
const response = await apiClient.post<JobDetail>(`/jobs/${jobId}/start_en_route/`, data || {});
return response.data;
}
export async function updateLocation(
jobId: number,
data: LocationUpdateRequest
): Promise<{ success: boolean }> {
const response = await apiClient.post<{ success: boolean }>(
`/jobs/${jobId}/location_update/`,
data
);
return response.data;
}
export async function getJobRoute(jobId: number): Promise<RouteResponse> {
const response = await apiClient.get<RouteResponse>(`/jobs/${jobId}/route/`);
return response.data;
}
export async function callCustomer(jobId: number): Promise<CallResponse> {
const response = await apiClient.post<CallResponse>(`/jobs/${jobId}/call_customer/`);
return response.data;
}
export async function sendSMS(jobId: number, data: SMSRequest): Promise<SMSResponse> {
const response = await apiClient.post<SMSResponse>(`/jobs/${jobId}/send_sms/`, data);
return response.data;
}
export async function getCallHistory(jobId: number): Promise<CallHistoryItem[]> {
const response = await apiClient.get<CallHistoryItem[]>(`/jobs/${jobId}/call_history/`);
return response.data;
}
export async function rescheduleJob(
jobId: number,
data: RescheduleRequest
): Promise<{ success: boolean; job: JobDetail }> {
const response = await apiClient.post<{ success: boolean; job: JobDetail }>(
`/jobs/${jobId}/reschedule/`,
data
);
return response.data;
}

View File

@@ -0,0 +1,23 @@
/**
* API Configuration
*
* Configurable base URL for development and production environments.
*/
function getApiBaseUrl(): string {
// Use environment variable if set
if (process.env.EXPO_PUBLIC_API_BASE_URL) {
return process.env.EXPO_PUBLIC_API_BASE_URL;
}
// Development fallback
if (__DEV__) {
return 'http://lvh.me:8000';
}
// Production default
return 'https://smoothschedule.com';
}
export const API_BASE_URL = getApiBaseUrl();
export const API_MOBILE_URL = `${API_BASE_URL}/mobile`;

View File

@@ -0,0 +1,109 @@
/**
* Authentication Hook
*
* Provides auth context and methods for the app.
*/
import React, { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import { loginApi, logoutApi, getProfile } from '../api/auth';
import {
getAuthToken,
setAuthToken,
setUserData,
getUserData,
clearAllAuthData,
} from '../services/storage';
import type { User, LoginCredentials } from '../types';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check for existing auth on mount
useEffect(() => {
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
const token = await getAuthToken();
if (token) {
// Try to get cached user data first
const cachedUser = await getUserData();
if (cachedUser) {
setUser(cachedUser);
}
// Then refresh from server
try {
const freshUser = await getProfile();
setUser(freshUser);
await setUserData(freshUser);
} catch (error) {
// If profile fetch fails, clear auth
await clearAllAuthData();
setUser(null);
}
}
} catch (error) {
console.error('Error checking auth status:', error);
} finally {
setIsLoading(false);
}
};
const login = useCallback(async (credentials: LoginCredentials) => {
const response = await loginApi(credentials);
await setAuthToken(response.access);
await setUserData(response.user);
setUser(response.user);
}, []);
const logout = useCallback(async () => {
try {
await logoutApi();
} finally {
await clearAllAuthData();
setUser(null);
}
}, []);
const refreshUser = useCallback(async () => {
try {
const freshUser = await getProfile();
setUser(freshUser);
await setUserData(freshUser);
} catch (error) {
console.error('Error refreshing user:', error);
}
}, []);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,223 @@
/**
* Jobs Hook with Real-time Updates
*
* Combines React Query with WebSocket for real-time job updates.
* Automatically invalidates queries when WebSocket messages are received.
*/
import { useEffect, useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AppState, type AppStateStatus } from 'react-native';
import { getJobs, getJobDetail } from '../api/jobs';
import { websocketService, type WebSocketMessage } from '../services/websocket';
import { useAuth } from './useAuth';
import type { JobsResponse, JobDetail, JobListItem } from '../types';
interface UseJobsOptions {
startDate?: string;
endDate?: string;
enabled?: boolean;
}
/**
* Hook for fetching jobs list with real-time updates
*/
export function useJobs(options: UseJobsOptions = {}) {
const { startDate, endDate, enabled = true } = options;
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const appState = useRef(AppState.currentState);
// Track WebSocket connection status
const wsConnectedRef = useRef(false);
// Connect/disconnect WebSocket based on auth state and app state
useEffect(() => {
if (!isAuthenticated || !enabled) {
websocketService.disconnect();
wsConnectedRef.current = false;
return;
}
// Connect on mount
websocketService.connect();
wsConnectedRef.current = true;
// Handle app state changes (foreground/background)
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === 'active'
) {
// App came to foreground - reconnect and refresh
websocketService.connect();
queryClient.invalidateQueries({ queryKey: ['jobs'] });
} else if (nextAppState.match(/inactive|background/)) {
// App going to background - disconnect to save battery
websocketService.disconnect();
}
appState.current = nextAppState;
});
return () => {
subscription.remove();
websocketService.disconnect();
wsConnectedRef.current = false;
};
}, [isAuthenticated, enabled, queryClient]);
// Handle WebSocket messages
useEffect(() => {
if (!isAuthenticated || !enabled) {
return;
}
const handleMessage = (message: WebSocketMessage) => {
switch (message.type) {
case 'event_created':
case 'job_assigned':
// New job - invalidate the jobs list
queryClient.invalidateQueries({ queryKey: ['jobs'] });
break;
case 'event_updated':
case 'event_status_changed':
// Update optimistically if we have the data
if (message.event?.id) {
// Invalidate specific job detail
queryClient.invalidateQueries({ queryKey: ['job', message.event.id] });
// Invalidate ALL jobs queries (matches ['jobs', ...] with any params)
queryClient.invalidateQueries({ queryKey: ['jobs'] });
}
break;
case 'event_deleted':
case 'job_unassigned':
// Job removed - remove from cache and invalidate
if (message.event_id) {
queryClient.removeQueries({ queryKey: ['job', message.event_id] });
queryClient.setQueryData<JobsResponse>(['jobs'], (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
jobs: oldData.jobs.filter((job: JobListItem) => job.id !== message.event_id),
count: oldData.count - 1,
};
});
}
break;
default:
break;
}
};
const removeHandler = websocketService.addHandler(handleMessage);
return removeHandler;
}, [isAuthenticated, enabled, queryClient]);
// Build params object for date range query
const queryParams = {
...(startDate && { startDate }),
...(endDate && { endDate }),
};
// Fetch jobs using React Query
const query = useQuery({
queryKey: ['jobs', queryParams],
queryFn: () => getJobs(queryParams),
enabled: isAuthenticated && enabled && !!startDate && !!endDate,
staleTime: 1000 * 60 * 2, // 2 minutes (WebSocket keeps it fresh)
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
// Refresh function for pull-to-refresh
const refresh = useCallback(async () => {
await query.refetch();
}, [query]);
return {
...query,
refresh,
isConnected: wsConnectedRef.current,
};
}
/**
* Hook for fetching a single job with real-time updates
*/
export function useJob(jobId: number | null, options: { enabled?: boolean } = {}) {
const { enabled = true } = options;
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
// Subscribe to this specific event for updates
useEffect(() => {
if (!jobId || !isAuthenticated || !enabled) {
return;
}
websocketService.subscribeToEvent(jobId);
return () => {
websocketService.unsubscribeFromEvent(jobId);
};
}, [jobId, isAuthenticated, enabled]);
// Handle WebSocket messages for this job
useEffect(() => {
if (!jobId || !isAuthenticated || !enabled) {
return;
}
const handleMessage = (message: WebSocketMessage) => {
// Only handle messages for this specific job
const messageJobId = message.event?.id || message.event_id;
if (messageJobId !== jobId) {
return;
}
switch (message.type) {
case 'event_updated':
case 'event_status_changed':
// Refetch the job detail
queryClient.invalidateQueries({ queryKey: ['job', jobId] });
break;
case 'event_deleted':
case 'job_unassigned':
// Job was deleted/unassigned - you might want to navigate away
queryClient.removeQueries({ queryKey: ['job', jobId] });
break;
default:
break;
}
};
const removeHandler = websocketService.addHandler(handleMessage);
return removeHandler;
}, [jobId, isAuthenticated, enabled, queryClient]);
// Fetch job detail using React Query
const query = useQuery({
queryKey: ['job', jobId],
queryFn: () => getJobDetail(jobId!),
enabled: isAuthenticated && enabled && !!jobId,
staleTime: 1000 * 60 * 2, // 2 minutes
});
// Refresh function
const refresh = useCallback(async () => {
await query.refetch();
}, [query]);
return {
...query,
refresh,
};
}
export default useJobs;

View File

@@ -0,0 +1,132 @@
/**
* Location Tracking Service
*
* Handles GPS location updates for en-route and in-progress jobs.
*/
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';
import { updateLocation } from '../api/jobs';
const LOCATION_TASK_NAME = 'background-location-task';
let activeJobId: number | null = null;
// Define the background task
TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }: any) => {
if (error) {
console.error('Background location error:', error);
return;
}
if (data && activeJobId) {
const { locations } = data;
const location = locations[0];
if (location) {
try {
await updateLocation(activeJobId, {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
accuracy: location.coords.accuracy,
heading: location.coords.heading,
speed: location.coords.speed,
});
} catch (error) {
console.error('Failed to send location update:', error);
}
}
}
});
export async function requestLocationPermissions(): Promise<boolean> {
const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync();
if (foregroundStatus !== 'granted') {
return false;
}
const { status: backgroundStatus } = await Location.requestBackgroundPermissionsAsync();
return backgroundStatus === 'granted';
}
export async function startLocationTracking(jobId: number): Promise<boolean> {
const hasPermission = await requestLocationPermissions();
if (!hasPermission) {
console.warn('Location permission not granted');
return false;
}
activeJobId = jobId;
// Check if already tracking
const isTracking = await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
if (isTracking) {
return true;
}
await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
accuracy: Location.Accuracy.High,
timeInterval: 30000, // 30 seconds
distanceInterval: 50, // 50 meters
deferredUpdatesInterval: 30000,
deferredUpdatesDistance: 50,
showsBackgroundLocationIndicator: true,
foregroundService: {
notificationTitle: 'SmoothSchedule',
notificationBody: 'Tracking your location for the current job',
notificationColor: '#2563eb',
},
});
return true;
}
export async function stopLocationTracking(): Promise<void> {
activeJobId = null;
const isTracking = await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
if (isTracking) {
await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
}
}
export async function getCurrentLocation(): Promise<Location.LocationObject | null> {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
return null;
}
return await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
} catch (error) {
console.error('Error getting current location:', error);
return null;
}
}
export function isTrackingActive(): boolean {
return activeJobId !== null;
}
export function getActiveJobId(): number | null {
return activeJobId;
}
// Default export for cleaner imports
const locationService = {
requestPermissions: requestLocationPermissions,
startTracking: startLocationTracking,
stopTracking: stopLocationTracking,
getCurrentLocation,
isTracking: async () => {
return await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
},
getActiveJobId,
};
export default locationService;

View File

@@ -0,0 +1,67 @@
/**
* Secure Storage Service
*
* Handles secure storage of authentication tokens.
*/
import * as SecureStore from 'expo-secure-store';
const AUTH_TOKEN_KEY = 'auth_token';
const USER_DATA_KEY = 'user_data';
export async function getAuthToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
} catch (error) {
console.error('Error reading auth token:', error);
return null;
}
}
export async function setAuthToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
} catch (error) {
console.error('Error saving auth token:', error);
throw error;
}
}
export async function removeAuthToken(): Promise<void> {
try {
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY);
} catch (error) {
console.error('Error removing auth token:', error);
}
}
export async function getUserData(): Promise<any | null> {
try {
const data = await SecureStore.getItemAsync(USER_DATA_KEY);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('Error reading user data:', error);
return null;
}
}
export async function setUserData(user: any): Promise<void> {
try {
await SecureStore.setItemAsync(USER_DATA_KEY, JSON.stringify(user));
} catch (error) {
console.error('Error saving user data:', error);
throw error;
}
}
export async function removeUserData(): Promise<void> {
try {
await SecureStore.deleteItemAsync(USER_DATA_KEY);
} catch (error) {
console.error('Error removing user data:', error);
}
}
export async function clearAllAuthData(): Promise<void> {
await Promise.all([removeAuthToken(), removeUserData()]);
}

View File

@@ -0,0 +1,241 @@
/**
* WebSocket Service
*
* Manages WebSocket connection for real-time calendar updates.
* Handles reconnection with exponential backoff.
*/
import { API_BASE_URL } from '../config/api';
import { getAuthToken } from './storage';
import type { JobListItem, JobDetail, JobStatus } from '../types';
// WebSocket URL (convert http(s) to ws(s))
function getWebSocketUrl(): string {
const wsUrl = API_BASE_URL.replace(/^http/, 'ws');
return `${wsUrl}/ws/calendar/`;
}
// Message types from server
export type WebSocketMessageType =
| 'connection_established'
| 'event_created'
| 'event_updated'
| 'event_deleted'
| 'event_status_changed'
| 'job_assigned'
| 'job_unassigned'
| 'subscribed'
| 'pong';
export interface WebSocketMessage {
type: WebSocketMessageType;
event?: Partial<JobDetail>;
event_id?: number;
old_status?: JobStatus;
new_status?: JobStatus;
changed_fields?: string[];
user_id?: number;
groups?: string[];
}
export type WebSocketEventHandler = (message: WebSocketMessage) => void;
class WebSocketService {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 1000; // Start with 1 second
private maxReconnectDelay = 30000; // Max 30 seconds
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingInterval: ReturnType<typeof setInterval> | null = null;
private eventHandlers: Set<WebSocketEventHandler> = new Set();
private isConnecting = false;
private shouldReconnect = true;
/**
* Connect to WebSocket server
*/
async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
return;
}
this.isConnecting = true;
this.shouldReconnect = true;
try {
const token = await getAuthToken();
if (!token) {
this.isConnecting = false;
return;
}
const wsUrl = `${getWebSocketUrl()}?token=${token}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.isConnecting = false;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.startPingInterval();
};
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
this.notifyHandlers(message);
} catch (error) {
// Silently ignore parse errors
}
};
this.ws.onerror = () => {
this.isConnecting = false;
};
this.ws.onclose = (event) => {
this.isConnecting = false;
this.stopPingInterval();
// Don't reconnect if we got 403 (invalid/expired token)
if (event.reason?.includes('403') || event.reason?.includes('Access denied')) {
this.shouldReconnect = false;
return;
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
} catch (error) {
this.isConnecting = false;
this.scheduleReconnect();
}
}
/**
* Disconnect from WebSocket server
*/
disconnect(): void {
this.shouldReconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopPingInterval();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* Add event handler for WebSocket messages
*/
addHandler(handler: WebSocketEventHandler): () => void {
this.eventHandlers.add(handler);
return () => this.eventHandlers.delete(handler);
}
/**
* Remove event handler
*/
removeHandler(handler: WebSocketEventHandler): void {
this.eventHandlers.delete(handler);
}
/**
* Subscribe to updates for a specific event
*/
subscribeToEvent(eventId: number): void {
this.send({ type: 'subscribe_event', event_id: eventId });
}
/**
* Unsubscribe from updates for a specific event
*/
unsubscribeFromEvent(eventId: number): void {
this.send({ type: 'unsubscribe_event', event_id: eventId });
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
/**
* Send message to server
*/
private send(data: object): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
/**
* Notify all handlers of a message
*/
private notifyHandlers(message: WebSocketMessage): void {
this.eventHandlers.forEach((handler) => {
try {
handler(message);
} catch (error) {
// Silently ignore handler errors
}
});
}
/**
* Schedule reconnection with exponential backoff
*/
private scheduleReconnect(): void {
if (!this.shouldReconnect) {
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
return;
}
this.reconnectAttempts++;
const delay = Math.min(
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
this.maxReconnectDelay
);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
/**
* Start ping interval to keep connection alive
*/
private startPingInterval(): void {
this.stopPingInterval();
this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 'ping' });
}
}, 30000); // Ping every 30 seconds
}
/**
* Stop ping interval
*/
private stopPingInterval(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
}
// Export singleton instance
export const websocketService = new WebSocketService();
export default websocketService;

View File

@@ -0,0 +1,164 @@
/**
* Type definitions for the Field App
*/
export interface User {
id: number;
email: string;
name: string;
username?: string;
role?: string;
business?: number;
business_name?: string;
business_subdomain?: string;
can_use_masked_calls?: boolean;
}
export interface AuthResponse {
access: string;
refresh: string;
user: User;
}
export interface LoginCredentials {
email: string;
password: string;
}
export type JobStatus =
| 'SCHEDULED'
| 'EN_ROUTE'
| 'IN_PROGRESS'
| 'COMPLETED'
| 'CANCELED'
| 'NOSHOW'
| 'AWAITING_PAYMENT'
| 'PAID';
export interface JobListItem {
id: number;
title: string;
start_time: string;
end_time: string;
status: JobStatus;
status_display: string;
customer_name: string | null;
address: string | null;
service_name: string | null;
duration_minutes: number | null;
}
export interface StatusHistoryItem {
old_status: JobStatus;
new_status: JobStatus;
changed_by: string;
changed_at: string;
notes?: string;
}
export interface JobDetail {
id: number;
title: string;
description: string | null;
start_time: string;
end_time: string;
status: JobStatus;
status_display: string;
duration_minutes: number | null;
customer: {
id: number;
name: string;
email: string | null;
phone: string | null;
phone_masked?: string;
} | null;
address: string | null;
latitude: number | null;
longitude: number | null;
service: {
id: number;
name: string;
duration_minutes: number;
price: string | null;
} | null;
notes: string | null;
available_transitions: JobStatus[];
allowed_transitions: JobStatus[];
is_tracking_location: boolean;
status_history?: StatusHistoryItem[];
can_edit_schedule?: boolean;
}
export interface RescheduleRequest {
start_time?: string;
end_time?: string;
duration_minutes?: number;
}
export interface JobsResponse {
jobs: JobListItem[];
count: number;
}
export interface StatusChangeRequest {
status: JobStatus;
notes?: string;
latitude?: number;
longitude?: number;
}
export interface LocationUpdateRequest {
latitude: number;
longitude: number;
accuracy?: number;
heading?: number;
speed?: number;
}
export interface LocationPoint {
latitude: number;
longitude: number;
timestamp: string;
accuracy: number | null;
}
export interface RouteResponse {
job_id: number;
status: JobStatus;
is_tracking: boolean;
route: LocationPoint[];
latest_location: LocationPoint | null;
}
export interface CallResponse {
success: boolean;
call_sid?: string;
message?: string;
error?: string;
}
export interface SMSRequest {
message: string;
}
export interface SMSResponse {
success: boolean;
message_sid?: string;
error?: string;
}
export interface CallHistoryItem {
id: number;
call_type: 'OUTBOUND_CALL' | 'INBOUND_CALL' | 'OUTBOUND_SMS' | 'INBOUND_SMS';
direction: 'outbound' | 'inbound';
duration_seconds: number | null;
status: string;
created_at: string;
sms_body: string | null;
}
export interface ApiError {
error?: string;
message?: string;
detail?: string;
}

View File

@@ -0,0 +1,16 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}