const BASE = '/api/v1'; function getToken(): string | null { return localStorage.getItem('token'); } class ApiError extends Error { status: number; data: any; constructor(message: string, status: number, data: any) { super(message); this.status = status; this.data = data; } } async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = { 'Content-Type': 'application/json' }; const token = getToken(); if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (res.status === 204) return undefined as T; const data = await res.json(); if (!res.ok) throw new ApiError(data.error || data.message || 'Request failed', res.status, data); return data as T; } export { ApiError }; export const api = { // Setup getSetupStatus: () => request<{ needs_setup: boolean }>('GET', '/setup/status'), createSetupAdmin: (data: { name: string; email: string; password: string }) => request<{ token: string }>('POST', '/setup/admin', data), // Auth login: (email: string, password: string) => request<{ token: string }>('POST', '/auth/login', { email, password }), activate: (token: string, password: string) => request('POST', '/auth/activate', { token, password }), // Volunteers createVolunteer: (data: CreateVolunteerInput) => request('POST', '/volunteers', data), listVolunteers: () => request('GET', '/volunteers'), getVolunteer: (id: number) => request('GET', `/volunteers/${id}`), updateVolunteer: (id: number, data: Partial) => request('PUT', `/volunteers/${id}`, data), resendInvite: (id: number) => request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}), // Shift templates listShiftTemplates: () => request('GET', '/shift-templates'), createShiftTemplate: (data: CreateShiftTemplateInput) => request('POST', '/shift-templates', data), updateShiftTemplate: (id: number, data: Partial) => request('PUT', `/shift-templates/${id}`, data), deleteShiftTemplate: (id: number) => request('DELETE', `/shift-templates/${id}`), // Shift instances listShifts: (year: number, month: number) => request('GET', `/shifts?year=${year}&month=${month}`), generateShifts: (year: number, month: number) => request('POST', '/shifts/generate', { year, month }), publishShifts: (year: number, month: number) => request<{ year: number; month: number }>('POST', '/shifts/publish', { year, month }), unpublishShifts: (year: number, month: number) => request<{ year: number; month: number }>('POST', '/shifts/unpublish', { year, month }), updateShift: (id: number, data: UpdateShiftInput) => request('PUT', `/shifts/${id}`, data), confirmShift: (id: number) => request('POST', `/shifts/${id}/confirm`, {}), // Time off listTimeOff: () => request('GET', '/timeoff'), createTimeOff: (data: CreateTimeOffInput & { volunteer_id?: number; confirm_conflicts?: boolean }) => request('POST', '/timeoff', data), updateTimeOff: (id: number, data: { starts_at: string; ends_at: string; reason?: string }) => request('PUT', `/timeoff/${id}`, data), deleteTimeOff: (id: number) => request<{ deleted: boolean; restored_shifts: ConflictingShift[] }>('DELETE', `/timeoff/${id}`), reviewTimeOff: (id: number, status: 'approved' | 'rejected') => request('PUT', `/timeoff/${id}/review`, { status }), getRemovedShifts: (id: number) => request('GET', `/timeoff/${id}/shifts`), // Check-in / out checkIn: (schedule_id?: number, notes?: string) => request('POST', '/checkin', { schedule_id, notes }), checkOut: (notes?: string) => request('POST', '/checkout', { notes }), getHistory: () => request('GET', '/checkin/history'), // Notifications listNotifications: () => request('GET', '/notifications'), markRead: (id: number) => request('PUT', `/notifications/${id}/read`, {}), }; export interface Volunteer { id: number; name: string; email: string; role: 'admin' | 'volunteer'; active: boolean; is_trainee: boolean; phone?: string; operational_roles: string; notification_preference: string; last_login?: string; completed_shifts: number; created_at: string; updated_at: string; } export interface AdminVolunteer extends Volunteer { admin_notes?: string; invite_token?: string; } export interface CreateVolunteerInput { name: string; email: string; role?: 'admin' | 'volunteer'; is_trainee?: boolean; phone?: string; operational_roles?: string; } export interface UpdateVolunteerInput { name?: string; email?: string; phone?: string; role?: 'admin' | 'volunteer'; active?: boolean; is_trainee?: boolean; operational_roles?: string; notification_preference?: string; admin_notes?: string; } export interface TemplateRole { id?: number; template_id?: number; role_name: string; count: number; } export interface ShiftTemplate { id: number; name: string; day_of_week: number; // 0=Sun, 1=Mon, ..., 6=Sat start_time: string; end_time: string; min_capacity: number; max_capacity: number; roles: TemplateRole[]; volunteer_ids: number[]; created_at: string; updated_at: string; } export interface CreateShiftTemplateInput { name: string; day_of_week: number; start_time: string; end_time: string; min_capacity: number; max_capacity: number; roles?: TemplateRole[]; volunteer_ids?: number[]; } export interface InstanceVolunteer { instance_id: number; volunteer_id: number; name: string; confirmed: boolean; confirmed_at?: string; } export interface ShiftInstance { id: number; template_id?: number; name: string; date: string; // "YYYY-MM-DD" start_time: string; end_time: string; min_capacity: number; max_capacity: number; status: 'draft' | 'published'; year: number; month: number; volunteers: InstanceVolunteer[]; created_at: string; updated_at: string; } export interface UpdateShiftInput { volunteer_ids?: number[]; min_capacity?: number; max_capacity?: number; } export interface TimeOffRequest { id: number; volunteer_id: number; starts_at: string; ends_at: string; reason?: string; status: 'pending' | 'approved' | 'rejected'; reviewed_by?: number; reviewed_at?: string; created_at: string; updated_at: string; } export interface CreateTimeOffInput { starts_at: string; ends_at: string; reason?: string; } export interface ConflictingShift { instance_id: number; name: string; date: string; start_time: string; end_time: string; } export interface TimeOffConflictResponse { message: string; conflicts: ConflictingShift[]; } export interface CheckIn { id: number; volunteer_id: number; schedule_id?: number; checked_in_at: string; checked_out_at?: string; notes?: string; } export interface Notification { id: number; volunteer_id: number; message: string; is_read: boolean; created_at: string; } export const OPERATIONAL_ROLES = [ 'Behaviour Team', 'Dog Log Monitor', 'Dog Shelter Volunteer', 'Trainee', 'Floater', ] as const;