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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user