Merge branch 'main' of https://git.demonkernel.io.vn/FoodSurf/frontend into ii
Release package / release (pull_request) Has been cancelled

This commit is contained in:
Thanh Quy- wolf
2026-05-14 10:16:27 +07:00
20 changed files with 308 additions and 425 deletions
+1 -3
View File
@@ -95,7 +95,6 @@ jobs:
password: ${{ secrets.ACCESS_TOKEN }} password: ${{ secrets.ACCESS_TOKEN }}
- name: Prepare Docker Metadata - name: Prepare Docker Metadata
if: gitea.ref == 'refs/heads/main'
id: meta id: meta
run: | run: |
# Lowercase Repository # Lowercase Repository
@@ -108,11 +107,10 @@ jobs:
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
- name: Build and Push Docker Image - name: Build and Push Docker Image
if: gitea.ref == 'refs/heads/main'
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: true push: ${{ github.ref_name == 'main' }}
tags: | tags: |
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }} vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { SearchBar } from "@/components/molecules/search-bar";
import { ShopGrid } from "@/components/organisms/shop-grid";
import { useState } from "react";
export default function FeedPage() {
const [searchName, setSearchName] = useState("");
const [searchAddress, setSearchAddress] = useState("");
const hasFilters = searchName || searchAddress;
return (
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
<div className="mx-auto max-w-7xl px-4 py-8 md:px-6 lg:px-8">
{/* Page title */}
<div className="mb-8">
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
Explore Shops
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Find and choose your favorite shop
</p>
</div>
{/* Shop grid */}
<div className="mb-10">
<ShopGrid searchName={searchName} searchAddress={searchAddress} />
{hasFilters && (
<div className="mt-4 flex justify-center">
<button
onClick={() => {
setSearchName("");
setSearchAddress("");
}}
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
>
Clear filters
</button>
</div>
)}
</div>
{/* Filter / Search bar — sticky bottom */}
<div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5">
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
<i className="fa-solid fa-filter text-(--color-primary)"></i>
<span>Filter shops</span>
</div>
{/* Name search */}
<SearchBar
value={searchName}
onChange={setSearchName}
onClear={() => setSearchName("")}
placeholder="Search by shop name..."
className="min-w-0 flex-1"
/>
{/* Address search — different icon so not using SearchBar atom */}
<div className="relative min-w-0 flex-1">
<i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
<input
type="text"
value={searchAddress}
onChange={(e) => setSearchAddress(e.target.value)}
placeholder="Search by address..."
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
/>
{searchAddress && (
<button
onClick={() => setSearchAddress("")}
aria-label="Clear address search"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
</div>
</div>
</div>
</main>
);
}
-9
View File
@@ -1,9 +0,0 @@
import { FeedLayout } from "@/components/templates/feed-layout";
export default function RootFeedLayout({
children,
}: {
children: React.ReactNode;
}) {
return <FeedLayout>{children}</FeedLayout>;
}
+6 -6
View File
@@ -7,7 +7,7 @@ import ShiftDetailModal from "@/components/organisms/shift-schedule/ShiftDetailM
import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule"; import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { useShift } from "@/lib/shift-context"; import { useShift } from "@/lib/shift-context";
import type { ShiftSlot } from "@/lib/types"; import type { ShiftEntity } from "@/lib/types";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
@@ -52,24 +52,24 @@ export default function StaffSchedulePage() {
getWeeklyBudget, getWeeklyBudget,
} = useShift(); } = useShift();
const [selectedShift, setSelectedShift] = useState<ShiftSlot | null>(null); const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createDate, setCreateDate] = useState<string | undefined>(); const [createDate, setCreateDate] = useState<Date | undefined>();
const isManager = user?.role === "manager"; const isManager = user?.role === "manager";
const handleShiftClick = (shift: ShiftSlot) => { const handleShiftClick = (shift: ShiftEntity) => {
setSelectedShift(shift); setSelectedShift(shift);
setDetailOpen(true); setDetailOpen(true);
}; };
const handleCreateShift = (date: string) => { const handleCreateShift = (date: Date) => {
setCreateDate(date); setCreateDate(date);
setCreateOpen(true); setCreateOpen(true);
}; };
const handleDateSelect = (date: string) => { const handleDateSelect = (date: Date) => {
// In month view on desktop, clicking a date could open create modal for managers // In month view on desktop, clicking a date could open create modal for managers
if (isManager) { if (isManager) {
setCreateDate(date); setCreateDate(date);
+19 -44
View File
@@ -1,35 +1,9 @@
"use client"; "use client";
import type { ShiftSlot } from "@/lib/types"; import type { ShiftEntity } from "@/lib/types";
import type { ShiftCardProps } from "./ShiftCard.types"; import type { ShiftCardProps } from "./ShiftCard.types";
const STATUS_STYLES: Record<
ShiftSlot["status"],
{ bg: string; text: string; label: string }
> = {
available: {
bg: "bg-blue-50 border-blue-200",
text: "text-blue-700",
label: "Còn trống",
},
registered: {
bg: "bg-blue-100 border-blue-400",
text: "text-blue-900",
label: "Đã đăng ký",
},
approved_leave: {
bg: "bg-purple-50 border-purple-300",
text: "text-purple-700",
label: "Nghỉ phép",
},
absent: {
bg: "bg-red-50 border-red-300",
text: "text-red-700",
label: "Vắng mặt",
},
};
function formatWage(wage: number): string { function formatWage(wage: number): string {
if (wage >= 1000) { if (wage >= 1000) {
return `${(wage / 1000).toFixed(0)}k`; return `${(wage / 1000).toFixed(0)}k`;
@@ -42,20 +16,20 @@ export default function ShiftCard({
compact = false, compact = false,
onClick, onClick,
}: ShiftCardProps) { }: ShiftCardProps) {
const style = STATUS_STYLES[shift.status]; console.log(shift);
if (compact) { if (compact) {
return ( return (
<button <button
type="button" type="button"
onClick={() => onClick?.(shift)} onClick={() => onClick?.(shift)}
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm ${style.bg} ${style.text}`} className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm `}
> >
<p className="font-semibold"> <p className="font-semibold">
{shift.startTime} {shift.endTime} {shift.startTime} {shift.endTime}
</p> </p>
<p className="mt-0.5 opacity-75"> <p className="mt-0.5 opacity-75">
{shift.durationHours}h · {formatWage(shift.wage)} {}h · {formatWage(shift.wage)}
</p> </p>
</button> </button>
); );
@@ -65,7 +39,7 @@ export default function ShiftCard({
<button <button
type="button" type="button"
onClick={() => onClick?.(shift)} onClick={() => onClick?.(shift)}
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md ${style.bg} ${style.text}`} className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md `}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
@@ -73,25 +47,26 @@ export default function ShiftCard({
{shift.startTime} {shift.endTime} {shift.startTime} {shift.endTime}
</p> </p>
<p className="mt-1 text-xs opacity-75"> <p className="mt-1 text-xs opacity-75">
{shift.durationHours}h · {formatWage(shift.wage)} VND {}h · {formatWage(shift.wage)} VND
</p> </p>
</div> </div>
<span <span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${ className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
shift.status === "available" // shift.status === "available"
? "bg-blue-200 text-blue-800" // ? "bg-blue-200 text-blue-800"
: shift.status === "registered" // : shift.status === "registered"
? "bg-blue-300 text-blue-900" // ? "bg-blue-300 text-blue-900"
: shift.status === "approved_leave" // : shift.status === "approved_leave"
? "bg-purple-200 text-purple-800" // ? "bg-purple-200 text-purple-800"
: "bg-red-200 text-red-800" // : "bg-red-200 text-red-800"
""
}`} }`}
> >
{style.label} {/* {style.label} */}
</span> </span>
</div> </div>
{shift.registeredStaff.length > 0 && ( {(shift.registeredStaff && shift.registeredStaff.length > 0) && (
<div className="mt-2 border-t border-current/10 pt-2"> <div className="mt-2 border-t border-current/10 pt-2">
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60"> <p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff}) Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
@@ -102,18 +77,18 @@ export default function ShiftCard({
key={s.id} key={s.id}
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium" className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
> >
{s.name} {s.staffId}
</span> </span>
))} ))}
</div> </div>
</div> </div>
)} )}
{shift.status === "available" && shift.registeredStaff.length === 0 && ( {/* {shift.status === "available" && shift.registeredStaff.length === 0 && (
<p className="mt-2 text-[10px] italic opacity-50"> <p className="mt-2 text-[10px] italic opacity-50">
{shift.maxStaff} vị trí còn trống {shift.maxStaff} vị trí còn trống
</p> </p>
)} )} */}
</button> </button>
); );
} }
@@ -1,7 +1,7 @@
import type { ShiftSlot } from "@/lib/types"; import type { ShiftEntity } from "@/lib/types";
export interface ShiftCardProps { export interface ShiftCardProps {
shift: ShiftSlot; shift: ShiftEntity;
compact?: boolean; compact?: boolean;
onClick?: (shift: ShiftSlot) => void; onClick?: (shift: ShiftEntity) => void;
} }
+1 -1
View File
@@ -15,7 +15,7 @@ export { ReviewModal } from "./modals";
export type { ReviewModalProps, ConfirmModalProps } from "./modals"; export type { ReviewModalProps, ConfirmModalProps } from "./modals";
// Shop Grid // Shop Grid
export { ShopGrid } from "./shop-grid"; // export { ShopGrid } from "./shop-grid";
export type { ShopGridProps } from "./shop-grid"; export type { ShopGridProps } from "./shop-grid";
// Manager // Manager
@@ -44,9 +44,7 @@ export default function MobileShiftView({
onShiftClick, onShiftClick,
}: MobileShiftViewProps) { }: MobileShiftViewProps) {
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift(); const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
const [selectedDate, setSelectedDate] = useState<string>( const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
formatDateISO(new Date(2026, 3, 10)),
);
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
@@ -67,21 +65,20 @@ export default function MobileShiftView({
}, [currentDate]); }, [currentDate]);
const getDotColors = (date: Date): string[] => { const getDotColors = (date: Date): string[] => {
const dateStr = formatDateISO(date); const dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
const dayShifts = shifts.filter((s) => s.date === dateStr);
const dots: string[] = []; const dots: string[] = [];
if (dayShifts.some((s) => s.status === "available")) // if (dayShifts.some((s) => s.status === "available"))
dots.push("bg-amber-400"); // dots.push("bg-amber-400");
if (dayShifts.some((s) => s.status === "registered")) // if (dayShifts.some((s) => s.status === "registered"))
dots.push("bg-green-500"); // dots.push("bg-green-500");
if (dayShifts.some((s) => s.status === "approved_leave")) // if (dayShifts.some((s) => s.status === "approved_leave"))
dots.push("bg-purple-400"); // dots.push("bg-purple-400");
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400"); // if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
return dots; return dots;
}; };
const selectedShifts = useMemo(() => { const selectedShifts = useMemo(() => {
return shifts.filter((s) => s.date === selectedDate); return shifts.filter((s) => s.date.getDate() === selectedDate.getDate());
}, [shifts, selectedDate]); }, [shifts, selectedDate]);
const selectedDateObj = new Date(selectedDate + "T00:00:00"); const selectedDateObj = new Date(selectedDate + "T00:00:00");
@@ -133,16 +130,15 @@ export default function MobileShiftView({
return <div key={`empty-${i}`} className="p-1" />; return <div key={`empty-${i}`} className="p-1" />;
} }
const dateStr = formatDateISO(date);
const today = isToday(date); const today = isToday(date);
const selected = dateStr === selectedDate; const selected = date === selectedDate;
const dots = getDotColors(date); const dots = getDotColors(date);
return ( return (
<button <button
type="button" type="button"
key={i} key={i}
onClick={() => setSelectedDate(dateStr)} onClick={() => setSelectedDate(date)}
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${ className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
selected ? "rounded-lg bg-(--color-primary)/10" : "" selected ? "rounded-lg bg-(--color-primary)/10" : ""
}`} }`}
@@ -218,9 +214,7 @@ export default function MobileShiftView({
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{DEPARTMENTS.map((dept) => { {DEPARTMENTS.map((dept) => {
const deptShifts = selectedShifts.filter( const deptShifts = selectedShifts;
(s) => s.department === dept.id,
);
if (deptShifts.length === 0) return null; if (deptShifts.length === 0) return null;
return ( return (
<div key={dept.id}> <div key={dept.id}>
@@ -7,7 +7,7 @@ import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function formatDateISO(d: Date): string { export function formatDateISO(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0"); const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0"); const day = d.getDate().toString().padStart(2, "0");
@@ -60,15 +60,14 @@ export default function MonthlyCalendar({
}, [currentDate]); }, [currentDate]);
const getShiftSummary = (date: Date) => { const getShiftSummary = (date: Date) => {
const dateStr = formatDateISO(date); const dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
const dayShifts = shifts.filter((s) => s.date === dateStr); // const available = dayShifts.filter((s) => s.status === "available").length;
const available = dayShifts.filter((s) => s.status === "available").length; // const registered = dayShifts.filter(
const registered = dayShifts.filter( // (s) => s.status === "registered",
(s) => s.status === "registered", // ).length;
).length; // const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
const leave = dayShifts.filter((s) => s.status === "approved_leave").length; // const absent = dayShifts.filter((s) => s.status === "absent").length;
const absent = dayShifts.filter((s) => s.status === "absent").length; return { total: dayShifts.length };
return { total: dayShifts.length, available, registered, leave, absent };
}; };
return ( return (
@@ -104,7 +103,7 @@ export default function MonthlyCalendar({
<button <button
type="button" type="button"
key={i} key={i}
onClick={() => onDateSelect?.(formatDateISO(date))} onClick={() => onDateSelect?.(date)}
className={`min-h-25 cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${ className={`min-h-25 cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${
today ? "bg-(--color-primary)/5" : "" today ? "bg-(--color-primary)/5" : ""
}`} }`}
@@ -119,7 +118,7 @@ export default function MonthlyCalendar({
{date.getDate()} {date.getDate()}
</span> </span>
{summary.total > 0 && ( {/* {summary.total > 0 && (
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{summary.available > 0 && ( {summary.available > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -154,7 +153,7 @@ export default function MonthlyCalendar({
</div> </div>
)} )}
</div> </div>
)} )} */}
</button> </button>
); );
})} })}
@@ -4,6 +4,7 @@ import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context"; import { useShift } from "@/lib/shift-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { formatDateISO } from "./MonthlyCalendar";
import type { ShiftCreateModalProps } from "./ShiftSchedule.types"; import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
export default function ShiftCreateModal({ export default function ShiftCreateModal({
@@ -13,7 +14,7 @@ export default function ShiftCreateModal({
}: ShiftCreateModalProps) { }: ShiftCreateModalProps) {
const { createShift } = useShift(); const { createShift } = useShift();
const [date, setDate] = useState(defaultDate ?? "2026-04-10"); const [date, setDate] = useState<Date>(new Date(defaultDate ?? "2026-04-10"));
const [startTime, setStartTime] = useState("08:00"); const [startTime, setStartTime] = useState("08:00");
const [endTime, setEndTime] = useState("12:00"); const [endTime, setEndTime] = useState("12:00");
const [department, setDepartment] = useState("bar"); const [department, setDepartment] = useState("bar");
@@ -23,7 +24,7 @@ export default function ShiftCreateModal({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setDate(defaultDate ?? "2026-04-10"); setDate(new Date(defaultDate ?? "2026-04-10"));
} }
}, [defaultDate, isOpen]); }, [defaultDate, isOpen]);
@@ -49,18 +50,12 @@ export default function ShiftCreateModal({
return; return;
} }
const durationHours = (endMinutes - startMinutes) / 60;
createShift({ createShift({
date, date,
startTime, startTime,
endTime, endTime,
durationHours,
wage, wage,
department,
maxStaff, maxStaff,
registeredStaff: [],
status: "available",
}); });
onClose(); onClose();
@@ -103,8 +98,8 @@ export default function ShiftCreateModal({
<input <input
title="Date" title="Date"
type="date" type="date"
value={date} value={formatDateISO(date)}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(new Date(e.target.value))}
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
@@ -19,12 +19,12 @@ export default function ShiftDetailModal({
if (!isOpen || !shift) return null; if (!isOpen || !shift) return null;
const dept = DEPARTMENTS.find((d) => d.id === shift.department); // const dept = DEPARTMENTS.find((d) => d.id === shift.department);
const isManager = user?.role === "manager"; const isManager = user?.role === "manager";
const isRegistered = user const isRegistered = user
? shift.registeredStaff.some((s) => s.id === user.id) ? shift.registeredStaff!.some((s) => s.id === user.id)
: false; : false;
const isFull = shift.registeredStaff.length >= shift.maxStaff; const isFull = shift.registeredStaff!.length >= shift.maxStaff;
const handleRegister = () => { const handleRegister = () => {
if (!user) return; if (!user) return;
@@ -47,7 +47,7 @@ export default function ShiftDetailModal({
setTimeout(onClose, 1200); setTimeout(onClose, 1200);
}; };
const handleManagerUnregister = (staffId: number) => { const handleManagerUnregister = (staffId: string) => {
unregisterShift(shift.id, staffId); unregisterShift(shift.id, staffId);
setSuccess("Đã xóa nhân viên khỏi ca."); setSuccess("Đã xóa nhân viên khỏi ca.");
}; };
@@ -81,12 +81,12 @@ export default function ShiftDetailModal({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4"> <div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>} {/* {dept && <i className={`${dept.icon} text-(--color-primary)`}></i>} */}
<div> <div>
<h2 className="text-foreground text-base font-bold"> <h2 className="text-foreground text-base font-bold">
Chi tiết ca làm Chi tiết ca làm
</h2> </h2>
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p> {/* <p className="text-xs text-(--color-text-muted)">{dept?.name}</p> */}
</div> </div>
</div> </div>
<button <button
@@ -102,13 +102,13 @@ export default function ShiftDetailModal({
{/* Body */} {/* Body */}
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
{/* Status badge */} {/* Status badge */}
<div className="flex items-center gap-2"> {/* <div className="flex items-center gap-2">
<span <span
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`} className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
> >
{statusLabel[shift.status]} {statusLabel[shift.status]}
</span> </span>
</div> </div> */}
{/* Shift info grid */} {/* Shift info grid */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -141,7 +141,7 @@ export default function ShiftDetailModal({
Thời lượng Thời lượng
</p> </p>
<p className="text-foreground mt-1 text-sm font-bold"> <p className="text-foreground mt-1 text-sm font-bold">
{shift.durationHours} giờ {""} giờ
</p> </p>
</div> </div>
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
@@ -157,16 +157,16 @@ export default function ShiftDetailModal({
{/* Registered staff */} {/* Registered staff */}
<div> <div>
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)"> <p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
Nhân viên đã đăng ({shift.registeredStaff.length}/ Nhân viên đã đăng ({shift.registeredStaff!.length}/
{shift.maxStaff}) {shift.maxStaff})
</p> </p>
{shift.registeredStaff.length === 0 ? ( {(shift.registeredStaff && shift.registeredStaff.length === 0) ? (
<p className="text-xs text-(--color-text-muted) italic"> <p className="text-xs text-(--color-text-muted) italic">
Chưa ai đăng Chưa ai đăng
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{shift.registeredStaff.map((staff) => ( {shift.registeredStaff!.map((staff) => (
<div <div
key={staff.id} key={staff.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2" className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
@@ -176,7 +176,7 @@ export default function ShiftDetailModal({
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i> <i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
</div> </div>
<span className="text-foreground text-sm font-medium"> <span className="text-foreground text-sm font-medium">
{staff.name} {staff.staffId}
</span> </span>
</div> </div>
{isManager && ( {isManager && (
@@ -212,7 +212,7 @@ export default function ShiftDetailModal({
{/* Footer actions */} {/* Footer actions */}
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4"> <div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
{!isRegistered && {/* {!isRegistered &&
!isFull && !isFull &&
shift.status !== "approved_leave" && shift.status !== "approved_leave" &&
shift.status !== "absent" && ( shift.status !== "absent" && (
@@ -224,7 +224,7 @@ export default function ShiftDetailModal({
<i className="fa-solid fa-calendar-plus mr-2"></i> <i className="fa-solid fa-calendar-plus mr-2"></i>
Đăng ký ca Đăng ký ca
</button> </button>
)} )} */}
{isRegistered && ( {isRegistered && (
<button <button
type="button" type="button"
@@ -1,22 +1,22 @@
import type { ShiftSlot } from "@/lib/types"; import type { ShiftEntity } from "@/lib/types";
export interface WeeklyScheduleProps { export interface WeeklyScheduleProps {
onShiftClick: (shift: ShiftSlot) => void; onShiftClick: (shift: ShiftEntity) => void;
onCreateShift?: (date: string) => void; onCreateShift?: (date: Date) => void;
mobileCalendarHeader?: boolean; mobileCalendarHeader?: boolean;
} }
export interface MonthlyCalendarProps { export interface MonthlyCalendarProps {
onShiftClick: (shift: ShiftSlot) => void; onShiftClick: (shift: ShiftEntity) => void;
onDateSelect?: (date: string) => void; onDateSelect?: (date: Date) => void;
} }
export interface MobileShiftViewProps { export interface MobileShiftViewProps {
onShiftClick: (shift: ShiftSlot) => void; onShiftClick: (shift: ShiftEntity) => void;
} }
export interface ShiftDetailModalProps { export interface ShiftDetailModalProps {
shift: ShiftSlot | null; shift: ShiftEntity | null;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
@@ -24,5 +24,5 @@ export interface ShiftDetailModalProps {
export interface ShiftCreateModalProps { export interface ShiftCreateModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
defaultDate?: string; defaultDate?: Date;
} }
@@ -37,7 +37,7 @@ function formatDateISO(d: Date): string {
} }
function isToday(d: Date): boolean { function isToday(d: Date): boolean {
const today = new Date(2026, 3, 10); const today = new Date(Date.now());
return ( return (
d.getDate() === today.getDate() && d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() && d.getMonth() === today.getMonth() &&
@@ -59,36 +59,32 @@ export default function WeeklySchedule({
} = useShift(); } = useShift();
const weekDates = getWeekDates(); const weekDates = getWeekDates();
const [selectedDate, setSelectedDate] = useState<string>( const [selectedDate, setSelectedDate] = useState<Date>(
formatDateISO(weekDates[0] ?? currentDate), weekDates[0] ?? currentDate,
); );
const statusDotsByDate = useMemo(() => { const statusDotsByDate = useMemo(() => {
const map: Record<string, string[]> = {}; const map: Record<string, string[]> = {};
weekDates.forEach((date) => { weekDates.forEach((date) => {
const dateStr = formatDateISO(date);
const dayShifts = getShiftsForDate(dateStr);
const dots: string[] = []; const dots: string[] = [];
if (dayShifts.some((s) => s.status === "available")) // if (dayShifts.some((s) => s.status === "available"))
dots.push("bg-sky-300"); // dots.push("bg-sky-300");
if (dayShifts.some((s) => s.status === "registered")) // if (dayShifts.some((s) => s.status === "registered"))
dots.push("bg-blue-600"); // dots.push("bg-blue-600");
if (dayShifts.some((s) => s.status === "approved_leave")) // if (dayShifts.some((s) => s.status === "approved_leave"))
dots.push("bg-purple-400"); // dots.push("bg-purple-400");
if (dayShifts.some((s) => s.status === "absent")) // if (dayShifts.some((s) => s.status === "absent"))
dots.push("bg-rose-400"); // dots.push("bg-rose-400");
map[dateStr] = dots.slice(0, 3); map[date.toISOString()] = dots.slice(0, 3);
}); });
return map; return map;
}, [weekDates, getShiftsForDate]); }, [weekDates, getShiftsForDate]);
const selectedDateObj = useMemo(() => { const selectedDateStr = useMemo(() => {
const inWeek = weekDates.find((d) => formatDateISO(d) === selectedDate); const inWeek = weekDates.find((d) => d === selectedDate);
return inWeek ?? weekDates[0] ?? currentDate; return inWeek ?? weekDates[0] ?? currentDate;
}, [selectedDate, weekDates, currentDate]); }, [selectedDate, weekDates, currentDate]);
const selectedDateStr = formatDateISO(selectedDateObj);
const renderMobileDayView = mobileCalendarHeader && ( const renderMobileDayView = mobileCalendarHeader && (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-xl border border-(--color-border-light) bg-white p-3"> <div className="rounded-xl border border-(--color-border-light) bg-white p-3">
@@ -116,14 +112,13 @@ export default function WeeklySchedule({
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1">
{weekDates.map((date, i) => { {weekDates.map((date, i) => {
const dateStr = formatDateISO(date); const active = date === selectedDateStr;
const active = dateStr === selectedDateStr; const dots = statusDotsByDate[date.toISOString()] ?? [];
const dots = statusDotsByDate[dateStr] ?? [];
return ( return (
<button <button
key={i} key={i}
type="button" type="button"
onClick={() => setSelectedDate(dateStr)} onClick={() => setSelectedDate(date)}
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${ className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
active ? "bg-(--color-primary)/10" : "bg-transparent" active ? "bg-(--color-primary)/10" : "bg-transparent"
}`} }`}
@@ -135,14 +130,14 @@ export default function WeeklySchedule({
</span> </span>
<span <span
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${ className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
active || isToday(date) active || isToday(new Date(date))
? "bg-indigo-500 text-white" ? "bg-indigo-500 text-white"
: i >= 5 : i >= 5
? "text-pink-500" ? "text-pink-500"
: "text-sky-500" : "text-sky-500"
}`} }`}
> >
{date.getDate()} {new Date(date).getDate()}
</span> </span>
<div className="flex min-h-2 items-center gap-0.5"> <div className="flex min-h-2 items-center gap-0.5">
{dots.map((dot, idx) => ( {dots.map((dot, idx) => (
@@ -160,9 +155,10 @@ export default function WeeklySchedule({
<div className="space-y-3"> <div className="space-y-3">
{DEPARTMENTS.map((dept) => { {DEPARTMENTS.map((dept) => {
const deptShifts = getShiftsForDate(selectedDateStr).filter( const deptShifts = getShiftsForDate(new Date(selectedDateStr));
(s) => s.department === dept.id, // .filter(
); // (s) => s.department === dept.id,
// );
if (deptShifts.length === 0 && !onCreateShift) return null; if (deptShifts.length === 0 && !onCreateShift) return null;
return ( return (
<div <div
@@ -219,14 +215,14 @@ export default function WeeklySchedule({
<th <th
key={i} key={i}
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${ className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
isToday(date) isToday(new Date(date))
? "bg-(--color-primary)/10 font-bold text-(--color-primary)" ? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
: "bg-gray-50 font-semibold text-(--color-text-muted)" : "bg-gray-50 font-semibold text-(--color-text-muted)"
}`} }`}
> >
<span className="block uppercase">{DAY_LABELS[i]}</span> <span className="block uppercase">{DAY_LABELS[i]}</span>
<span className="mt-0.5 block text-[11px] font-normal"> <span className="mt-0.5 block text-[11px] font-normal">
{formatDateShort(date)} {date.toISOString()}
</span> </span>
</th> </th>
))} ))}
@@ -246,15 +242,16 @@ export default function WeeklySchedule({
</div> </div>
</td> </td>
{weekDates.map((date, i) => { {weekDates.map((date, i) => {
const dateStr = formatDateISO(date); const dateStr = date;
const shifts = getShiftsForDate(dateStr).filter( const shifts = getShiftsForDate(new Date(date));
(s) => s.department === dept.id, // .filter(
); // (s) => s.department === dept.id,
// );
return ( return (
<td <td
key={i} key={i}
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${ className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
isToday(date) ? "bg-(--color-primary)/5" : "" isToday(new Date(date)) ? "bg-(--color-primary)/5" : ""
}`} }`}
> >
<div className="flex min-h-20 flex-col gap-1"> <div className="flex min-h-20 flex-col gap-1">
+40 -40
View File
@@ -1,46 +1,46 @@
"use client"; "use client";
import { ShopCard } from "@/components/molecules/cards"; // import { ShopCard } from "@/components/molecules/cards";
import { MOCK_SHOPS } from "@/lib/constants"; // import { MOCK_SHOPS } from "@/lib/constants";
import type { ShopGridProps } from "./ShopGrid.types"; // import type { ShopGridProps } from "./ShopGrid.types";
export default function ShopGrid({ // export default function ShopGrid({
searchName = "", // searchName = "",
searchAddress = "", // searchAddress = "",
}: ShopGridProps) { // }: ShopGridProps) {
const filtered = MOCK_SHOPS.filter((shop) => { // const filtered = MOCK_SHOPS.filter((shop) => {
const matchesName = // const matchesName =
searchName.trim() === "" || // searchName.trim() === "" ||
shop.name.toLowerCase().includes(searchName.toLowerCase()); // shop.name.toLowerCase().includes(searchName.toLowerCase());
const matchesAddress = // const matchesAddress =
searchAddress.trim() === "" || // searchAddress.trim() === "" ||
shop.address.toLowerCase().includes(searchAddress.toLowerCase()); // shop.address.toLowerCase().includes(searchAddress.toLowerCase());
return matchesName && matchesAddress; // return matchesName && matchesAddress;
}); // });
if (filtered.length === 0) { // if (filtered.length === 0) {
return ( // return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)"> // <div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-store text-5xl opacity-30"></i> // <i className="fa-solid fa-store text-5xl opacity-30"></i>
<p className="text-base font-medium"> // <p className="text-base font-medium">
No shops found matching your search // No shops found matching your search
</p> // </p>
</div> // </div>
); // );
} // }
return ( // return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> // <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((shop) => ( // {filtered.map((shop) => (
<ShopCard // <ShopCard
key={shop.id} // key={shop.id}
id={shop.id} // id={shop.id}
name={shop.name} // name={shop.name}
address={shop.address} // address={shop.address}
image={shop.image} // image={shop.image}
/> // />
))} // ))}
</div> // </div>
); // );
} // }
+1 -1
View File
@@ -1,2 +1,2 @@
export { default as ShopGrid } from "./ShopGrid"; // export { default as ShopGrid } from "./ShopGrid";
export type { ShopGridProps } from "./ShopGrid.types"; export type { ShopGridProps } from "./ShopGrid.types";
+1 -1
View File
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
# Cài đặt pnpm # Cài đặt pnpm
RUN npm install -g pnpm RUN npm install -g pnpm@10.9.0
# Copy file định nghĩa package để tận dụng cache của Docker # Copy file định nghĩa package để tận dụng cache của Docker
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
+2 -2
View File
@@ -96,7 +96,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
const { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, { const { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, {
client: cartClient, client: cartClient,
variables: { cartId } variables: { cartId },
}); });
const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, { const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, {
@@ -130,7 +130,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
if (localCartId) setCartId(localCartId); if (localCartId) setCartId(localCartId);
else createCartFunc(); else createCartFunc();
} }
}, [eateryData, createCart, data, loading]); }, [eateryData, createCart, data, loading, error]);
useEffect(() => { useEffect(() => {
if (data?.getCart) setCart(data.getCart); if (data?.getCart) setCart(data.getCart);
+17 -63
View File
@@ -1,13 +1,9 @@
import type { import type {
Department, Department,
MenuItemEntity,
ProductSalesStats,
RevenueDataPoint, RevenueDataPoint,
ShiftSlot, ShiftEntity,
Shop,
ShopInfo, ShopInfo,
SocialLinks, SocialLinks,
User,
} from "./types"; } from "./types";
// ===== SHOP INFORMATION ===== // ===== SHOP INFORMATION =====
@@ -33,45 +29,6 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/", 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 ===== // ===== MOCK FINANCIAL DATA =====
// Daily revenue for the last 30 days (current month) // 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). * Generate mock shift slots for the weeks around today (April 2026).
* Covers Mon 6 Apr Sun 26 Apr 2026. * Covers Mon 6 Apr Sun 26 Apr 2026.
*/ */
function generateMockShifts(): ShiftSlot[] { function generateMockShifts(): ShiftEntity[] {
const shifts: ShiftSlot[] = []; const shifts: ShiftEntity[] = [];
const departments = ["bar", "kitchen", "cashier", "janitor"]; const departments = ["waiter"];
const timeSlots = [ const timeSlots = [
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 }, { start: "07:00", end: "11:00", hours: 4, wage: 120000 },
{ start: "11:00", end: "15: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 // Determine registration status
const registered: { id: number; name: string }[] = []; const registered: { id: number; name: string }[] = [];
let status: ShiftSlot["status"] = "available"; // let status: ShiftEntity["status"] = "available";
// Past shifts (before April 10) are mostly registered // Past shifts (before April 10) are mostly registered
if (day < 10) { if (day < 10) {
@@ -198,37 +155,37 @@ function generateMockShifts(): ShiftSlot[] {
if (shiftCounter % 7 === 0) { if (shiftCounter % 7 === 0) {
registered.push(staffPool[(staffIdx + 1) % staffPool.length]); registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
} }
status = "registered"; // status = "registered";
// Some past shifts have leave/absent // Some past shifts have leave/absent
if (shiftCounter % 11 === 0) status = "approved_leave"; // if (shiftCounter % 11 === 0) status = "approved_leave";
if (shiftCounter % 13 === 0) status = "absent"; // if (shiftCounter % 13 === 0) status = "absent";
} }
// Current week (April 6-12): mix of registered and available // Current week (April 6-12): mix of registered and available
else if (day >= 10 && day <= 12) { else if (day >= 10 && day <= 12) {
if (shiftCounter % 3 === 0) { if (shiftCounter % 3 === 0) {
registered.push(staffPool[shiftCounter % staffPool.length]); registered.push(staffPool[shiftCounter % staffPool.length]);
status = "registered"; // status = "registered";
} }
} }
// Future shifts: mostly available, some registered // Future shifts: mostly available, some registered
else { else {
if (shiftCounter % 5 === 0) { if (shiftCounter % 5 === 0) {
registered.push(staffPool[shiftCounter % staffPool.length]); registered.push(staffPool[shiftCounter % staffPool.length]);
status = "registered"; // status = "registered";
} }
} }
shifts.push({ shifts.push({
id: "shiftId", id: "shiftId",
date: dateStr, date: new Date(dateStr),
startTime: slot.start, startTime: slot.start,
endTime: slot.end, endTime: slot.end,
durationHours: slot.hours, // durationHours: slot.hours,
wage: slot.wage, wage: slot.wage,
department: dept, // department: dept,
maxStaff: dept === "bar" ? 3 : 2, maxStaff: dept === "bar" ? 3 : 2,
registeredStaff: registered, // registeredStaff: registered,
status, // status,
}); });
} }
} }
@@ -237,11 +194,8 @@ function generateMockShifts(): ShiftSlot[] {
return shifts; return shifts;
} }
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts(); export const MOCK_SHIFT_SLOTS: ShiftEntity[] = generateMockShifts();
export const DEPARTMENTS: Department[] = [ export const DEPARTMENTS: Department[] = [
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" }, { id: "waiter", name: "Bar Staff", icon: "fa-brands fa-jenkins" },
{ 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" },
]; ];
+108 -50
View File
@@ -1,15 +1,22 @@
"use client"; "use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { import {
ReactNode, ReactNode,
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useState, useState,
} from "react"; } from "react";
import { MOCK_SHIFT_SLOTS } from "./constants"; import { eateryClient } from "./apollo-clients";
import type { ShiftSlot, ShiftStatus } from "./types"; import {
type ShiftEntity,
type allShiftsQuery,
createShiftMutation,
} from "./types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -17,7 +24,7 @@ export type ScheduleView = "week" | "month";
interface ShiftContextType { interface ShiftContextType {
// Data // Data
shifts: ShiftSlot[]; shifts: ShiftEntity[];
view: ScheduleView; view: ScheduleView;
setView: (view: ScheduleView) => void; setView: (view: ScheduleView) => void;
@@ -32,23 +39,23 @@ interface ShiftContextType {
// Shift actions // Shift actions
registerShift: ( registerShift: (
shiftId: string, shiftId: string,
staffId: number, staffId: string,
staffName: string, staffName: string,
) => { success: boolean; error?: string }; ) => { success: boolean; error?: string };
unregisterShift: (shiftId: string, staffId: number) => void; unregisterShift: (shiftId: string, staffId: string) => void;
createShift: (shift: Omit<ShiftSlot, "id">) => void; createShift: (shift: Omit<ShiftEntity, "id">) => void;
updateShift: (shift: ShiftSlot) => void; updateShift: (shift: ShiftEntity) => void;
deleteShift: (shiftId: string) => void; deleteShift: (shiftId: string) => void;
// Helpers // Helpers
getShiftsForDate: (date: string) => ShiftSlot[]; getShiftsForDate: (date: Date) => ShiftEntity[];
getShiftsForWeek: (weekStart: Date) => ShiftSlot[]; getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
getWeekDates: () => Date[]; getWeekDates: () => Date[];
hasConflict: ( hasConflict: (
date: string, date: Date,
startTime: string, startTime: string,
endTime: string, endTime: string,
staffId: number, staffId: string,
excludeShiftId?: string, excludeShiftId?: string,
) => boolean; ) => boolean;
getWeeklyBudget: () => number; getWeeklyBudget: () => number;
@@ -94,13 +101,60 @@ function timesOverlap(
return s1 < e2 && s2 < e1; 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 ───────────────────────────────────────────────────────────────── // ─── Provider ─────────────────────────────────────────────────────────────────
export function ShiftProvider({ children }: { children: ReactNode }) { 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 [view, setView] = useState<ScheduleView>("week");
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026 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 ────────────────────────────────────────────────────────── // ── Navigation ──────────────────────────────────────────────────────────
const goToNextWeek = useCallback(() => { const goToNextWeek = useCallback(() => {
@@ -151,18 +205,20 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
}, [currentDate]); }, [currentDate]);
const getShiftsForDate = useCallback( const getShiftsForDate = useCallback(
(date: string): ShiftSlot[] => { (date: Date): ShiftEntity[] => {
return shifts.filter((s) => s.date === date); return shifts.filter((s) => {
return new Date(s.date).getDate() === date.getDate();
});
}, },
[shifts], [shifts],
); );
const getShiftsForWeek = useCallback( const getShiftsForWeek = useCallback(
(weekStart: Date): ShiftSlot[] => { (weekStart: Date): ShiftEntity[] => {
const dates = Array.from({ length: 7 }, (_, i) => { const dates = Array.from({ length: 7 }, (_, i) => {
const d = new Date(weekStart); const d = new Date(weekStart);
d.setDate(weekStart.getDate() + i); d.setDate(weekStart.getDate() + i);
return formatDate(d); return d;
}); });
return shifts.filter((s) => dates.includes(s.date)); return shifts.filter((s) => dates.includes(s.date));
}, },
@@ -171,17 +227,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
const hasConflict = useCallback( const hasConflict = useCallback(
( (
date: string, date: Date,
startTime: string, startTime: string,
endTime: string, endTime: string,
staffId: number, staffId: string,
excludeShiftId?: string, excludeShiftId?: string,
): boolean => { ): boolean => {
return shifts.some( return shifts.some(
(s) => (s) =>
s.date === date && s.date === date &&
s.id !== excludeShiftId && s.id !== excludeShiftId &&
s.registeredStaff.some((rs) => rs.id === staffId) && s.registeredStaff!.some((rs) => rs.id === staffId) &&
timesOverlap(startTime, endTime, s.startTime, s.endTime), timesOverlap(startTime, endTime, s.startTime, s.endTime),
); );
}, },
@@ -189,10 +245,12 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
); );
const getWeeklyBudget = useCallback((): number => { const getWeeklyBudget = useCallback((): number => {
const weekDates = getWeekDates().map(formatDate); const weekDates = getWeekDates();
return shifts return shifts
.filter((s) => weekDates.includes(s.date) && s.registeredStaff.length > 0) .filter(
.reduce((sum, s) => sum + s.wage * s.registeredStaff.length, 0); (s) => weekDates.includes(s.date) && s.registeredStaff!.length > 0,
)
.reduce((sum, s) => sum + s.wage * s.registeredStaff!.length, 0);
}, [shifts, getWeekDates]); }, [shifts, getWeekDates]);
// ── Shift actions ─────────────────────────────────────────────────────── // ── Shift actions ───────────────────────────────────────────────────────
@@ -200,17 +258,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
const registerShift = useCallback( const registerShift = useCallback(
( (
shiftId: string, shiftId: string,
staffId: number, staffId: string,
staffName: string, staffName: string,
): { success: boolean; error?: string } => { ): { success: boolean; error?: string } => {
const shift = shifts.find((s) => s.id === shiftId); 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) 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." }; 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." }; 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) => // setShifts((prev) =>
prev.map((s) => // prev.map((s) =>
s.id === shiftId // s.id === shiftId
? { // ? {
...s, // ...s,
registeredStaff: [ // registeredStaff: [
...s.registeredStaff, // ...s.registeredStaff,
{ id: staffId, name: staffName }, // { staffId, name: staffName },
], // ],
status: "registered" as ShiftStatus, // status: "registered" as ShiftStatus,
} // }
: s, // : s,
), // ),
); // );
return { success: true }; return { success: true };
}, },
[shifts, hasConflict], [shifts, hasConflict],
); );
const unregisterShift = useCallback((shiftId: string, staffId: number) => { const unregisterShift = useCallback((shiftId: string, staffId: string) => {
setShifts((prev) => setShifts((prev) =>
prev.map((s) => { prev.map((s) => {
if (s.id !== shiftId) return 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 { return {
...s, ...s,
registeredStaff: updated, registeredStaff: updated,
status:
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
}; };
}), }),
); );
}, []); }, []);
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => { const createShift = useCallback(async (shift: Omit<ShiftEntity, "id">) => {
const newShift: ShiftSlot = { const { data } = await mutateCreateShift({
...shift, variables: {
id: `shift_${Date.now()}`, shiftInput: shift,
}; },
setShifts((prev) => [...prev, newShift]); });
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))); 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 type UserRole = "manager" | "staff" | "customer";
export interface User { export interface User {
id: number; id: string;
name: string; name: string;
role: UserRole; role: UserRole;
avatar: string | null; avatar: string | null;
@@ -88,28 +88,26 @@ export type ShiftStatus =
| "approved_leave" | "approved_leave"
| "absent"; | "absent";
export interface RegisteredStaff { export interface ShiftRegistrationEntity {
id: number; id: string;
name: string; staffId: string;
shift: ShiftEntity;
} }
export interface ShiftSlot { export interface ShiftEntity {
id: string; id: string;
date: string; // ISO date string "YYYY-MM-DD" date: Date;
startTime: string; // "HH:mm" startTime: string;
endTime: string; // "HH:mm" endTime: string;
durationHours: number; wage: number;
wage: number; // VND per shift
department: string;
maxStaff: number; maxStaff: number;
registeredStaff: RegisteredStaff[]; registeredStaff?: ShiftRegistrationEntity[];
status: ShiftStatus;
} }
export interface Department { export interface Department {
id: string; id: string;
name: string; name: string;
icon: string; // FontAwesome class icon: string;
} }
export interface MenuItemEntity { export interface MenuItemEntity {
@@ -122,8 +120,6 @@ export interface MenuItemEntity {
description: string; description: string;
} }
export interface ShiftEntity {}
export interface EateryEntity { export interface EateryEntity {
id: string; id: string;
ownerId: string; ownerId: string;
@@ -172,3 +168,15 @@ export interface createCartMutation {
export interface addMenuItemMutation { export interface addMenuItemMutation {
addItem: CartEntity; addItem: CartEntity;
} }
export interface allRegistrationsQuery {
allRegistrations: ShiftRegistrationEntity[];
}
export interface allShiftsQuery {
allShifts: ShiftEntity[];
}
export interface createShiftMutation {
createShift: ShiftEntity;
}