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:
@@ -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++;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
mobile/field-app/.env.example
Normal file
10
mobile/field-app/.env.example
Normal 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
40
mobile/field-app/.gitignore
vendored
Normal 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
58
mobile/field-app/app.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
41
mobile/field-app/app/(auth)/_layout.tsx
Normal file
41
mobile/field-app/app/(auth)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1083
mobile/field-app/app/(auth)/job/[id].tsx
Normal file
1083
mobile/field-app/app/(auth)/job/[id].tsx
Normal file
File diff suppressed because it is too large
Load Diff
924
mobile/field-app/app/(auth)/jobs.tsx
Normal file
924
mobile/field-app/app/(auth)/jobs.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
38
mobile/field-app/app/_layout.tsx
Normal file
38
mobile/field-app/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
mobile/field-app/app/index.tsx
Normal file
20
mobile/field-app/app/index.tsx
Normal 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" />;
|
||||
}
|
||||
234
mobile/field-app/app/login.tsx
Normal file
234
mobile/field-app/app/login.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
BIN
mobile/field-app/assets/adaptive-icon.png
Normal file
BIN
mobile/field-app/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
mobile/field-app/assets/icon.png
Normal file
BIN
mobile/field-app/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
mobile/field-app/assets/logo.png
Normal file
BIN
mobile/field-app/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
mobile/field-app/assets/splash.png
Normal file
BIN
mobile/field-app/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
6
mobile/field-app/babel.config.js
Normal file
6
mobile/field-app/babel.config.js
Normal 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
12571
mobile/field-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
mobile/field-app/package.json
Normal file
43
mobile/field-app/package.json
Normal 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
|
||||
}
|
||||
35
mobile/field-app/src/api/auth.ts
Normal file
35
mobile/field-app/src/api/auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
mobile/field-app/src/api/client.ts
Normal file
45
mobile/field-app/src/api/client.ts
Normal 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;
|
||||
187
mobile/field-app/src/api/jobs.ts
Normal file
187
mobile/field-app/src/api/jobs.ts
Normal 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;
|
||||
}
|
||||
23
mobile/field-app/src/config/api.ts
Normal file
23
mobile/field-app/src/config/api.ts
Normal 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`;
|
||||
109
mobile/field-app/src/hooks/useAuth.tsx
Normal file
109
mobile/field-app/src/hooks/useAuth.tsx
Normal 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;
|
||||
}
|
||||
223
mobile/field-app/src/hooks/useJobs.tsx
Normal file
223
mobile/field-app/src/hooks/useJobs.tsx
Normal 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;
|
||||
132
mobile/field-app/src/services/location.ts
Normal file
132
mobile/field-app/src/services/location.ts
Normal 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;
|
||||
67
mobile/field-app/src/services/storage.ts
Normal file
67
mobile/field-app/src/services/storage.ts
Normal 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()]);
|
||||
}
|
||||
241
mobile/field-app/src/services/websocket.ts
Normal file
241
mobile/field-app/src/services/websocket.ts
Normal 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;
|
||||
164
mobile/field-app/src/types/index.ts
Normal file
164
mobile/field-app/src/types/index.ts
Normal 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;
|
||||
}
|
||||
16
mobile/field-app/tsconfig.json
Normal file
16
mobile/field-app/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user