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>
This commit is contained in:
2026-04-09 10:03:47 -03:00
parent 73ad1ed788
commit 07f11fa94e
11 changed files with 1500 additions and 57 deletions

View File

@@ -4,6 +4,16 @@ 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();
@@ -17,10 +27,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
if (res.status === 204) return undefined as T;
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
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: () =>
@@ -67,9 +79,16 @@ export const api = {
// Time off
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
createTimeOff: (data: CreateTimeOffInput) => request<TimeOffRequest>('POST', '/timeoff', data),
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) =>
@@ -206,6 +225,19 @@ export interface CreateTimeOffInput {
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;