321 lines
9.3 KiB
TypeScript
321 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ReactNode,
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useState,
|
|
} from "react";
|
|
|
|
import { MOCK_SHIFT_SLOTS } from "./constants";
|
|
import type { ShiftSlot, ShiftStatus } from "./types";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export type ScheduleView = "week" | "month";
|
|
|
|
interface ShiftContextType {
|
|
// Data
|
|
shifts: ShiftSlot[];
|
|
view: ScheduleView;
|
|
setView: (view: ScheduleView) => void;
|
|
|
|
// Current date navigation
|
|
currentDate: Date;
|
|
goToNextWeek: () => void;
|
|
goToPrevWeek: () => void;
|
|
goToNextMonth: () => void;
|
|
goToPrevMonth: () => void;
|
|
goToToday: () => void;
|
|
|
|
// Shift actions
|
|
registerShift: (
|
|
shiftId: string,
|
|
staffId: number,
|
|
staffName: string,
|
|
) => { success: boolean; error?: string };
|
|
unregisterShift: (shiftId: string, staffId: number) => void;
|
|
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
|
updateShift: (shift: ShiftSlot) => void;
|
|
deleteShift: (shiftId: string) => void;
|
|
|
|
// Helpers
|
|
getShiftsForDate: (date: string) => ShiftSlot[];
|
|
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
|
getWeekDates: () => Date[];
|
|
hasConflict: (
|
|
date: string,
|
|
startTime: string,
|
|
endTime: string,
|
|
staffId: number,
|
|
excludeShiftId?: string,
|
|
) => boolean;
|
|
getWeeklyBudget: () => number;
|
|
}
|
|
|
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
|
|
const ShiftContext = createContext<ShiftContextType | undefined>(undefined);
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function getMonday(d: Date): Date {
|
|
const date = new Date(d);
|
|
const day = date.getDay();
|
|
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
|
|
date.setDate(diff);
|
|
date.setHours(0, 0, 0, 0);
|
|
return date;
|
|
}
|
|
|
|
function formatDate(d: Date): string {
|
|
const y = d.getFullYear();
|
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
|
const day = d.getDate().toString().padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
function timeToMinutes(time: string): number {
|
|
const [h, m] = time.split(":").map(Number);
|
|
return h * 60 + m;
|
|
}
|
|
|
|
function timesOverlap(
|
|
start1: string,
|
|
end1: string,
|
|
start2: string,
|
|
end2: string,
|
|
): boolean {
|
|
const s1 = timeToMinutes(start1);
|
|
const e1 = timeToMinutes(end1);
|
|
const s2 = timeToMinutes(start2);
|
|
const e2 = timeToMinutes(end2);
|
|
return s1 < e2 && s2 < e1;
|
|
}
|
|
|
|
// ─── Provider ─────────────────────────────────────────────────────────────────
|
|
|
|
export function ShiftProvider({ children }: { children: ReactNode }) {
|
|
const [shifts, setShifts] = useState<ShiftSlot[]>(MOCK_SHIFT_SLOTS);
|
|
const [view, setView] = useState<ScheduleView>("week");
|
|
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
|
|
|
|
// ── Navigation ──────────────────────────────────────────────────────────
|
|
|
|
const goToNextWeek = useCallback(() => {
|
|
setCurrentDate((prev) => {
|
|
const next = new Date(prev);
|
|
next.setDate(next.getDate() + 7);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const goToPrevWeek = useCallback(() => {
|
|
setCurrentDate((prev) => {
|
|
const next = new Date(prev);
|
|
next.setDate(next.getDate() - 7);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const goToNextMonth = useCallback(() => {
|
|
setCurrentDate((prev) => {
|
|
const next = new Date(prev);
|
|
next.setMonth(next.getMonth() + 1);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const goToPrevMonth = useCallback(() => {
|
|
setCurrentDate((prev) => {
|
|
const next = new Date(prev);
|
|
next.setMonth(next.getMonth() - 1);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const goToToday = useCallback(() => {
|
|
setCurrentDate(new Date(2026, 3, 10));
|
|
}, []);
|
|
|
|
// ── Query helpers ───────────────────────────────────────────────────────
|
|
|
|
const getWeekDates = useCallback((): Date[] => {
|
|
const monday = getMonday(currentDate);
|
|
return Array.from({ length: 7 }, (_, i) => {
|
|
const d = new Date(monday);
|
|
d.setDate(monday.getDate() + i);
|
|
return d;
|
|
});
|
|
}, [currentDate]);
|
|
|
|
const getShiftsForDate = useCallback(
|
|
(date: string): ShiftSlot[] => {
|
|
return shifts.filter((s) => s.date === date);
|
|
},
|
|
[shifts],
|
|
);
|
|
|
|
const getShiftsForWeek = useCallback(
|
|
(weekStart: Date): ShiftSlot[] => {
|
|
const dates = Array.from({ length: 7 }, (_, i) => {
|
|
const d = new Date(weekStart);
|
|
d.setDate(weekStart.getDate() + i);
|
|
return formatDate(d);
|
|
});
|
|
return shifts.filter((s) => dates.includes(s.date));
|
|
},
|
|
[shifts],
|
|
);
|
|
|
|
const hasConflict = useCallback(
|
|
(
|
|
date: string,
|
|
startTime: string,
|
|
endTime: string,
|
|
staffId: number,
|
|
excludeShiftId?: string,
|
|
): boolean => {
|
|
return shifts.some(
|
|
(s) =>
|
|
s.date === date &&
|
|
s.id !== excludeShiftId &&
|
|
s.registeredStaff.some((rs) => rs.id === staffId) &&
|
|
timesOverlap(startTime, endTime, s.startTime, s.endTime),
|
|
);
|
|
},
|
|
[shifts],
|
|
);
|
|
|
|
const getWeeklyBudget = useCallback((): number => {
|
|
const weekDates = getWeekDates().map(formatDate);
|
|
return shifts
|
|
.filter((s) => weekDates.includes(s.date) && s.registeredStaff.length > 0)
|
|
.reduce((sum, s) => sum + s.wage * s.registeredStaff.length, 0);
|
|
}, [shifts, getWeekDates]);
|
|
|
|
// ── Shift actions ───────────────────────────────────────────────────────
|
|
|
|
const registerShift = useCallback(
|
|
(
|
|
shiftId: string,
|
|
staffId: number,
|
|
staffName: string,
|
|
): { success: boolean; error?: string } => {
|
|
const shift = shifts.find((s) => s.id === shiftId);
|
|
if (!shift) return { success: false, error: "Shift does not exist." };
|
|
|
|
if (shift.registeredStaff.length >= shift.maxStaff) {
|
|
return { success: false, error: "Shift is already full." };
|
|
}
|
|
|
|
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
|
|
return { success: false, error: "You have already registered for this shift." };
|
|
}
|
|
|
|
if (
|
|
hasConflict(
|
|
shift.date,
|
|
shift.startTime,
|
|
shift.endTime,
|
|
staffId,
|
|
shiftId,
|
|
)
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: "Schedule conflict! You already have an overlapping shift on this day.",
|
|
};
|
|
}
|
|
|
|
setShifts((prev) =>
|
|
prev.map((s) =>
|
|
s.id === shiftId
|
|
? {
|
|
...s,
|
|
registeredStaff: [
|
|
...s.registeredStaff,
|
|
{ id: staffId, name: staffName },
|
|
],
|
|
status: "registered" as ShiftStatus,
|
|
}
|
|
: s,
|
|
),
|
|
);
|
|
|
|
return { success: true };
|
|
},
|
|
[shifts, hasConflict],
|
|
);
|
|
|
|
const unregisterShift = useCallback((shiftId: string, staffId: number) => {
|
|
setShifts((prev) =>
|
|
prev.map((s) => {
|
|
if (s.id !== shiftId) return s;
|
|
const updated = s.registeredStaff.filter((rs) => rs.id !== staffId);
|
|
return {
|
|
...s,
|
|
registeredStaff: updated,
|
|
status:
|
|
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
|
};
|
|
}),
|
|
);
|
|
}, []);
|
|
|
|
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => {
|
|
const newShift: ShiftSlot = {
|
|
...shift,
|
|
id: `shift_${Date.now()}`,
|
|
};
|
|
setShifts((prev) => [...prev, newShift]);
|
|
}, []);
|
|
|
|
const updateShift = useCallback((shift: ShiftSlot) => {
|
|
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
|
}, []);
|
|
|
|
const deleteShift = useCallback((shiftId: string) => {
|
|
setShifts((prev) => prev.filter((s) => s.id !== shiftId));
|
|
}, []);
|
|
|
|
return (
|
|
<ShiftContext.Provider
|
|
value={{
|
|
shifts,
|
|
view,
|
|
setView,
|
|
currentDate,
|
|
goToNextWeek,
|
|
goToPrevWeek,
|
|
goToNextMonth,
|
|
goToPrevMonth,
|
|
goToToday,
|
|
registerShift,
|
|
unregisterShift,
|
|
createShift,
|
|
updateShift,
|
|
deleteShift,
|
|
getShiftsForDate,
|
|
getShiftsForWeek,
|
|
getWeekDates,
|
|
hasConflict,
|
|
getWeeklyBudget,
|
|
}}
|
|
>
|
|
{children}
|
|
</ShiftContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
|
|
|
export function useShift() {
|
|
const context = useContext(ShiftContext);
|
|
if (context === undefined) {
|
|
throw new Error("useShift must be used within a ShiftProvider");
|
|
}
|
|
return context;
|
|
}
|