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

- Add React Native Expo field app for mobile staff
- Use main /appointments/ endpoint with date range support
- Add X-Business-Subdomain header for tenant context
- Support day/week view navigation
- Remove WebSocket console logging from frontend
- Update AppointmentStatus type to include all backend statuses
- Add responsive status legend to scheduler header

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-07 01:23:24 -05:00
parent 46b154e957
commit 61882b300f
30 changed files with 16529 additions and 91 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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