Files
frontend/lib/shift-context.tsx
T
Thanh Quy- wolf 0842960888 feat: enhance login functionality and add staff creation page
- Update LoginForm to handle staff role and redirect accordingly.
- Improve ProductsTab UI for better visibility when no products are found.
- Refactor ShiftCreateModal to use custom TimeInput component for better time handling.
- Add parseShiftDate utility in ShiftDetailModal for improved date parsing.
- Modify ShiftContext to handle shift deletion with proper error handling.
- Introduce feature-workflow-tracer skill for tracing feature workflows in the codebase.
- Add learning-assistant skill to support users in understanding concepts and code.
- Implement CreateStaffPage for manager to create new staff accounts with validation and error handling.
2026-05-14 16:51:21 +07:00

417 lines
12 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;
deleteShift: (shiftId: string) => Promise<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}`;
}
// 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`;
}
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
}
}
`;
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)
}
`;
// ─── 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,
});
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 ?? 0) * (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">) => {
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;
}