Files
walkies/web/src/api.ts
James Griffin 07f11fa94e Implement time off management (Issue #3)
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>
2026-04-09 10:54:12 -03:00

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;