379 lines
10 KiB
TypeScript
379 lines
10 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 { MOCK_SHIFT_SLOTS } from "./constants";
|
|
import {
|
|
type ShiftEntity,
|
|
type ShiftStatus,
|
|
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,
|
|
) => { success: boolean; error?: string };
|
|
unregisterShift: (shiftId: string, staffId: string) => void;
|
|
createShift: (shift: Omit<ShiftEntity, "id">) => void;
|
|
updateShift: (shift: ShiftEntity) => void;
|
|
deleteShift: (shiftId: string) => void;
|
|
|
|
// Helpers
|
|
getShiftsForDate: (date: Date) => ShiftEntity[];
|
|
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
|
|
getWeekDates: () => Date[];
|
|
hasConflict: (
|
|
date: Date,
|
|
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}`;
|
|
}
|
|
|
|
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 {
|
|
staffId
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const CREATE_SHIFT = gql`
|
|
mutation createShift($shiftInput: CreateShiftInput!) {
|
|
createShift(shiftInput: $shiftInput) {
|
|
id
|
|
date
|
|
startTime
|
|
endTime
|
|
maxStaff
|
|
wage
|
|
registeredStaff {
|
|
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 } = useQuery<allShiftsQuery>(GET_ALL_SHIFTS, {
|
|
client: eateryClient,
|
|
fetchPolicy: "network-only",
|
|
});
|
|
|
|
const [mutateCreateShift] = useMutation<createShiftMutation>(CREATE_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[] => {
|
|
return shifts.filter((s) => {
|
|
return new Date(s.date).getDate() === date.getDate();
|
|
});
|
|
},
|
|
[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;
|
|
});
|
|
return shifts.filter((s) => dates.includes(s.date));
|
|
},
|
|
[shifts],
|
|
);
|
|
|
|
const hasConflict = useCallback(
|
|
(
|
|
date: Date,
|
|
startTime: string,
|
|
endTime: string,
|
|
staffId: string,
|
|
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();
|
|
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: string,
|
|
staffName: string,
|
|
): { 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 (
|
|
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.",
|
|
};
|
|
}
|
|
|
|
// setShifts((prev) =>
|
|
// prev.map((s) =>
|
|
// s.id === shiftId
|
|
// ? {
|
|
// ...s,
|
|
// registeredStaff: [
|
|
// ...s.registeredStaff,
|
|
// { staffId, name: staffName },
|
|
// ],
|
|
// status: "registered" as ShiftStatus,
|
|
// }
|
|
// : s,
|
|
// ),
|
|
// );
|
|
|
|
return { success: true };
|
|
},
|
|
[shifts, hasConflict],
|
|
);
|
|
|
|
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">) => {
|
|
const { data } = await mutateCreateShift({
|
|
variables: {
|
|
shiftInput: shift,
|
|
},
|
|
});
|
|
|
|
if (data) setShifts((prev) => [...prev, data.createShift]);
|
|
}, []);
|
|
|
|
const updateShift = useCallback((shift: ShiftEntity) => {
|
|
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;
|
|
}
|