Files
frontend/lib/shift-context.tsx
T
TakahashiNguyen 9ed4889310
Release package / release (push) Successful in 10m13s
fix: add shift management (#41)
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #41
2026-05-14 03:13:06 +00:00

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 {
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,
) => { 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;
}