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 { getWebSocketUrl } from '../utils/domain';
|
||||||
import { Appointment } from '../types';
|
import { Appointment } from '../types';
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketEvent {
|
||||||
type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong';
|
id: number;
|
||||||
appointment?: {
|
title: string;
|
||||||
id: string;
|
start_time: string;
|
||||||
business_id: string;
|
end_time: string;
|
||||||
service_id: string;
|
status: string;
|
||||||
resource_id: string | null;
|
notes: string;
|
||||||
customer_id: string;
|
service?: {
|
||||||
customer_name: string;
|
id: number;
|
||||||
start_time: string;
|
name: string;
|
||||||
end_time: string;
|
duration: number;
|
||||||
duration_minutes: number;
|
price: string;
|
||||||
status: string;
|
|
||||||
notes: 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;
|
message?: string;
|
||||||
|
user_id?: number;
|
||||||
|
groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAppointmentWebSocketOptions {
|
interface UseAppointmentWebSocketOptions {
|
||||||
@@ -36,25 +44,26 @@ interface UseAppointmentWebSocketOptions {
|
|||||||
onError?: (error: Event) => void;
|
onError?: (error: Event) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket is not yet implemented in the backend - disable for now
|
// Enable WebSocket for real-time calendar updates
|
||||||
const WEBSOCKET_ENABLED = false;
|
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;
|
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 {
|
return {
|
||||||
id: data.id,
|
id: String(data.id),
|
||||||
resourceId: data.resource_id,
|
startTime,
|
||||||
customerId: data.customer_id,
|
durationMinutes,
|
||||||
customerName: data.customer_name,
|
|
||||||
serviceId: data.service_id,
|
|
||||||
startTime: new Date(data.start_time),
|
|
||||||
durationMinutes: data.duration_minutes,
|
|
||||||
status: data.status as Appointment['status'],
|
status: data.status as Appointment['status'],
|
||||||
notes: data.notes,
|
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 token = getCookie('access_token');
|
||||||
const subdomain = getSubdomain();
|
const subdomain = getSubdomain();
|
||||||
|
|
||||||
if (!token || !subdomain) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the getWebSocketUrl utility from domain.ts
|
// 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) => {
|
const updateQueryCache = useCallback((message: WebSocketMessage) => {
|
||||||
@@ -109,27 +124,46 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
|||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'appointment_created': {
|
case 'event_created': {
|
||||||
const newAppointment = transformAppointment(message.appointment);
|
if (!message.event) return old;
|
||||||
if (!newAppointment) return old;
|
const eventUpdate = transformEvent(message.event);
|
||||||
|
if (!eventUpdate) return old;
|
||||||
|
const eventId = String(message.event.id);
|
||||||
// Check if appointment already exists (avoid duplicates)
|
// 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;
|
||||||
}
|
}
|
||||||
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': {
|
case 'event_updated':
|
||||||
const updatedAppointment = transformAppointment(message.appointment);
|
case 'event_status_changed': {
|
||||||
if (!updatedAppointment) return old;
|
if (!message.event) return old;
|
||||||
return old.map(apt =>
|
const eventUpdate = transformEvent(message.event);
|
||||||
apt.id === updatedAppointment.id ? updatedAppointment : apt
|
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': {
|
case 'event_deleted': {
|
||||||
if (!message.appointment_id) return old;
|
if (!message.event_id) return old;
|
||||||
return old.filter(apt => apt.id !== message.appointment_id);
|
const eventId = String(message.event_id);
|
||||||
|
return old.filter(apt => apt.id !== eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -160,7 +194,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
|||||||
|
|
||||||
const url = getWsUrl();
|
const url = getWsUrl();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
console.log('WebSocket: Missing token or subdomain, skipping connection');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +202,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
|||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***'));
|
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -179,7 +211,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('WebSocket: Connected');
|
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
onConnectedRef.current?.();
|
onConnectedRef.current?.();
|
||||||
@@ -204,40 +235,37 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
|||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'connection_established':
|
case 'connection_established':
|
||||||
console.log('WebSocket: Connection confirmed -', message.message);
|
|
||||||
break;
|
|
||||||
case 'pong':
|
case 'pong':
|
||||||
// Heartbeat response, ignore
|
// Heartbeat/connection responses, ignore
|
||||||
break;
|
break;
|
||||||
case 'appointment_created':
|
case 'event_created':
|
||||||
case 'appointment_updated':
|
case 'event_updated':
|
||||||
case 'appointment_deleted':
|
case 'event_deleted':
|
||||||
console.log('WebSocket: Received', message.type);
|
case 'event_status_changed':
|
||||||
updateQueryCache(message);
|
updateQueryCache(message);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log('WebSocket: Unknown message type', message);
|
// Unknown message type, ignore
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('WebSocket: Failed to parse message', err);
|
// Silently ignore parse errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
// Only log error if not aborted (StrictMode cleanup causes expected errors)
|
// Only call error callback if not aborted
|
||||||
if (!effectAborted) {
|
if (!effectAborted) {
|
||||||
console.error('WebSocket: Error', error);
|
|
||||||
onErrorRef.current?.(error);
|
onErrorRef.current?.(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
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) {
|
if (effectAborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('WebSocket: Disconnected', event.code, event.reason);
|
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
onDisconnectedRef.current?.();
|
onDisconnectedRef.current?.();
|
||||||
|
|
||||||
@@ -250,7 +278,6 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
|||||||
// Only attempt reconnection if not cleaning up
|
// Only attempt reconnection if not cleaning up
|
||||||
if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
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(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
reconnectAttemptsRef.current++;
|
reconnectAttemptsRef.current++;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { Appointment, AppointmentStatus, User, Business, Resource } from '../types';
|
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 { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments';
|
||||||
import { useResources } from '../hooks/useResources';
|
import { useResources } from '../hooks/useResources';
|
||||||
import { useServices } from '../hooks/useServices';
|
import { useServices } from '../hooks/useServices';
|
||||||
@@ -119,14 +119,18 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
const [editDateTime, setEditDateTime] = useState('');
|
const [editDateTime, setEditDateTime] = useState('');
|
||||||
const [editResource, setEditResource] = useState('');
|
const [editResource, setEditResource] = useState('');
|
||||||
const [editDuration, setEditDuration] = useState(0);
|
const [editDuration, setEditDuration] = useState(0);
|
||||||
const [editStatus, setEditStatus] = useState<AppointmentStatus>('CONFIRMED');
|
const [editStatus, setEditStatus] = useState<AppointmentStatus>('SCHEDULED');
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
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 [filterResources, setFilterResources] = useState<Set<string>>(new Set()); // Empty means all
|
||||||
const [filterServices, setFilterServices] = 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 filterMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const statusLegendRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Update edit state when selected appointment changes
|
// Update edit state when selected appointment changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -204,19 +208,22 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
}
|
}
|
||||||
}, [overlayWheelHandler]);
|
}, [overlayWheelHandler]);
|
||||||
|
|
||||||
// Close filter menu when clicking outside
|
// Close dropdowns when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
|
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
|
||||||
setShowFilterMenu(false);
|
setShowFilterMenu(false);
|
||||||
}
|
}
|
||||||
|
if (statusLegendRef.current && !statusLegendRef.current.contains(e.target as Node)) {
|
||||||
|
setShowStatusLegend(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (showFilterMenu) {
|
if (showFilterMenu || showStatusLegend) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}
|
}
|
||||||
}, [showFilterMenu]);
|
}, [showFilterMenu, showStatusLegend]);
|
||||||
|
|
||||||
// Filter toggle helpers
|
// Filter toggle helpers
|
||||||
const toggleStatusFilter = (status: AppointmentStatus) => {
|
const toggleStatusFilter = (status: AppointmentStatus) => {
|
||||||
@@ -249,13 +256,18 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
setFilterServices(newSet);
|
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 = () => {
|
const clearAllFilters = () => {
|
||||||
setFilterStatuses(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW']));
|
setFilterStatuses(new Set(DEFAULT_STATUSES));
|
||||||
setFilterResources(new Set());
|
setFilterResources(new Set());
|
||||||
setFilterServices(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)
|
// Scroll to current time on mount (centered in view)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -576,9 +588,19 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel);
|
const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel);
|
||||||
|
|
||||||
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
|
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';
|
// Completed statuses
|
||||||
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 === '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';
|
||||||
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';
|
// 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();
|
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 > 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';
|
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)
|
// Simplified status colors for month view (no border classes)
|
||||||
const getMonthStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
|
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';
|
// Completed statuses
|
||||||
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 === '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';
|
||||||
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';
|
// 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();
|
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 > 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';
|
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} />
|
<Redo size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Status Legend */}
|
{/* Status Legend - Hidden on smaller screens, shown on 2xl+ (1536px) */}
|
||||||
<div className="flex items-center gap-3 border-l border-gray-300 dark:border-gray-600 pl-4">
|
<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>
|
<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="flex items-center gap-1">
|
||||||
<div className="w-3 h-3 rounded bg-blue-100 border border-blue-500"></div>
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
<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>
|
<span className="text-xs text-gray-600 dark:text-gray-400">In Progress</span>
|
||||||
</div>
|
</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="flex items-center gap-1">
|
||||||
<div className="w-3 h-3 rounded bg-green-100 border border-green-500"></div>
|
<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>
|
<span className="text-xs text-gray-600 dark:text-gray-400">Completed</span>
|
||||||
</div>
|
</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="flex items-center gap-1">
|
||||||
<div className="w-3 h-3 rounded bg-gray-100 border border-gray-400"></div>
|
<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>
|
<span className="text-xs text-gray-600 dark:text-gray-400">Cancelled</span>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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">
|
<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>
|
<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">
|
<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
|
<div
|
||||||
key={status}
|
key={status}
|
||||||
onClick={() => toggleStatusFilter(status)}
|
onClick={() => toggleStatusFilter(status)}
|
||||||
@@ -1134,13 +1224,16 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
{filterStatuses.has(status) && <Check size={12} className="text-white" />}
|
{filterStatuses.has(status) && <Check size={12} className="text-white" />}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">
|
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">
|
||||||
{status.toLowerCase().replace('_', ' ')}
|
{status.toLowerCase().replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
<div className={`w-2 h-2 rounded-full ml-auto ${
|
<div className={`w-2 h-2 rounded-full ml-auto ${
|
||||||
status === 'COMPLETED' ? 'bg-green-500' :
|
status === 'COMPLETED' || status === 'PAID' ? 'bg-green-500' :
|
||||||
status === 'CANCELLED' ? 'bg-gray-400' :
|
status === 'CANCELLED' || status === 'CANCELED' ? 'bg-gray-400' :
|
||||||
status === 'NO_SHOW' ? 'bg-orange-500' :
|
status === 'NO_SHOW' || status === 'NOSHOW' ? 'bg-orange-500' :
|
||||||
status === 'CONFIRMED' ? 'bg-blue-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'
|
'bg-yellow-400'
|
||||||
}`}></div>
|
}`}></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1897,9 +1990,11 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
onChange={(e) => setEditStatus(e.target.value as AppointmentStatus)}
|
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"
|
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="SCHEDULED">Scheduled</option>
|
||||||
<option value="CONFIRMED">Confirmed</option>
|
<option value="EN_ROUTE">En Route</option>
|
||||||
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
<option value="COMPLETED">Completed</option>
|
<option value="COMPLETED">Completed</option>
|
||||||
|
<option value="AWAITING_PAYMENT">Awaiting Payment</option>
|
||||||
<option value="CANCELLED">Cancelled</option>
|
<option value="CANCELLED">Cancelled</option>
|
||||||
<option value="NO_SHOW">No Show</option>
|
<option value="NO_SHOW">No Show</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -151,9 +151,15 @@ export interface Resource {
|
|||||||
savedLaneCount?: number; // Remembered lane count when multilane is disabled
|
savedLaneCount?: number; // Remembered lane count when multilane is disabled
|
||||||
created_at?: string; // Used for quota overage calculation (oldest archived first)
|
created_at?: string; // Used for quota overage calculation (oldest archived first)
|
||||||
is_archived_by_quota?: boolean; // True if archived due to quota overage
|
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 {
|
export interface Appointment {
|
||||||
id: string;
|
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