Add full time-off lifecycle: create/edit/delete with shift conflict detection, auto-removal from conflicting shifts with admin notification, shift restoration on admin delete, and hard block on assigning volunteers with approved time off to shifts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
265 lines
7.5 KiB
TypeScript
265 lines
7.5 KiB
TypeScript
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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const headers: Record<string, string> = { '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<Volunteer>('POST', '/auth/activate', { token, password }),
|
|
|
|
// Volunteers
|
|
createVolunteer: (data: CreateVolunteerInput) =>
|
|
request<AdminVolunteer>('POST', '/volunteers', data),
|
|
listVolunteers: () => request<Volunteer[] | AdminVolunteer[]>('GET', '/volunteers'),
|
|
getVolunteer: (id: number) => request<Volunteer | AdminVolunteer>('GET', `/volunteers/${id}`),
|
|
updateVolunteer: (id: number, data: Partial<UpdateVolunteerInput>) =>
|
|
request<Volunteer>('PUT', `/volunteers/${id}`, data),
|
|
resendInvite: (id: number) =>
|
|
request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}),
|
|
|
|
// Shift templates
|
|
listShiftTemplates: () => request<ShiftTemplate[]>('GET', '/shift-templates'),
|
|
createShiftTemplate: (data: CreateShiftTemplateInput) =>
|
|
request<ShiftTemplate>('POST', '/shift-templates', data),
|
|
updateShiftTemplate: (id: number, data: Partial<CreateShiftTemplateInput>) =>
|
|
request<ShiftTemplate>('PUT', `/shift-templates/${id}`, data),
|
|
deleteShiftTemplate: (id: number) => request<void>('DELETE', `/shift-templates/${id}`),
|
|
|
|
// Shift instances
|
|
listShifts: (year: number, month: number) =>
|
|
request<ShiftInstance[]>('GET', `/shifts?year=${year}&month=${month}`),
|
|
generateShifts: (year: number, month: number) =>
|
|
request<ShiftInstance[]>('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<ShiftInstance>('PUT', `/shifts/${id}`, data),
|
|
confirmShift: (id: number) => request<void>('POST', `/shifts/${id}/confirm`, {}),
|
|
|
|
// Time off
|
|
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
|
createTimeOff: (data: CreateTimeOffInput & { volunteer_id?: number; confirm_conflicts?: boolean }) =>
|
|
request<TimeOffRequest | TimeOffConflictResponse>('POST', '/timeoff', data),
|
|
updateTimeOff: (id: number, data: { starts_at: string; ends_at: string; reason?: string }) =>
|
|
request<TimeOffRequest>('PUT', `/timeoff/${id}`, data),
|
|
deleteTimeOff: (id: number) =>
|
|
request<{ deleted: boolean; restored_shifts: ConflictingShift[] }>('DELETE', `/timeoff/${id}`),
|
|
reviewTimeOff: (id: number, status: 'approved' | 'rejected') =>
|
|
request<TimeOffRequest>('PUT', `/timeoff/${id}/review`, { status }),
|
|
getRemovedShifts: (id: number) =>
|
|
request<ConflictingShift[]>('GET', `/timeoff/${id}/shifts`),
|
|
|
|
// Check-in / out
|
|
checkIn: (schedule_id?: number, notes?: string) =>
|
|
request<CheckIn>('POST', '/checkin', { schedule_id, notes }),
|
|
checkOut: (notes?: string) => request<CheckIn>('POST', '/checkout', { notes }),
|
|
getHistory: () => request<CheckIn[]>('GET', '/checkin/history'),
|
|
|
|
// Notifications
|
|
listNotifications: () => request<Notification[]>('GET', '/notifications'),
|
|
markRead: (id: number) => request<Notification>('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;
|