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