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:
+200
-186
@@ -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 lý
|
||||
</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}
|
||||
|
||||
@@ -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 ký
|
||||
</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 có ca làm</p>
|
||||
<p className="mt-0.5 text-xs text-(--color-text-muted)">Chưa có 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 ký 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 có 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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user