Files
frontend/lib/shift-context.tsx
T
2026-05-14 15:57:15 +00:00

457 lines
13 KiB
TypeScript

"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<ShiftEntity, "id">) => void;
deleteShift: (shiftId: string) => Promise<void>;
// 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<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}`;
}
// 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<ShiftEntity[]>([]);
const [view, setView] = useState<ScheduleView>("week");
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
const { data, refetch } = useQuery<allShiftsQuery>(GET_ALL_SHIFTS, {
client: eateryClient,
fetchPolicy: "network-only",
});
const [mutateCreateShift] = useMutation<createShiftMutation>(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<ShiftEntity, "id">) => {
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 (
<ShiftContext.Provider
value={{
shifts,
view,
setView,
currentDate,
goToNextWeek,
goToPrevWeek,
goToNextMonth,
goToPrevMonth,
goToToday,
registerShift,
unregisterShift,
createShift,
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;
}