Co-authored-by: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn> Co-authored-by: Thanh Quy- wolf <524H0124@student.tdtu.edu.vn> Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com> Reviewed-on: #29 Co-authored-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost> Co-committed-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
This commit was merged in pull request #29.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import type {
|
||||
Combo,
|
||||
Department,
|
||||
MenuCategory,
|
||||
Product,
|
||||
ProductSalesStats,
|
||||
RevenueDataPoint,
|
||||
ShiftSlot,
|
||||
Shop,
|
||||
ShopInfo,
|
||||
SocialLinks,
|
||||
@@ -608,3 +610,102 @@ export const MOCK_USERS: Record<string, User> = {
|
||||
avatar: null,
|
||||
},
|
||||
};
|
||||
|
||||
// ===== SHIFT / SCHEDULE DATA =====
|
||||
|
||||
export const DEPARTMENTS: Department[] = [
|
||||
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" },
|
||||
{ id: "kitchen", name: "Kitchen", icon: "fa-solid fa-kitchen-set" },
|
||||
{ id: "cashier", name: "Cashier", icon: "fa-solid fa-cash-register" },
|
||||
{ id: "janitor", name: "Janitor", icon: "fa-solid fa-broom" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate mock shift slots for the weeks around today (April 2026).
|
||||
* Covers Mon 6 Apr – Sun 26 Apr 2026.
|
||||
*/
|
||||
function generateMockShifts(): ShiftSlot[] {
|
||||
const shifts: ShiftSlot[] = [];
|
||||
const departments = ["bar", "kitchen", "cashier", "janitor"];
|
||||
const timeSlots = [
|
||||
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 },
|
||||
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 },
|
||||
{ start: "15:00", end: "19:00", hours: 4, wage: 120000 },
|
||||
{ start: "19:00", end: "22:00", hours: 3, wage: 100000 },
|
||||
];
|
||||
|
||||
const staffPool = [
|
||||
{ id: 2, name: "Nguyễn Văn An" },
|
||||
{ id: 3, name: "Trần Thị Bình" },
|
||||
{ id: 4, name: "Lê Văn Cường" },
|
||||
];
|
||||
|
||||
// Generate shifts from April 6 to April 26, 2026
|
||||
let shiftCounter = 0;
|
||||
for (let day = 6; day <= 26; day++) {
|
||||
const dateStr = `2026-04-${day.toString().padStart(2, "0")}`;
|
||||
|
||||
for (const dept of departments) {
|
||||
// Not every department has shifts every day
|
||||
if (dept === "janitor" && day % 3 !== 0) continue;
|
||||
|
||||
for (const slot of timeSlots) {
|
||||
// Skip some slots randomly for variety
|
||||
if (dept === "kitchen" && slot.start === "19:00") continue;
|
||||
if (dept === "cashier" && slot.start === "07:00" && day % 2 === 0)
|
||||
continue;
|
||||
|
||||
shiftCounter++;
|
||||
const shiftId = `shift_${shiftCounter.toString().padStart(3, "0")}`;
|
||||
|
||||
// Determine registration status
|
||||
const registered: { id: number; name: string }[] = [];
|
||||
let status: ShiftSlot["status"] = "available";
|
||||
|
||||
// Past shifts (before April 10) are mostly registered
|
||||
if (day < 10) {
|
||||
const staffIdx = shiftCounter % staffPool.length;
|
||||
registered.push(staffPool[staffIdx]);
|
||||
if (shiftCounter % 7 === 0) {
|
||||
registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
|
||||
}
|
||||
status = "registered";
|
||||
// Some past shifts have leave/absent
|
||||
if (shiftCounter % 11 === 0) status = "approved_leave";
|
||||
if (shiftCounter % 13 === 0) status = "absent";
|
||||
}
|
||||
// Current week (April 6-12): mix of registered and available
|
||||
else if (day >= 10 && day <= 12) {
|
||||
if (shiftCounter % 3 === 0) {
|
||||
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||
status = "registered";
|
||||
}
|
||||
}
|
||||
// Future shifts: mostly available, some registered
|
||||
else {
|
||||
if (shiftCounter % 5 === 0) {
|
||||
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||
status = "registered";
|
||||
}
|
||||
}
|
||||
|
||||
shifts.push({
|
||||
id: shiftId,
|
||||
date: dateStr,
|
||||
startTime: slot.start,
|
||||
endTime: slot.end,
|
||||
durationHours: slot.hours,
|
||||
wage: slot.wage,
|
||||
department: dept,
|
||||
maxStaff: dept === "bar" ? 3 : 2,
|
||||
registeredStaff: registered,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shifts;
|
||||
}
|
||||
|
||||
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
import { MOCK_SHIFT_SLOTS } from "./constants";
|
||||
import type { ShiftSlot, ShiftStatus } from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ScheduleView = "week" | "month";
|
||||
|
||||
interface ShiftContextType {
|
||||
// Data
|
||||
shifts: ShiftSlot[];
|
||||
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: number, staffName: string) => { success: boolean; error?: string };
|
||||
unregisterShift: (shiftId: string, staffId: number) => void;
|
||||
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
||||
updateShift: (shift: ShiftSlot) => void;
|
||||
deleteShift: (shiftId: string) => void;
|
||||
|
||||
// Helpers
|
||||
getShiftsForDate: (date: string) => ShiftSlot[];
|
||||
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
||||
getWeekDates: () => Date[];
|
||||
hasConflict: (date: string, startTime: string, endTime: string, staffId: number, 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;
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
const [shifts, setShifts] = useState<ShiftSlot[]>(MOCK_SHIFT_SLOTS);
|
||||
const [view, setView] = useState<ScheduleView>("week");
|
||||
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
|
||||
|
||||
// ── 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: string): ShiftSlot[] => {
|
||||
return shifts.filter((s) => s.date === date);
|
||||
},
|
||||
[shifts],
|
||||
);
|
||||
|
||||
const getShiftsForWeek = useCallback(
|
||||
(weekStart: Date): ShiftSlot[] => {
|
||||
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(weekStart);
|
||||
d.setDate(weekStart.getDate() + i);
|
||||
return formatDate(d);
|
||||
});
|
||||
return shifts.filter((s) => dates.includes(s.date));
|
||||
},
|
||||
[shifts],
|
||||
);
|
||||
|
||||
const hasConflict = useCallback(
|
||||
(
|
||||
date: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
staffId: number,
|
||||
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().map(formatDate);
|
||||
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: number,
|
||||
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, { id: staffId, name: staffName }],
|
||||
status: "registered" as ShiftStatus,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
[shifts, hasConflict],
|
||||
);
|
||||
|
||||
const unregisterShift = useCallback((shiftId: string, staffId: number) => {
|
||||
setShifts((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== shiftId) return s;
|
||||
const updated = s.registeredStaff.filter((rs) => rs.id !== staffId);
|
||||
return {
|
||||
...s,
|
||||
registeredStaff: updated,
|
||||
status: updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => {
|
||||
const newShift: ShiftSlot = {
|
||||
...shift,
|
||||
id: `shift_${Date.now()}`,
|
||||
};
|
||||
setShifts((prev) => [...prev, newShift]);
|
||||
}, []);
|
||||
|
||||
const updateShift = useCallback((shift: ShiftSlot) => {
|
||||
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;
|
||||
}
|
||||
@@ -114,3 +114,30 @@ export interface Combo {
|
||||
items: ComboItem[]; // list of products + quantities in this combo
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// ===== SHIFT / SCHEDULE TYPES =====
|
||||
export type ShiftStatus = "available" | "registered" | "approved_leave" | "absent";
|
||||
|
||||
export interface RegisteredStaff {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ShiftSlot {
|
||||
id: string;
|
||||
date: string; // ISO date string "YYYY-MM-DD"
|
||||
startTime: string; // "HH:mm"
|
||||
endTime: string; // "HH:mm"
|
||||
durationHours: number;
|
||||
wage: number; // VND per shift
|
||||
department: string;
|
||||
maxStaff: number;
|
||||
registeredStaff: RegisteredStaff[];
|
||||
status: ShiftStatus;
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // FontAwesome class
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user