fix: add shift management (#41)
Release package / release (push) Successful in 10m13s

Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
2026-05-14 03:13:06 +00:00
parent dfcb1b09c0
commit 9ed4889310
18 changed files with 305 additions and 422 deletions
+18 -64
View File
@@ -1,13 +1,9 @@
import type {
Department,
MenuItemEntity,
ProductSalesStats,
RevenueDataPoint,
ShiftSlot,
Shop,
ShiftEntity,
ShopInfo,
SocialLinks,
User,
} from "./types";
// ===== SHOP INFORMATION =====
@@ -33,45 +29,6 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/",
};
// ===== MOCK SHOPS (for Feed page) =====
export const MOCK_SHOPS: Shop[] = [
{
id: 1,
name: "The Coffee House",
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1554118811-1e0d58224f24?w=600&h=400&fit=crop",
},
{
id: 2,
name: "Highlands Coffee",
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1559305616-3f99cd43e353?w=600&h=400&fit=crop",
},
{
id: 3,
name: "Phúc Long Heritage",
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=600&h=400&fit=crop",
},
{
id: 4,
name: "Katinat Saigon Kafe",
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600&h=400&fit=crop",
},
{
id: 5,
name: "Trung Nguyên E-Coffee",
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1453614512568-c4024d13c247?w=600&h=400&fit=crop",
},
];
// ===== MOCK FINANCIAL DATA =====
// Daily revenue for the last 30 days (current month)
@@ -153,9 +110,9 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
* 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"];
function generateMockShifts(): ShiftEntity[] {
const shifts: ShiftEntity[] = [];
const departments = ["waiter"];
const timeSlots = [
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 },
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 },
@@ -189,7 +146,7 @@ function generateMockShifts(): ShiftSlot[] {
// Determine registration status
const registered: { id: number; name: string }[] = [];
let status: ShiftSlot["status"] = "available";
// let status: ShiftEntity["status"] = "available";
// Past shifts (before April 10) are mostly registered
if (day < 10) {
@@ -198,37 +155,37 @@ function generateMockShifts(): ShiftSlot[] {
if (shiftCounter % 7 === 0) {
registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
}
status = "registered";
// status = "registered";
// Some past shifts have leave/absent
if (shiftCounter % 11 === 0) status = "approved_leave";
if (shiftCounter % 13 === 0) status = "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";
// status = "registered";
}
}
// Future shifts: mostly available, some registered
else {
if (shiftCounter % 5 === 0) {
registered.push(staffPool[shiftCounter % staffPool.length]);
status = "registered";
// status = "registered";
}
}
shifts.push({
id: "shiftId",
date: dateStr,
date: new Date(dateStr),
startTime: slot.start,
endTime: slot.end,
durationHours: slot.hours,
// durationHours: slot.hours,
wage: slot.wage,
department: dept,
// department: dept,
maxStaff: dept === "bar" ? 3 : 2,
registeredStaff: registered,
status,
// registeredStaff: registered,
// status,
});
}
}
@@ -237,11 +194,8 @@ function generateMockShifts(): ShiftSlot[] {
return shifts;
}
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
export const MOCK_SHIFT_SLOTS: ShiftEntity[] = generateMockShifts();
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" },
];
{ id: "waiter", name: "Bar Staff", icon: "fa-brands fa-jenkins" },
];
+108 -50
View File
@@ -1,15 +1,22 @@
"use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { MOCK_SHIFT_SLOTS } from "./constants";
import type { ShiftSlot, ShiftStatus } from "./types";
import { eateryClient } from "./apollo-clients";
import {
type ShiftEntity,
type allShiftsQuery,
createShiftMutation,
} from "./types";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -17,7 +24,7 @@ export type ScheduleView = "week" | "month";
interface ShiftContextType {
// Data
shifts: ShiftSlot[];
shifts: ShiftEntity[];
view: ScheduleView;
setView: (view: ScheduleView) => void;
@@ -32,23 +39,23 @@ interface ShiftContextType {
// Shift actions
registerShift: (
shiftId: string,
staffId: number,
staffId: string,
staffName: string,
) => { success: boolean; error?: string };
unregisterShift: (shiftId: string, staffId: number) => void;
createShift: (shift: Omit<ShiftSlot, "id">) => void;
updateShift: (shift: ShiftSlot) => void;
unregisterShift: (shiftId: string, staffId: string) => void;
createShift: (shift: Omit<ShiftEntity, "id">) => void;
updateShift: (shift: ShiftEntity) => void;
deleteShift: (shiftId: string) => void;
// Helpers
getShiftsForDate: (date: string) => ShiftSlot[];
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
getShiftsForDate: (date: Date) => ShiftEntity[];
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
getWeekDates: () => Date[];
hasConflict: (
date: string,
date: Date,
startTime: string,
endTime: string,
staffId: number,
staffId: string,
excludeShiftId?: string,
) => boolean;
getWeeklyBudget: () => number;
@@ -94,13 +101,60 @@ function timesOverlap(
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<ShiftSlot[]>(MOCK_SHIFT_SLOTS);
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(() => {
@@ -151,18 +205,20 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
}, [currentDate]);
const getShiftsForDate = useCallback(
(date: string): ShiftSlot[] => {
return shifts.filter((s) => s.date === date);
(date: Date): ShiftEntity[] => {
return shifts.filter((s) => {
return new Date(s.date).getDate() === date.getDate();
});
},
[shifts],
);
const getShiftsForWeek = useCallback(
(weekStart: Date): ShiftSlot[] => {
(weekStart: Date): ShiftEntity[] => {
const dates = Array.from({ length: 7 }, (_, i) => {
const d = new Date(weekStart);
d.setDate(weekStart.getDate() + i);
return formatDate(d);
return d;
});
return shifts.filter((s) => dates.includes(s.date));
},
@@ -171,17 +227,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
const hasConflict = useCallback(
(
date: string,
date: Date,
startTime: string,
endTime: string,
staffId: number,
staffId: string,
excludeShiftId?: string,
): boolean => {
return shifts.some(
(s) =>
s.date === date &&
s.id !== excludeShiftId &&
s.registeredStaff.some((rs) => rs.id === staffId) &&
s.registeredStaff!.some((rs) => rs.id === staffId) &&
timesOverlap(startTime, endTime, s.startTime, s.endTime),
);
},
@@ -189,10 +245,12 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
);
const getWeeklyBudget = useCallback((): number => {
const weekDates = getWeekDates().map(formatDate);
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);
.filter(
(s) => weekDates.includes(s.date) && s.registeredStaff!.length > 0,
)
.reduce((sum, s) => sum + s.wage * s.registeredStaff!.length, 0);
}, [shifts, getWeekDates]);
// ── Shift actions ───────────────────────────────────────────────────────
@@ -200,17 +258,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
const registerShift = useCallback(
(
shiftId: string,
staffId: number,
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) {
if (shift.registeredStaff!.length >= shift.maxStaff) {
return { success: false, error: "Ca làm đã đủ người." };
}
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
if (shift.registeredStaff!.some((rs) => rs.id === staffId)) {
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
}
@@ -229,50 +287,50 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
};
}
setShifts((prev) =>
prev.map((s) =>
s.id === shiftId
? {
...s,
registeredStaff: [
...s.registeredStaff,
{ id: staffId, name: staffName },
],
status: "registered" as ShiftStatus,
}
: s,
),
);
// 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: number) => {
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);
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 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: ShiftSlot) => {
const updateShift = useCallback((shift: ShiftEntity) => {
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
}, []);
+24 -16
View File
@@ -2,7 +2,7 @@
export type UserRole = "manager" | "staff" | "customer";
export interface User {
id: number;
id: string;
name: string;
role: UserRole;
avatar: string | null;
@@ -88,28 +88,26 @@ export type ShiftStatus =
| "approved_leave"
| "absent";
export interface RegisteredStaff {
id: number;
name: string;
export interface ShiftRegistrationEntity {
id: string;
staffId: string;
shift: ShiftEntity;
}
export interface ShiftSlot {
export interface ShiftEntity {
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;
date: Date;
startTime: string;
endTime: string;
wage: number;
maxStaff: number;
registeredStaff: RegisteredStaff[];
status: ShiftStatus;
registeredStaff?: ShiftRegistrationEntity[];
}
export interface Department {
id: string;
name: string;
icon: string; // FontAwesome class
icon: string;
}
export interface MenuItemEntity {
@@ -122,8 +120,6 @@ export interface MenuItemEntity {
description: string;
}
export interface ShiftEntity {}
export interface EateryEntity {
id: string;
ownerId: string;
@@ -172,3 +168,15 @@ export interface createCartMutation {
export interface addMenuItemMutation {
addItem: CartEntity;
}
export interface allRegistrationsQuery {
allRegistrations: ShiftRegistrationEntity[];
}
export interface allShiftsQuery {
allShifts: ShiftEntity[];
}
export interface createShiftMutation {
createShift: ShiftEntity;
}