"use client"; import { gql } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client/react"; import { ReactNode, createContext, useCallback, useContext, useEffect, useState, } from "react"; import { eateryClient } from "./apollo-clients"; import { type ShiftEntity, type allShiftsQuery, createShiftMutation, } from "./types"; // ─── Types ──────────────────────────────────────────────────────────────────── export type ScheduleView = "week" | "month"; interface ShiftContextType { // Data shifts: ShiftEntity[]; 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: string, staffName: string, ) => Promise<{ success: boolean; error?: string }>; unregisterShift: (shiftId: string, staffId: string) => void; createShift: (shift: Omit) => void; deleteShift: (shiftId: string) => Promise; // Helpers getShiftsForDate: (date: Date) => ShiftEntity[]; getShiftsForWeek: (weekStart: Date) => ShiftEntity[]; getWeekDates: () => Date[]; hasConflict: ( date: Date | string, startTime: string, endTime: string, staffId: string, excludeShiftId?: string, ) => boolean; getWeeklyBudget: () => number; } // ─── Context ────────────────────────────────────────────────────────────────── const ShiftContext = createContext(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}`; } // Dùng local date parts thay vì toISOString() để tránh lệch timezone UTC+7 function toLocalDateISO(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}T00:00:00.000Z`; } // Normalize Date or ISO string to "YYYY-MM-DD" for safe comparison function dateKey(d: Date | string | undefined): string { if (!d) return ""; if (d instanceof Date) return formatDate(d); return (d as string).slice(0, 10); } 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; } // ___ GraphQL __________________________________________________________________ const GET_ALL_SHIFTS = gql` query allShifts { allShifts { id date startTime endTime maxStaff wage registeredStaff { id staffId } } } `; const CREATE_SHIFT = gql` mutation createShift($shiftInput: CreateShiftInput!) { createShift(shiftInput: $shiftInput) { id date startTime endTime maxStaff wage } } `; const DELETE_SHIFT = gql` mutation deleteShift($id: String!) { deleteShift(id: $id) } `; const REGISTER_SHIFT = gql` mutation registerShift($RegisterShiftInput: RegisterShiftInput) { registerShift(registration: $RegisterShiftInput) { staffId } } `; // ─── Provider ───────────────────────────────────────────────────────────────── export function ShiftProvider({ children }: { children: ReactNode }) { const [shifts, setShifts] = useState([]); const [view, setView] = useState("week"); const [currentDate, setCurrentDate] = useState(new Date(2026, 3, 10)); // April 10, 2026 const { data, refetch } = useQuery(GET_ALL_SHIFTS, { client: eateryClient, fetchPolicy: "network-only", }); const [mutateCreateShift] = useMutation(CREATE_SHIFT, { client: eateryClient, }); const [mutateDeleteShift] = useMutation<{ deleteShift: boolean }>( DELETE_SHIFT, { client: eateryClient, }, ); const [mutateRegisterShift] = useMutation< { registerShift: { staffId: string } }, { RegisterShiftInput: { shiftId: string } } >(REGISTER_SHIFT, { client: eateryClient }); useEffect(() => { if (data) setShifts(data.allShifts); }, [data]); // ── 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: Date): ShiftEntity[] => { const key = formatDate(date); return shifts.filter((s) => dateKey(s.date) === key); }, [shifts], ); const getShiftsForWeek = useCallback( (weekStart: Date): ShiftEntity[] => { const dates = Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart); d.setDate(weekStart.getDate() + i); return d; }); const keys = new Set(dates.map(formatDate)); return shifts.filter((s) => keys.has(dateKey(s.date))); }, [shifts], ); const hasConflict = useCallback( ( date: Date | string, startTime: string, endTime: string, staffId: string, excludeShiftId?: string, ): boolean => { const key = dateKey(date); return shifts.some( (s) => dateKey(s.date) === key && 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(); const weekKeys = new Set(weekDates.map(formatDate)); return shifts .filter( (s) => weekKeys.has(dateKey(s.date)) && (s.registeredStaff ?? []).length > 0, ) .reduce( (sum, s) => sum + (s.wage ?? 0) * (s.registeredStaff ?? []).length, 0, ); }, [shifts, getWeekDates]); // ── Shift actions ─────────────────────────────────────────────────────── const registerShift = useCallback( async ( shiftId: string, staffId: string, staffName: string, ): Promise<{ success: boolean; error?: string }> => { const shift = shifts.find((s) => s.id === shiftId); if (!shift) return { success: false, error: "Ca làm không tồn tại." }; if ((shift.registeredStaff ?? []).length >= shift.maxStaff) { return { success: false, error: "Ca làm đã đủ người." }; } if ((shift.registeredStaff ?? []).some((rs) => rs.id === staffId)) { return { success: false, error: "Bạn đã đăng ký ca này rồi." }; } if ( shift.date && hasConflict( shift.date, shift.startTime, shift.endTime, staffId, shiftId, ) ) { return { success: false, error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.", }; } try { await mutateRegisterShift({ variables: { RegisterShiftInput: { shiftId } }, }); refetch(); return { success: true }; } catch (err: unknown) { const message = err instanceof Error ? err.message : "Đăng ký thất bại, thử lại sau."; return { success: false, error: message }; } }, [shifts, hasConflict, mutateRegisterShift, refetch], ); const unregisterShift = useCallback((shiftId: string, staffId: string) => { setShifts((prev) => prev.map((s) => { if (s.id !== shiftId) return s; const updated = (s.registeredStaff ?? []).filter( (rs) => rs.id !== staffId, ); return { ...s, registeredStaff: updated, }; }), ); }, []); const createShift = useCallback( async (shift: Omit) => { try { const { data } = await mutateCreateShift({ variables: { shiftInput: { date: toLocalDateISO(shift.date ?? new Date()), startTime: shift.startTime, endTime: shift.endTime, maxStaff: shift.maxStaff, wage: shift.wage, }, }, }); if (data) { // Optimistic: dùng date từ server nếu có, fallback sang local const serverDate = data.createShift.date ? new Date(data.createShift.date as unknown as string) : shift.date; setShifts((prev) => [ ...prev, { ...data.createShift, date: serverDate, wage: data.createShift.wage ?? shift.wage, registeredStaff: [], }, ]); // Refetch để đồng bộ với server refetch(); } } catch (err) { console.error("[createShift] mutation failed:", err); } }, [mutateCreateShift, refetch], ); const deleteShift = useCallback( async (shiftId: string) => { try { const { data } = await mutateDeleteShift({ variables: { id: shiftId }, }); if (data?.deleteShift) { setShifts((prev) => prev.filter((s) => s.id !== shiftId)); } } catch (err) { console.error("[deleteShift] mutation failed:", err); } }, [mutateDeleteShift], ); return ( {children} ); } // ─── Hook ───────────────────────────────────────────────────────────────────── export function useShift() { const context = useContext(ShiftContext); if (context === undefined) { throw new Error("useShift must be used within a ShiftProvider"); } return context; }