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;
}