Refactor shift scheduling components for improved functionality and UI

- Updated MonthlyCalendar to utilize getShiftsForDate and simplified date rendering logic.
- Enhanced ShiftDetailModal to handle asynchronous shift registration and deletion.
- Refined WeeklySchedule for better mobile and desktop views, including improved date handling and shift display.
- Modified shift-context to support asynchronous operations for shift registration and improved date comparison logic.
- Cleaned up unused code and comments for better readability and maintainability.
This commit is contained in:
Thanh Quy- wolf
2026-05-14 17:26:24 +07:00
parent 0842960888
commit 363bcae39d
7 changed files with 658 additions and 783 deletions
+200 -186
View File
@@ -12,18 +12,8 @@ import Link from "next/link";
import { useState } from "react";
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function getMonday(d: Date): Date {
@@ -41,15 +31,10 @@ function formatDateShort(d: Date): string {
export default function StaffSchedulePage() {
const { user, logout } = useAuth();
const {
view,
setView,
currentDate,
goToNextWeek,
goToPrevWeek,
goToNextMonth,
goToPrevMonth,
goToToday,
getWeeklyBudget,
view, setView, currentDate,
goToNextWeek, goToPrevWeek,
goToNextMonth, goToPrevMonth,
goToToday, getWeeklyBudget, shifts,
} = useShift();
const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
@@ -70,136 +55,171 @@ export default function StaffSchedulePage() {
};
const handleDateSelect = (date: Date) => {
// In month view on desktop, clicking a date could open create modal for managers
if (isManager) {
setCreateDate(date);
setCreateOpen(true);
}
};
// Week range label
const monday = getMonday(currentDate);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const weekLabel = `${formatDateShort(monday)} ${formatDateShort(sunday)}`;
const weeklyBudget = getWeeklyBudget();
// Count shifts this week for stats
const totalShiftsThisWeek = shifts.filter((s) => {
if (!s.date) return false;
const d = s.date instanceof Date ? s.date : new Date(s.date as unknown as string);
return d >= monday && d <= sunday;
}).length;
return (
<div className="flex min-h-screen">
{/* ── Sidebar (Desktop) ── */}
<div className="flex min-h-screen bg-gray-50/50">
{/* ── Sidebar (Desktop) ─────────────────────────────────────────────────── */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-(--color-border-light) bg-white shadow-sm lg:flex">
{/* Brand */}
<div className="flex items-center gap-3 border-b border-(--color-border-light) px-5 py-5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-primary)">
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
</div>
<div>
<p className="text-foreground text-sm font-bold">Work Schedule</p>
<p className="text-xs text-(--color-text-muted)">
{isManager ? "Manager" : "Staff"}
</p>
{/* Brand — gradient accent */}
<div className="bg-gradient-to-br from-(--color-primary) to-(--color-primary-dark) px-5 py-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20 shadow-sm backdrop-blur-sm">
<i className="fa-solid fa-calendar-days text-base text-white"></i>
</div>
<div>
<p className="text-sm font-bold text-white">Work Schedule</p>
<p className="text-xs text-white/70">
{isManager ? "Manager View" : "Staff View"}
</p>
</div>
</div>
</div>
{/* View toggle */}
<nav className="flex-1 space-y-1 p-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
View
<nav className="flex-1 space-y-0.5 overflow-y-auto p-3">
{/* View toggle */}
<p className="mb-1 px-3 pt-2 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
Chế đ xem
</p>
<button
type="button"
onClick={() => setView("week")}
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
view === "week"
? "bg-(--color-primary) text-white shadow-sm"
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
? "bg-(--color-primary)/10 text-(--color-primary) shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-gray-50 hover:text-(--color-primary-dark)"
}`}
>
<i className="fa-solid fa-table-columns w-4 text-center"></i>
<span className="flex-1 text-left">Weekly</span>
<i className={`fa-solid fa-table-columns w-4 text-center ${view === "week" ? "text-(--color-primary)" : ""}`}></i>
<span className="flex-1 text-left">Theo tuần</span>
{view === "week" && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)"></span>
)}
</button>
<button
type="button"
onClick={() => setView("month")}
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
view === "month"
? "bg-(--color-primary) text-white shadow-sm"
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
? "bg-(--color-primary)/10 text-(--color-primary) shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-gray-50 hover:text-(--color-primary-dark)"
}`}
>
<i className="fa-solid fa-calendar w-4 text-center"></i>
<span className="flex-1 text-left">Monthly</span>
<i className={`fa-solid fa-calendar w-4 text-center ${view === "month" ? "text-(--color-primary)" : ""}`}></i>
<span className="flex-1 text-left">Theo tháng</span>
{view === "month" && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)"></span>
)}
</button>
{/* Quick nav */}
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Navigation
{/* Navigation */}
<div className="mt-2 pt-2">
<p className="mb-1 px-3 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
Điều hướng
</p>
<button
type="button"
onClick={goToToday}
className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)"
className="flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:bg-gray-50 hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-crosshairs w-4 text-center"></i>
<span className="flex-1 text-left">Today</span>
<span className="flex-1 text-left">Hôm nay</span>
</button>
</div>
{/* Manager link */}
{isManager && (
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Management
<div className="mt-2 pt-2">
<p className="mb-1 px-3 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
Quản
</p>
<Link
href="/manager"
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:bg-gray-50 hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-store w-4 text-center"></i>
<span className="flex-1 text-left">Dashboard</span>
<i className="fa-solid fa-arrow-right text-xs opacity-40"></i>
</Link>
{/* Create shift button */}
<button
type="button"
onClick={() => { setCreateDate(undefined); setCreateOpen(true); }}
className="mt-1 flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-(--color-primary)/10 px-3 py-2.5 text-sm font-semibold text-(--color-primary) transition-all hover:bg-(--color-primary)/20"
>
<i className="fa-solid fa-plus w-4 text-center"></i>
<span className="flex-1 text-left">Tạo ca mới</span>
</button>
</div>
)}
{/* Weekly budget */}
<div className="mt-3 border-t border-(--color-border-light) pt-3">
<div className="rounded-xl bg-(--color-primary)/5 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Weekly Budget
{/* Stats cards */}
<div className="mt-3 space-y-2 border-t border-(--color-border-light) pt-3">
{isManager && (
<div className="rounded-xl bg-gradient-to-br from-(--color-primary)/10 to-(--color-primary)/5 p-3">
<p className="text-[10px] font-bold tracking-wider text-(--color-text-muted) uppercase">
Ngân sách tuần
</p>
<p className="mt-1 text-xl font-bold text-(--color-primary)">
{weeklyBudget >= 1_000_000
? `${(weeklyBudget / 1_000_000).toFixed(1)}M`
: weeklyBudget.toLocaleString("vi-VN")}
</p>
<p className="text-[10px] text-(--color-text-muted)">VND</p>
</div>
)}
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-bold tracking-wider text-(--color-text-muted) uppercase">
Ca tuần này
</p>
<p className="mt-1 text-lg font-bold text-(--color-primary)">
{weeklyBudget.toLocaleString("vi-VN")}
<p className="mt-1 text-xl font-bold text-(--color-text-secondary)">
{totalShiftsThisWeek}
</p>
<p className="text-[10px] text-(--color-text-muted)">VND</p>
<p className="text-[10px] text-(--color-text-muted)">ca làm</p>
</div>
</div>
</nav>
{/* User info */}
<div className="border-t border-(--color-border-light) p-3">
<div className="flex items-center gap-3 rounded-xl p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
></i>
<div className="flex items-center gap-3 rounded-xl bg-gray-50 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-primary)/15">
<i className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}></i>
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Staff"}
</p>
<p className="text-xs text-(--color-text-muted)">
<span className={`inline-block rounded-full px-2 py-px text-[10px] font-semibold ${
isManager ? "bg-(--color-primary)/10 text-(--color-primary)" : "bg-gray-100 text-(--color-text-muted)"
}`}>
{isManager ? "Manager" : "Staff"}
</p>
</span>
</div>
</div>
<div className="mt-1 flex gap-2 px-1">
<div className="mt-2 flex gap-2 px-1">
<Link
href="/"
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:bg-gray-50"
>
<i className="fa-solid fa-house"></i>
<i className="fa-solid fa-house text-[10px]"></i>
Home
</Link>
<button
@@ -207,122 +227,122 @@ export default function StaffSchedulePage() {
onClick={logout}
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket"></i>
<i className="fa-solid fa-right-from-bracket text-[10px]"></i>
Logout
</button>
</div>
</div>
</aside>
{/* ── Main content ── */}
{/* ── Main content ──────────────────────────────────────────────────────── */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header */}
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4">
<div>
<h1 className="text-foreground text-base font-bold md:text-lg">
Register Shift
</h1>
<p className="text-xs text-(--color-text-muted)">
{view === "week"
? weekLabel
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
</p>
</div>
<div className="flex items-center gap-2">
{/* View toggle (mobile) */}
<div className="flex items-center rounded-xl border border-(--color-border-light) lg:hidden">
<button
type="button"
onClick={() => setView("week")}
className={`cursor-pointer rounded-l-xl border-none px-3 py-2 text-xs font-medium transition ${
view === "week"
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Week
</button>
<button
type="button"
onClick={() => setView("month")}
className={`cursor-pointer rounded-r-xl border-none px-3 py-2 text-xs font-medium transition ${
view === "month"
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Month
</button>
<header className="sticky top-0 z-40 border-b border-(--color-border-light) bg-white/95 px-4 py-3 shadow-sm backdrop-blur-sm md:px-6 md:py-4">
<div className="flex items-center justify-between gap-3">
{/* Title */}
<div>
<h1 className="text-foreground text-base font-bold md:text-lg">
{isManager ? "Quản lý ca làm" : "Đăng ký ca làm"}
</h1>
<p className="text-xs text-(--color-text-muted)">
{view === "week"
? `Tuần: ${weekLabel}`
: `Tháng: ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
</p>
</div>
{/* Navigation arrows */}
<div className="hidden items-center gap-1 md:flex">
<button
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
type="button"
onClick={goToToday}
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
>
Today
</button>
<button
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
<div className="flex items-center gap-2">
{/* Mobile view toggle */}
<div className="flex items-center overflow-hidden rounded-xl border border-(--color-border-light) lg:hidden">
<button
type="button"
onClick={() => setView("week")}
className={`cursor-pointer rounded-l-xl border-none px-3 py-2 text-xs font-semibold transition ${
view === "week"
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Tuần
</button>
<button
type="button"
onClick={() => setView("month")}
className={`cursor-pointer rounded-r-xl border-none px-3 py-2 text-xs font-semibold transition ${
view === "month"
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Tháng
</button>
</div>
{/* Create shift button (manager only) */}
{isManager && (
<button
type="button"
onClick={() => {
setCreateDate(undefined);
setCreateOpen(true);
}}
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
>
<i className="fa-solid fa-plus"></i>
Create Shift
</button>
)}
{/* Desktop navigation arrows */}
<div className="hidden items-center gap-1 md:flex">
<button
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-white text-(--color-text-muted) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
type="button"
onClick={goToToday}
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-white px-3 py-1.5 text-xs font-semibold text-(--color-text-secondary) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
>
Hôm nay
</button>
<button
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-white text-(--color-text-muted) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
{/* Mobile nav */}
<div className="flex items-center gap-1 md:hidden">
<button
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
{/* Create shift (manager, desktop) */}
{isManager && (
<button
type="button"
onClick={() => { setCreateDate(undefined); setCreateOpen(true); }}
className="hidden cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:opacity-90 md:flex"
>
<i className="fa-solid fa-plus"></i>
Tạo ca mới
</button>
)}
{/* Mobile navigation arrows */}
<div className="flex items-center gap-1 md:hidden">
<button
title="Previous"
type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
title="Next"
type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
</div>
</div>
</header>
{/* Content */}
<main className="flex-1 p-4 md:p-6">
{/* Desktop views */}
{/* Desktop */}
<div className="hidden md:block">
{view === "week" ? (
<WeeklySchedule
@@ -337,7 +357,7 @@ export default function StaffSchedulePage() {
)}
</div>
{/* Mobile view */}
{/* Mobile */}
<div className="md:hidden">
{view === "week" ? (
<WeeklySchedule
@@ -350,16 +370,13 @@ export default function StaffSchedulePage() {
)}
</div>
{/* Mobile FAB for manager */}
{/* FAB for mobile manager */}
{isManager && (
<button
title="Create Shift"
title="Tạo ca mới"
type="button"
onClick={() => {
setCreateDate(undefined);
setCreateOpen(true);
}}
className="fixed right-4 bottom-4 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-lg transition hover:opacity-90 md:hidden"
onClick={() => { setCreateDate(undefined); setCreateOpen(true); }}
className="fixed right-4 bottom-6 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-xl transition hover:opacity-90 md:hidden"
>
<i className="fa-solid fa-plus text-lg"></i>
</button>
@@ -371,10 +388,7 @@ export default function StaffSchedulePage() {
<ShiftDetailModal
shift={selectedShift}
isOpen={detailOpen}
onClose={() => {
setDetailOpen(false);
setSelectedShift(null);
}}
onClose={() => { setDetailOpen(false); setSelectedShift(null); }}
/>
<ShiftCreateModal
isOpen={createOpen}
+80 -64
View File
@@ -1,36 +1,64 @@
"use client";
import type { ShiftEntity } from "@/lib/types";
import type { ShiftCardProps } from "./ShiftCard.types";
function formatWage(wage: number): string {
if (wage >= 1000) {
return `${(wage / 1000).toFixed(0)}k`;
}
if (wage >= 1_000_000) return `${(wage / 1_000_000).toFixed(1)}M`;
if (wage >= 1000) return `${(wage / 1000).toFixed(0)}k`;
return wage.toLocaleString("vi-VN");
}
export default function ShiftCard({
shift,
compact = false,
onClick,
}: ShiftCardProps) {
console.log(shift);
function getShiftPeriod(startTime: string): "morning" | "afternoon" | "evening" {
const h = parseInt(startTime.split(":")[0] ?? "0", 10);
if (h < 12) return "morning";
if (h < 18) return "afternoon";
return "evening";
}
const PERIOD_STYLES = {
morning: {
border: "border-l-indigo-500",
text: "text-indigo-700",
badge: "bg-indigo-100 text-indigo-700",
dot: "bg-indigo-400",
},
afternoon: {
border: "border-l-amber-500",
text: "text-amber-700",
badge: "bg-amber-100 text-amber-700",
dot: "bg-amber-400",
},
evening: {
border: "border-l-violet-500",
text: "text-violet-700",
badge: "bg-violet-100 text-violet-700",
dot: "bg-violet-400",
},
};
export default function ShiftCard({ shift, compact = false, onClick }: ShiftCardProps) {
const registeredCount = shift.registeredStaff?.length ?? 0;
const period = getShiftPeriod(shift.startTime);
const s = PERIOD_STYLES[period];
if (compact) {
return (
<button
type="button"
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`}
className={`group w-full cursor-pointer rounded-xl border-l-[3px] bg-white text-left shadow-sm transition hover:-translate-y-px hover:shadow-md ${s.border}`}
>
<p className="font-semibold">
{shift.startTime} {shift.endTime}
</p>
<p className="mt-0.5 opacity-75">
{}h · {formatWage(shift.wage)}
</p>
<div className="px-2.5 py-2">
<p className={`text-xs font-bold leading-tight ${s.text}`}>
{shift.startTime}{shift.endTime}
</p>
<div className="mt-1 flex items-center justify-between gap-1">
<p className="text-[10px] text-(--color-text-muted)">{formatWage(shift.wage ?? 0)}đ</p>
<span className={`rounded-full px-1.5 py-px text-[9px] font-semibold ${s.badge}`}>
{registeredCount}/{shift.maxStaff}
</span>
</div>
</div>
</button>
);
}
@@ -39,56 +67,44 @@ export default function ShiftCard({
<button
type="button"
onClick={() => onClick?.(shift)}
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md`}
className={`group w-full cursor-pointer rounded-2xl border-l-[4px] bg-white text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg ${s.border}`}
>
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-bold">
{shift.startTime} {shift.endTime}
</p>
<p className="mt-1 text-xs opacity-75">
{}h · {formatWage(shift.wage)} VND
</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
// shift.status === "available"
// ? "bg-blue-200 text-blue-800"
// : shift.status === "registered"
// ? "bg-blue-300 text-blue-900"
// : shift.status === "approved_leave"
// ? "bg-purple-200 text-purple-800"
// : "bg-red-200 text-red-800"
""
}`}
>
{/* {style.label} */}
</span>
</div>
{shift.registeredStaff && shift.registeredStaff.length > 0 && (
<div className="mt-2 border-t border-current/10 pt-2">
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
</p>
<div className="mt-1 flex flex-wrap gap-1">
{shift.registeredStaff.map((s) => (
<span
key={s.id}
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
>
{s.staffId}
</span>
))}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<p className={`text-base font-bold ${s.text}`}>
{shift.startTime} {shift.endTime}
</p>
</div>
<p className="mt-1 text-xs text-(--color-text-muted)">
{(shift.wage ?? 0).toLocaleString("vi-VN")} VND/ca
</p>
</div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${s.badge}`}>
{registeredCount}/{shift.maxStaff} người
</span>
</div>
)}
{/* {shift.status === "available" && shift.registeredStaff.length === 0 && (
<p className="mt-2 text-[10px] italic opacity-50">
{shift.maxStaff} vị trí còn trống
</p>
)} */}
{registeredCount > 0 && (
<div className="mt-3 border-t border-gray-100 pt-3">
<p className="mb-2 text-[10px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Nhân viên đăng
</p>
<div className="flex flex-wrap gap-1.5">
{shift.registeredStaff!.map((staff) => (
<span
key={staff.id}
className="rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] font-medium text-(--color-text-secondary)"
>
{staff.staffId}
</span>
))}
</div>
</div>
)}
</div>
</button>
);
}
@@ -1,15 +1,18 @@
"use client";
import ShiftCard from "@/components/molecules/cards/ShiftCard";
import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context";
import { useMemo, useState } from "react";
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function formatDateISO(d: Date): string {
function formatDateKey(d: Date): string {
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
@@ -18,32 +21,11 @@ function formatDateISO(d: Date): string {
function isToday(d: Date): boolean {
const today = new Date(2026, 3, 10);
return (
d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() &&
d.getFullYear() === today.getFullYear()
);
return formatDateKey(d) === formatDateKey(today);
}
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export default function MobileShiftView({
onShiftClick,
}: MobileShiftViewProps) {
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps) {
const { currentDate, getShiftsForDate, goToNextMonth, goToPrevMonth } = useShift();
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
const calendarDays = useMemo(() => {
@@ -57,187 +39,126 @@ export default function MobileShiftView({
const days: (Date | null)[] = [];
for (let i = 0; i < startOffset; i++) days.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) {
days.push(new Date(year, month, d));
}
for (let d = 1; d <= lastDay.getDate(); d++) days.push(new Date(year, month, d));
while (days.length % 7 !== 0) days.push(null);
return days;
}, [currentDate]);
const getDotColors = (date: Date): string[] => {
const dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
const dots: string[] = [];
// if (dayShifts.some((s) => s.status === "available"))
// dots.push("bg-amber-400");
// if (dayShifts.some((s) => s.status === "registered"))
// dots.push("bg-green-500");
// if (dayShifts.some((s) => s.status === "approved_leave"))
// dots.push("bg-purple-400");
// if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
return dots;
};
const selectedShifts = useMemo(() => getShiftsForDate(selectedDate), [selectedDate, getShiftsForDate]);
const selectedShifts = useMemo(() => {
return shifts.filter((s) => s.date.getDate() === selectedDate.getDate());
}, [shifts, selectedDate]);
const selectedDateObj = new Date(selectedDate + "T00:00:00");
const dayOfWeek = DAY_HEADERS[(selectedDateObj.getDay() + 6) % 7];
const dayOfWeek = DAY_HEADERS[(selectedDate.getDay() + 6) % 7];
return (
<div className="space-y-4">
{/* Compact month calendar */}
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
{/* Month calendar card */}
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
{/* Month navigation */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center justify-between border-b border-(--color-border-light) bg-gradient-to-r from-(--color-primary)/8 to-(--color-primary)/3 px-4 py-3">
<button
title="Previous month"
type="button"
onClick={goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/70 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<h3 className="text-foreground text-sm font-bold">
<h3 className="text-sm font-bold text-(--color-primary-dark)">
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Next month"
type="button"
onClick={goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/70 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
{/* Day headers */}
<div className="mb-1 grid grid-cols-7">
{DAY_HEADERS.map((day) => (
<div
key={day}
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
{calendarDays.map((date, i) => {
if (!date) {
return <div key={`empty-${i}`} className="p-1" />;
}
const today = isToday(date);
const selected = date === selectedDate;
const dots = getDotColors(date);
return (
<button
type="button"
key={i}
onClick={() => setSelectedDate(date)}
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
selected ? "rounded-lg bg-(--color-primary)/10" : ""
<div className="p-3">
{/* Day headers */}
<div className="mb-1 grid grid-cols-7">
{DAY_HEADERS.map((day, i) => (
<div
key={day}
className={`py-1 text-center text-[10px] font-bold uppercase ${
i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"
}`}
>
<span
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs ${
today
? "bg-(--color-primary) font-bold text-white"
: selected
? "text-foreground font-bold"
: "text-foreground"
}`}
>
{date.getDate()}
</span>
<div className="mt-0.5 flex gap-0.5">
{dots.slice(0, 3).map((color, j) => (
<span key={j} className={`h-1 w-1 rounded-full ${color}`} />
))}
</div>
</button>
);
})}
</div>
{day}
</div>
))}
</div>
{/* Legend */}
<div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2">
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
<span className="text-[10px] text-(--color-text-muted)">
Available
</span>
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500"></span>
<span className="text-[10px] text-(--color-text-muted)">
Registered
</span>
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
<span className="text-[10px] text-(--color-text-muted)">
On leave
</span>
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-red-400"></span>
<span className="text-[10px] text-(--color-text-muted)">
Absent
</span>
{/* Calendar grid */}
<div className="grid grid-cols-7">
{calendarDays.map((date, i) => {
if (!date) return <div key={`empty-${i}`} className="p-1" />;
const today = isToday(date);
const selected = formatDateKey(date) === formatDateKey(selectedDate);
const dayShifts = getShiftsForDate(date);
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<button
type="button"
key={i}
onClick={() => setSelectedDate(date)}
className="flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition"
>
<span
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition ${
today
? "bg-(--color-primary) text-white shadow-sm"
: selected
? "bg-(--color-primary)/15 text-(--color-primary) ring-2 ring-(--color-primary)/30"
: isWeekend
? "text-rose-500"
: "text-(--color-text-secondary)"
}`}
>
{date.getDate()}
</span>
<div className="mt-0.5 flex min-h-2 items-center justify-center gap-px">
{dayShifts.length > 0 && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)/60" />
)}
</div>
</button>
);
})}
</div>
</div>
</div>
{/* Selected day shifts */}
<div>
<h3 className="text-foreground mb-3 text-sm font-bold">
{dayOfWeek}, {selectedDateObj.getDate()}/
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
({selectedShifts.length} shift
{selectedShifts.length !== 1 ? "s" : ""})
<div className="mb-3 flex items-center justify-between px-1">
<h3 className="text-sm font-bold text-(--color-primary-dark)">
{dayOfWeek},{" "}
{selectedDate.getDate()}/{selectedDate.getMonth() + 1}/{selectedDate.getFullYear()}
</h3>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
selectedShifts.length > 0
? "bg-(--color-primary)/10 text-(--color-primary)"
: "bg-gray-100 text-(--color-text-muted)"
}`}>
{selectedShifts.length} ca làm
</span>
</h3>
</div>
{selectedShifts.length === 0 ? (
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
<p className="text-sm text-(--color-text-muted)">
No shifts for this day
</p>
<div className="rounded-2xl border border-dashed border-(--color-border-light) py-10 text-center">
<i className="fa-regular fa-calendar-xmark mb-3 text-3xl text-gray-200"></i>
<p className="text-sm font-medium text-(--color-text-muted)">Không ca làm</p>
<p className="mt-0.5 text-xs text-(--color-text-muted)">Chưa ca nào đưc tạo hôm này</p>
</div>
) : (
<div className="space-y-3">
{DEPARTMENTS.map((dept) => {
const deptShifts = selectedShifts;
if (deptShifts.length === 0) return null;
return (
<div key={dept.id}>
<div className="mb-2 flex items-center gap-2">
<i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name}
</span>
</div>
<div className="space-y-2">
{deptShifts.map((shift) => (
<ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))}
</div>
</div>
);
})}
<div className="space-y-2.5">
{selectedShifts.map((shift) => (
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
))}
</div>
)}
</div>
@@ -23,11 +23,8 @@ function isToday(d: Date): boolean {
);
}
export default function MonthlyCalendar({
onShiftClick,
onDateSelect,
}: MonthlyCalendarProps) {
const { currentDate, shifts } = useShift();
export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyCalendarProps) {
const { currentDate, getShiftsForDate } = useShift();
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
@@ -35,49 +32,26 @@ export default function MonthlyCalendar({
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Monday = 0, Sunday = 6
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
const days: (Date | null)[] = [];
// Fill leading empty cells
for (let i = 0; i < startOffset; i++) {
days.push(null);
}
// Fill actual days
for (let d = 1; d <= lastDay.getDate(); d++) {
days.push(new Date(year, month, d));
}
// Fill trailing empty cells to complete the grid
while (days.length % 7 !== 0) {
days.push(null);
}
for (let i = 0; i < startOffset; i++) days.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) days.push(new Date(year, month, d));
while (days.length % 7 !== 0) days.push(null);
return days;
}, [currentDate]);
const getShiftSummary = (date: Date) => {
const dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
// const available = dayShifts.filter((s) => s.status === "available").length;
// const registered = dayShifts.filter(
// (s) => s.status === "registered",
// ).length;
// const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
// const absent = dayShifts.filter((s) => s.status === "absent").length;
return { total: dayShifts.length };
};
return (
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) shadow-sm">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-(--color-border-light) bg-gray-50">
{DAY_HEADERS.map((day) => (
<div className="grid grid-cols-7 border-b border-(--color-border-light) bg-gradient-to-r from-(--color-primary)/8 to-(--color-primary)/3">
{DAY_HEADERS.map((day, i) => (
<div
key={day}
className="px-2 py-2.5 text-center text-xs font-semibold text-(--color-text-muted) uppercase"
className={`px-2 py-3 text-center text-[11px] font-bold tracking-wider uppercase ${
i >= 5 ? "text-rose-400" : "text-(--color-primary)"
}`}
>
{day}
</div>
@@ -85,75 +59,60 @@ export default function MonthlyCalendar({
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
<div className="grid grid-cols-7 bg-white">
{calendarDays.map((date, i) => {
if (!date) {
return (
<div
key={`empty-${i}`}
className="min-h-25 border-r border-b border-(--color-border-light) bg-gray-50/30"
className="min-h-[100px] border-r border-b border-(--color-border-light) bg-gray-50/50"
/>
);
}
const summary = getShiftSummary(date);
const today = isToday(date);
const shifts = getShiftsForDate(date);
const shiftCount = shifts.length;
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<button
type="button"
key={i}
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 ${
today ? "bg-(--color-primary)/5" : ""
}`}
className={`group min-h-[100px] cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-(--color-primary)/5 ${
today ? "bg-(--color-primary)/8" : ""
} ${isWeekend && !today ? "bg-rose-50/30" : ""}`}
>
{/* Date number */}
<span
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-bold transition ${
today
? "bg-(--color-primary) font-bold text-white"
: "text-foreground font-medium"
? "bg-(--color-primary) text-white shadow-sm"
: isWeekend
? "text-rose-500"
: "text-(--color-text-secondary) group-hover:text-(--color-primary)"
}`}
>
{date.getDate()}
</span>
{/* {summary.total > 0 && (
<div className="mt-2 space-y-1">
{summary.available > 0 && (
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
<span className="text-[10px] text-blue-600">
{summary.available} available
</span>
</div>
)}
{summary.registered > 0 && (
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-blue-700"></span>
<span className="text-[10px] text-blue-800">
{summary.registered} registered
</span>
</div>
)}
{summary.leave > 0 && (
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
<span className="text-[10px] text-purple-600">
{summary.leave} on leave
</span>
</div>
)}
{summary.absent > 0 && (
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-red-400"></span>
<span className="text-[10px] text-red-600">
{summary.absent} absent
</span>
</div>
)}
{/* Shift count badge */}
{shiftCount > 0 && (
<div className="mt-1.5 space-y-1">
<span className="inline-flex items-center gap-1 rounded-full bg-(--color-primary)/10 px-1.5 py-0.5 text-[10px] font-semibold text-(--color-primary)">
<i className="fa-solid fa-clock text-[8px]"></i>
{shiftCount} ca
</span>
</div>
)} */}
)}
{/* Add indicator for manager */}
{shiftCount === 0 && onDateSelect && (
<p className="mt-1 text-[10px] text-(--color-text-muted) opacity-0 transition group-hover:opacity-100">
+ Thêm ca
</p>
)}
</button>
);
})}
@@ -35,11 +35,11 @@ export default function ShiftDetailModal({
const isRegistered = user ? registeredStaff.some((s) => s.id === user.id) : false;
const isFull = registeredStaff.length >= shift.maxStaff;
const handleRegister = () => {
const handleRegister = async () => {
if (!user) return;
setError(null);
setSuccess(null);
const result = registerShift(shift.id, user.id, user.name);
const result = await registerShift(shift.id, user.id, user.name);
if (result.success) {
setSuccess("Đăng ký ca thành công!");
setTimeout(onClose, 1200);
@@ -61,8 +61,8 @@ export default function ShiftDetailModal({
setSuccess("Đã xóa nhân viên khỏi ca.");
};
const handleDelete = () => {
deleteShift(shift.id);
const handleDelete = async () => {
await deleteShift(shift.id);
onClose();
};
@@ -156,7 +156,7 @@ export default function ShiftDetailModal({
Lương ca
</p>
<p className="mt-1 text-sm font-bold text-(--color-primary)">
{shift.wage.toLocaleString("vi-VN")} VND
{(shift.wage ?? 0).toLocaleString("vi-VN")} VND
</p>
</div>
</div>
@@ -219,19 +219,16 @@ export default function ShiftDetailModal({
{/* Footer actions */}
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
{/* {!isRegistered &&
!isFull &&
shift.status !== "approved_leave" &&
shift.status !== "absent" && (
<button
type="button"
onClick={handleRegister}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
>
<i className="fa-solid fa-calendar-plus mr-2"></i>
Đăng ký ca
</button>
)} */}
{!isManager && !isRegistered && !isFull && (
<button
type="button"
onClick={handleRegister}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
>
<i className="fa-solid fa-calendar-plus mr-2"></i>
Đăng ca
</button>
)}
{isRegistered && (
<button
type="button"
@@ -7,35 +7,17 @@ import { useMemo, useState } from "react";
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
const MONTH_NAMES_EN = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function formatDateShort(d: Date): string {
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`;
}
function formatDateISO(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 isToday(d: Date): boolean {
const today = new Date(Date.now());
return (
@@ -50,237 +32,207 @@ export default function WeeklySchedule({
onCreateShift,
mobileCalendarHeader = false,
}: WeeklyScheduleProps) {
const {
currentDate,
getWeekDates,
getShiftsForDate,
goToNextWeek,
goToPrevWeek,
} = useShift();
const { currentDate, getWeekDates, getShiftsForDate, goToNextWeek, goToPrevWeek } = useShift();
const weekDates = getWeekDates();
const [selectedDate, setSelectedDate] = useState<Date>(
weekDates[0] ?? currentDate,
);
const [selectedDate, setSelectedDate] = useState<Date>(weekDates[0] ?? currentDate);
const statusDotsByDate = useMemo(() => {
const map: Record<string, string[]> = {};
weekDates.forEach((date) => {
const dots: string[] = [];
// if (dayShifts.some((s) => s.status === "available"))
// dots.push("bg-sky-300");
// if (dayShifts.some((s) => s.status === "registered"))
// dots.push("bg-blue-600");
// if (dayShifts.some((s) => s.status === "approved_leave"))
// dots.push("bg-purple-400");
// if (dayShifts.some((s) => s.status === "absent"))
// dots.push("bg-rose-400");
map[date.toISOString()] = dots.slice(0, 3);
});
return map;
}, [weekDates, getShiftsForDate]);
const selectedDateStr = useMemo(() => {
const inWeek = weekDates.find((d) => d === selectedDate);
return inWeek ?? weekDates[0] ?? currentDate;
const selectedDateRef = useMemo(() => {
return weekDates.find((d) => d === selectedDate) ?? weekDates[0] ?? currentDate;
}, [selectedDate, weekDates, currentDate]);
const renderMobileDayView = mobileCalendarHeader && (
<div className="space-y-3">
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
<div className="mb-3 flex items-center justify-between">
<button
title="Previous week"
type="button"
onClick={goToPrevWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<h3 className="text-base font-bold text-(--color-primary-dark)">
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Next week"
type="button"
onClick={goToNextWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
<div className="grid grid-cols-7 gap-1">
{weekDates.map((date, i) => {
const active = date === selectedDateStr;
const dots = statusDotsByDate[date.toISOString()] ?? [];
return (
<button
key={i}
type="button"
onClick={() => setSelectedDate(date)}
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
active ? "bg-(--color-primary)/10" : "bg-transparent"
}`}
>
<span
className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
>
{DAY_LABELS_EN[i]}
</span>
<span
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
active || isToday(new Date(date))
? "bg-indigo-500 text-white"
: i >= 5
? "text-pink-500"
: "text-sky-500"
}`}
>
{new Date(date).getDate()}
</span>
<div className="flex min-h-2 items-center gap-0.5">
{dots.map((dot, idx) => (
<span
key={idx}
className={`h-1.5 w-1.5 rounded-full ${dot}`}
/>
))}
</div>
</button>
);
})}
</div>
</div>
<div className="space-y-3">
{DEPARTMENTS.map((dept) => {
const deptShifts = getShiftsForDate(new Date(selectedDateStr));
// .filter(
// (s) => s.department === dept.id,
// );
if (deptShifts.length === 0 && !onCreateShift) return null;
return (
<div
key={dept.id}
className="rounded-xl border border-(--color-border-light) bg-white p-3"
>
<div className="mb-2 flex items-center gap-2">
<i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name}
</span>
</div>
<div className="space-y-2">
{deptShifts.map((shift) => (
<ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))}
{onCreateShift && (
<button
type="button"
onClick={() => onCreateShift(selectedDateStr)}
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus mr-1"></i>
Add shift
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
// ── Mobile calendar header view ────────────────────────────────────────────
if (mobileCalendarHeader) {
return renderMobileDayView ?? null;
const dayShifts = getShiftsForDate(new Date(selectedDateRef));
return (
<div className="space-y-4">
{/* Week strip */}
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-(--color-border-light) bg-gradient-to-r from-(--color-primary)/5 to-transparent px-4 py-3">
<button
title="Previous week"
type="button"
onClick={goToPrevWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/80 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<h3 className="text-sm font-bold text-(--color-primary-dark)">
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Next week"
type="button"
onClick={goToNextWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/80 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
<div className="grid grid-cols-7 gap-1 p-3">
{weekDates.map((date, i) => {
const active = date === selectedDateRef;
const today = isToday(new Date(date));
const shifts = getShiftsForDate(new Date(date));
return (
<button
key={i}
type="button"
onClick={() => setSelectedDate(date)}
className="flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none bg-transparent py-1.5 transition"
>
<span className={`text-[10px] font-semibold uppercase ${i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"}`}>
{DAY_LABELS[i]}
</span>
<span className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold transition ${
today
? "bg-(--color-primary) text-white shadow-sm"
: active
? "bg-(--color-primary)/15 text-(--color-primary)"
: i >= 5
? "text-rose-500"
: "text-(--color-text-secondary)"
}`}>
{new Date(date).getDate()}
</span>
<div className="flex min-h-2 items-center gap-px">
{shifts.length > 0 && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)/60" />
)}
</div>
</button>
);
})}
</div>
</div>
{/* Day shifts */}
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="text-sm font-bold text-(--color-primary-dark)">
{DAY_LABELS[(new Date(selectedDateRef).getDay() + 6) % 7]},{" "}
{formatDateShort(new Date(selectedDateRef))}
</h3>
<span className="rounded-full bg-(--color-primary)/10 px-2.5 py-1 text-xs font-semibold text-(--color-primary)">
{dayShifts.length} ca
</span>
</div>
{dayShifts.length === 0 && !onCreateShift ? (
<div className="rounded-2xl border border-dashed border-(--color-border-light) py-10 text-center">
<i className="fa-regular fa-calendar-xmark mb-2 text-3xl text-gray-200"></i>
<p className="text-sm text-(--color-text-muted)">Không ca làm hôm nay</p>
</div>
) : (
<div className="space-y-2.5">
{dayShifts.map((shift) => (
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
))}
{onCreateShift && (
<button
type="button"
onClick={() => onCreateShift(selectedDateRef)}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-(--color-border-light) bg-transparent py-3 text-sm font-medium text-(--color-text-muted) transition hover:border-(--color-primary) hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus"></i>
Thêm ca làm
</button>
)}
</div>
)}
</div>
</div>
);
}
// ── Desktop table view ──────────────────────────────────────────────────────
return (
<div className="overflow-x-auto">
<table className="w-full min-w-225 border-collapse">
<thead>
<tr>
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
Department
</th>
{weekDates.map((date, i) => (
<th
key={i}
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
isToday(new Date(date))
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
: "bg-gray-50 font-semibold text-(--color-text-muted)"
}`}
>
<span className="block uppercase">{DAY_LABELS[i]}</span>
<span className="mt-0.5 block text-[11px] font-normal">
{date.toISOString()}
</span>
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] border-collapse">
<thead>
<tr>
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50/80 px-4 py-3 text-left text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Bộ phận
</th>
))}
</tr>
</thead>
<tbody>
{DEPARTMENTS.map((dept) => (
<tr key={dept.id}>
<td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
<div className="flex items-center gap-2">
<i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name}
</span>
</div>
</td>
{weekDates.map((date, i) => {
const dateStr = date;
const shifts = getShiftsForDate(new Date(date));
// .filter(
// (s) => s.department === dept.id,
// );
const today = isToday(new Date(date));
return (
<td
<th
key={i}
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
isToday(new Date(date)) ? "bg-(--color-primary)/5" : ""
className={`border-r border-b border-(--color-border-light) px-3 py-3 text-center text-xs transition ${
today
? "bg-gradient-to-b from-(--color-primary)/15 to-(--color-primary)/5 font-bold text-(--color-primary)"
: "bg-gray-50/80 font-semibold text-(--color-text-muted)"
}`}
>
<div className="flex min-h-20 flex-col gap-1">
{shifts.map((shift) => (
<ShiftCard
key={shift.id}
shift={shift}
compact
onClick={onShiftClick}
/>
))}
{onCreateShift && (
<button
type="button"
onClick={() => onCreateShift(dateStr)}
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus mr-1"></i>
Add shift
</button>
)}
</div>
</td>
<span className="block text-[11px] font-semibold uppercase tracking-wider">
{DAY_LABELS[i]}
</span>
<span className={`mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-bold ${
today
? "bg-(--color-primary) text-white shadow-sm"
: "text-(--color-text-secondary)"
}`}>
{date.getDate()}
</span>
<span className={`mt-0.5 block text-[10px] font-normal ${today ? "text-(--color-primary)/70" : "text-(--color-text-muted)"}`}>
{(date.getMonth() + 1).toString().padStart(2, "0")}/{date.getFullYear()}
</span>
</th>
);
})}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{DEPARTMENTS.map((dept, deptIdx) => (
<tr key={dept.id} className={deptIdx % 2 === 0 ? "bg-white" : "bg-gray-50/40"}>
<td className="border-r border-b border-(--color-border-light) px-4 py-3 align-top">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-(--color-primary)/10">
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i>
</div>
<span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name}
</span>
</div>
</td>
{weekDates.map((date, i) => {
const today = isToday(new Date(date));
const shifts = getShiftsForDate(new Date(date));
return (
<td
key={i}
className={`border-r border-b border-(--color-border-light) p-2 align-top transition ${
today ? "bg-(--color-primary)/5" : ""
}`}
>
<div className="flex min-h-[88px] flex-col gap-1.5">
{shifts.map((shift) => (
<ShiftCard key={shift.id} shift={shift} compact onClick={onShiftClick} />
))}
{onCreateShift && (
<button
type="button"
onClick={() => onCreateShift(date)}
className="mt-auto flex cursor-pointer items-center justify-center gap-1 rounded-lg border border-dashed border-gray-200 bg-transparent py-1.5 text-[10px] font-medium text-gray-300 transition hover:border-(--color-primary)/50 hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus"></i>
Thêm
</button>
)}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+51 -35
View File
@@ -41,7 +41,7 @@ interface ShiftContextType {
shiftId: string,
staffId: string,
staffName: string,
) => { success: boolean; error?: string };
) => Promise<{ success: boolean; error?: string }>;
unregisterShift: (shiftId: string, staffId: string) => void;
createShift: (shift: Omit<ShiftEntity, "id">) => void;
deleteShift: (shiftId: string) => Promise<void>;
@@ -51,7 +51,7 @@ interface ShiftContextType {
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
getWeekDates: () => Date[];
hasConflict: (
date: Date,
date: Date | string,
startTime: string,
endTime: string,
staffId: string,
@@ -90,6 +90,13 @@ function toLocalDateISO(d: Date): string {
return `${y}-${m}-${day}T00:00:00.000Z`;
}
// Normalize Date or ISO string to "YYYY-MM-DD" for safe comparison
function dateKey(d: Date | string | undefined): string {
if (!d) return "";
if (d instanceof Date) return formatDate(d);
return (d as string).slice(0, 10);
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
@@ -119,6 +126,10 @@ const GET_ALL_SHIFTS = gql`
endTime
maxStaff
wage
registeredStaff {
id
staffId
}
}
}
`;
@@ -142,6 +153,14 @@ const DELETE_SHIFT = gql`
}
`;
const REGISTER_SHIFT = gql`
mutation registerShift($RegisterShiftInput: RegisterShiftInput) {
registerShift(registration: $RegisterShiftInput) {
staffId
}
}
`;
// ─── Provider ─────────────────────────────────────────────────────────────────
export function ShiftProvider({ children }: { children: ReactNode }) {
@@ -162,6 +181,11 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
client: eateryClient,
});
const [mutateRegisterShift] = useMutation<
{ registerShift: { staffId: string } },
{ RegisterShiftInput: { shiftId: string } }
>(REGISTER_SHIFT, { client: eateryClient });
useEffect(() => {
if (data) setShifts(data.allShifts);
}, [data]);
@@ -217,9 +241,8 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
const getShiftsForDate = useCallback(
(date: Date): ShiftEntity[] => {
return shifts.filter((s) => {
return new Date(s.date).getDate() === date.getDate();
});
const key = formatDate(date);
return shifts.filter((s) => dateKey(s.date) === key);
},
[shifts],
);
@@ -231,22 +254,24 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
d.setDate(weekStart.getDate() + i);
return d;
});
return shifts.filter((s) => dates.includes(s.date));
const keys = new Set(dates.map(formatDate));
return shifts.filter((s) => keys.has(dateKey(s.date)));
},
[shifts],
);
const hasConflict = useCallback(
(
date: Date,
date: Date | string,
startTime: string,
endTime: string,
staffId: string,
excludeShiftId?: string,
): boolean => {
const key = dateKey(date);
return shifts.some(
(s) =>
s.date === date &&
dateKey(s.date) === key &&
s.id !== excludeShiftId &&
(s.registeredStaff ?? []).some((rs) => rs.id === staffId) &&
timesOverlap(startTime, endTime, s.startTime, s.endTime),
@@ -257,9 +282,10 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
const getWeeklyBudget = useCallback((): number => {
const weekDates = getWeekDates();
const weekKeys = new Set(weekDates.map(formatDate));
return shifts
.filter(
(s) => weekDates.includes(s.date) && (s.registeredStaff ?? []).length > 0,
(s) => weekKeys.has(dateKey(s.date)) && (s.registeredStaff ?? []).length > 0,
)
.reduce((sum, s) => sum + (s.wage ?? 0) * (s.registeredStaff ?? []).length, 0);
}, [shifts, getWeekDates]);
@@ -267,11 +293,11 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
// ── Shift actions ───────────────────────────────────────────────────────
const registerShift = useCallback(
(
async (
shiftId: string,
staffId: string,
staffName: string,
): { success: boolean; error?: string } => {
): Promise<{ 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." };
@@ -284,13 +310,8 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
}
if (
hasConflict(
shift.date,
shift.startTime,
shift.endTime,
staffId,
shiftId,
)
shift.date &&
hasConflict(shift.date, shift.startTime, shift.endTime, staffId, shiftId)
) {
return {
success: false,
@@ -298,24 +319,19 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
};
}
// setShifts((prev) =>
// prev.map((s) =>
// s.id === shiftId
// ? {
// ...s,
// registeredStaff: [
// ...s.registeredStaff,
// { staffId, name: staffName },
// ],
// status: "registered" as ShiftStatus,
// }
// : s,
// ),
// );
return { success: true };
try {
await mutateRegisterShift({
variables: { RegisterShiftInput: { shiftId } },
});
refetch();
return { success: true };
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Đăng ký thất bại, thử lại sau.";
return { success: false, error: message };
}
},
[shifts, hasConflict],
[shifts, hasConflict, mutateRegisterShift, refetch],
);
const unregisterShift = useCallback((shiftId: string, staffId: string) => {