develop (#29)
Release package / release (push) Failing after 2m31s

Co-authored-by: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn>
Co-authored-by: Thanh Quy- wolf <524H0124@student.tdtu.edu.vn>
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #29
Co-authored-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
Co-committed-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
This commit was merged in pull request #29.
This commit is contained in:
2026-04-13 15:19:41 +00:00
committed by TakahashiNguyen
parent 78f3d6bbe9
commit 5307205977
40 changed files with 2442 additions and 330 deletions
+25 -42
View File
@@ -1,7 +1,7 @@
# Components Documentation
> This project follows **Atomic Design** pattern. See `Atomic.md` at project
> root for full structure guide.
> This project follows **Atomic Design** pattern.
> See `Atomic.md` at project root for full structure guide.
## Directory Structure
@@ -19,21 +19,17 @@ components/
### ProductCard (`molecules/cards/ProductCard.tsx`)
**Description:** Product card molecule. Displays product image, name,
description, formatted price, and a Buy button. Width is controlled by the
parent grid (w-full).
**Description:** Product card molecule. Displays product image, name, description, formatted price, and a Buy button. Width is controlled by the parent grid (w-full).
**Atomic level:** Molecule (composed of Button, Caption, Text atoms)
### ShopCard (`molecules/cards/ShopCard.tsx`)
**Description:** Shop discovery card. Displays shop image, name, address, and a
link to the menu.
**Description:** Shop discovery card. Displays shop image, name, address, and a link to the menu.
### SearchBar (`molecules/search-bar/SearchBar.tsx`)
**Description:** Search input with clear button. Controlled component — value
and onChange come from parent.
**Description:** Search input with clear button. Controlled component — value and onChange come from parent.
---
@@ -41,41 +37,35 @@ and onChange come from parent.
### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`)
**Description:** Left sidebar with collapsible category filter. Sticky below
header, full viewport height minus header.
**Description:** Left sidebar with collapsible category filter. Sticky below header, full viewport height minus header.
### CartFab (`organisms/cart/CartFab.tsx`)
**Description:** Floating Action Button displaying cart item count. Links to
/payment page.
**Description:** Floating Action Button displaying cart item count. Links to /payment page.
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
**Description:** Full product grid organism with mobile category tabs,
filtering, and empty state. Uses useCart and useMenu contexts.
**Description:** Full product grid organism with mobile category tabs, filtering, and empty state. Uses useCart and useMenu contexts.
### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`)
**Description:** Responsive grid of ShopCard molecules for the feed/discovery
page.
**Description:** Responsive grid of ShopCard molecules for the feed/discovery page.
### ReviewModal (`organisms/modals/ReviewModal.tsx`)
**Description:** Modal for customer reviews. Shows 5-star rating and textarea.
After submission, displays thank-you message.
**Description:** Modal for customer reviews. Shows 5-star rating and textarea. After submission, displays thank-you message.
### Analytics Charts (`organisms/analytics/`)
**Description:** Pure-SVG chart and table components for the Financial Analytics
dashboard. All components are interactive with hover tooltips.
**Description:** Pure-SVG chart and table components for the Financial Analytics dashboard. All components are interactive with hover tooltips.
| Component | File | Description |
| ------------ | ---------------- | ---------------------------------------------------------- |
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
| Component | File | Description |
|-----------|------|-------------|
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
**Usage:** Imported via `@/components/organisms/analytics` barrel index.
@@ -85,13 +75,11 @@ dashboard. All components are interactive with hover tooltips.
### MainLayout (`templates/main-layout/MainLayout.tsx`)
**Description:** Main layout template — wraps content with Header, Footer, and
CartFab. Used by (main) route group.
**Description:** Main layout template — wraps content with Header, Footer, and CartFab. Used by (main) route group.
### AuthLayout (`templates/auth-layout/AuthLayout.tsx`)
**Description:** Auth layout template — centers content in screen. Used by
login/register pages.
**Description:** Auth layout template — centers content in screen. Used by login/register pages.
### FeedLayout (`templates/feed-layout/FeedLayout.tsx`)
@@ -99,8 +87,7 @@ login/register pages.
### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`)
**Description:** Manager layout template — auth guard + ManagerProvider. Used by
the manager route group.
**Description:** Manager layout template — auth guard + ManagerProvider. Used by the manager route group.
---
@@ -108,20 +95,16 @@ the manager route group.
### AuthContext (`lib/auth-context.tsx`)
Manages user authentication state including login, logout, and registration.
Uses localStorage for persistence.
Manages user authentication state including login, logout, and registration. Uses localStorage for persistence.
### CartContext (`lib/cart-context.tsx`)
Manages shopping cart state with localStorage persistence. Tracks items,
quantities, and totals.
Manages shopping cart state with localStorage persistence. Tracks items, quantities, and totals.
### MenuContext (`lib/menu-context.tsx`)
Provides shared activeCategory state across components. Synchronizes Header
mobile menu and CategorySidebar selection.
Provides shared activeCategory state across components. Synchronizes Header mobile menu and CategorySidebar selection.
### ManagerContext (`lib/manager-context.tsx`)
Manages menu CRUD state (products, combos, categories) for the manager
dashboard.
Manages menu CRUD state (products, combos, categories) for the manager dashboard.
-19
View File
@@ -42,7 +42,6 @@ Main interactive button component with multiple variants and sizes.
- All standard HTML button attributes
**Usage:**
```tsx
import { Button } from "@/components/atoms";
@@ -60,7 +59,6 @@ import { Button } from "@/components/atoms";
```
**Styling:**
- Primary: Branded color with dark hover
- Secondary: Border style with light background on hover
- Danger: Red for destructive actions
@@ -80,7 +78,6 @@ Button designed specifically for icon-only interactions.
- All standard HTML button attributes
**Usage:**
```tsx
import { IconButton } from "@/components/atoms";
@@ -105,7 +102,6 @@ General text input field with optional label, error, and icon.
- All standard HTML input attributes
**Usage:**
```tsx
import { TextInput } from "@/components/atoms";
@@ -139,7 +135,6 @@ Search input with built-in search icon and clear button.
- All standard HTML input attributes
**Usage:**
```tsx
import { SearchInput } from "@/components/atoms";
@@ -166,7 +161,6 @@ Multi-line text input with optional label and error.
- All standard HTML textarea attributes
**Usage:**
```tsx
import { Textarea } from "@/components/atoms";
@@ -194,7 +188,6 @@ Semantic heading component with level-based sizing.
- All standard HTML heading attributes
**Sizing:**
- Level 1: text-3xl font-bold
- Level 2: text-2xl font-bold
- Level 3: text-xl font-bold
@@ -203,7 +196,6 @@ Semantic heading component with level-based sizing.
- Level 6: text-sm font-semibold
**Usage:**
```tsx
import { Heading } from "@/components/atoms";
@@ -225,14 +217,12 @@ Paragraph text with semantic variants.
- All standard HTML paragraph attributes
**Variants:**
- body1: text-base (main content)
- body2: text-sm (secondary content)
- caption: text-xs (smallest text)
- label: text-sm font-medium (form labels)
**Usage:**
```tsx
import { Text } from "@/components/atoms";
@@ -254,7 +244,6 @@ Small caption/note text component.
- All standard HTML span attributes
**Usage:**
```tsx
import { Caption } from "@/components/atoms";
@@ -279,7 +268,6 @@ Labeled badge component for highlighting information.
- All standard HTML span attributes
**Variants:**
- primary: Branded color
- secondary: Light gray
- success: Green
@@ -287,7 +275,6 @@ Labeled badge component for highlighting information.
- warning: Yellow
**Usage:**
```tsx
import { Badge } from "@/components/atoms";
@@ -309,7 +296,6 @@ Specialized badge for displaying formatted prices.
- All standard HTML span attributes
**Usage:**
```tsx
import { PriceBadge } from "@/components/atoms";
@@ -318,7 +304,6 @@ import { PriceBadge } from "@/components/atoms";
```
**Output:**
- VND: "50.000 ₫"
- USD: "$99.99"
@@ -336,7 +321,6 @@ Visual separator element.
- All standard HTML hr attributes
**Usage:**
```tsx
import { Divider } from "@/components/atoms";
@@ -430,7 +414,6 @@ import type {
```
### Product Card
```tsx
<div className="rounded-lg border p-4">
<Heading level={3}>Product Name</Heading>
@@ -443,7 +426,6 @@ import type {
```
### Rating Display
```tsx
<div>
<Heading level={4}>Reviews</Heading>
@@ -457,7 +439,6 @@ import type {
## 🧪 Testing Atoms
### Props Validation
```tsx
// ✅ Valid
<Button variant="primary" size="sm">Click</Button>
+46
View File
@@ -0,0 +1,46 @@
"use client";
import type { ButtonProps } from "./Button.types";
export default function IconButton({
variant = "primary",
size = "md",
icon,
disabled = false,
className = "",
children,
style: _style,
...props
}: ButtonProps) {
const baseStyles =
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary:
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
secondary:
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
primaryNoBorder:
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
bgWhite:
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
};
const sizes = {
sm: "h-8 w-8 text-sm",
md: "h-10 w-10 text-base",
lg: "h-12 w-12 text-lg",
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
</button>
);
}
@@ -13,13 +13,14 @@ export default function PaymentSummaryCard({
isCustomer = false,
backHref,
}: PaymentSummaryCardProps) {
const [isReviewOpen, setIsReviewOpen] = useState(false);
const handlePayment = () => {
const [isReviewOpen, setIsReviewOpen] = useState(false);
const handlePayment = () => {
// UI-only: open review modal after "payment"
if (isCustomer) {
setIsReviewOpen(true);
}
};
setIsReviewOpen(true);
}
};
return (
<aside className="shrink-0 xl:w-85">
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
+112
View File
@@ -0,0 +1,112 @@
"use client";
import type { ShiftSlot } from "@/lib/types";
import type { ShiftCardProps } from "./ShiftCard.types";
const STATUS_STYLES: Record<ShiftSlot["status"], { bg: string; text: string; label: string }> = {
available: {
bg: "bg-blue-50 border-blue-200",
text: "text-blue-700",
label: "Còn trống",
},
registered: {
bg: "bg-blue-100 border-blue-400",
text: "text-blue-900",
label: "Đã đăng ký",
},
approved_leave: {
bg: "bg-purple-50 border-purple-300",
text: "text-purple-700",
label: "Nghỉ phép",
},
absent: {
bg: "bg-red-50 border-red-300",
text: "text-red-700",
label: "Vắng mặt",
},
};
function formatWage(wage: number): string {
if (wage >= 1000) {
return `${(wage / 1000).toFixed(0)}k`;
}
return wage.toLocaleString("vi-VN");
}
export default function ShiftCard({ shift, compact = false, onClick }: ShiftCardProps) {
const style = STATUS_STYLES[shift.status];
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 ${style.bg} ${style.text}`}
>
<p className="font-semibold">
{shift.startTime} {shift.endTime}
</p>
<p className="mt-0.5 opacity-75">
{shift.durationHours}h · {formatWage(shift.wage)}
</p>
</button>
);
}
return (
<button
type="button"
onClick={() => onClick?.(shift)}
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md ${style.bg} ${style.text}`}
>
<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">
{shift.durationHours}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.length > 0 && (
<div className="mt-2 border-t border-current/10 pt-2">
<p className="text-[10px] font-medium uppercase tracking-wide 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.name}
</span>
))}
</div>
</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>
)}
</button>
);
}
@@ -0,0 +1,7 @@
import type { ShiftSlot } from "@/lib/types";
export interface ShiftCardProps {
shift: ShiftSlot;
compact?: boolean;
onClick?: (shift: ShiftSlot) => void;
}
+2
View File
@@ -1,3 +1,5 @@
export { default as ProductCard } from "./ProductCard";
export { default as ShopCard } from "./ShopCard";
export { default as ShiftCard } from "./ShiftCard";
export type { ProductCardProps, ShopCardProps } from "./Card.types";
export type { ShiftCardProps } from "./ShiftCard.types";
+2 -8
View File
@@ -16,16 +16,10 @@ interface BarChartProps {
* Tooltip auto-flips above/below bar top to stay inside the viewBox.
*/
export function BarChart({ current, previous, height = 200 }: BarChartProps) {
const [hovered, setHovered] = useState<{
set: "cur" | "prev";
idx: number;
} | null>(null);
const [hovered, setHovered] = useState<{ set: "cur" | "prev"; idx: number } | null>(null);
const W = 800;
const H = height;
const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const padL = 56, padR = 16, padT = 16, padB = 40;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
+22 -100
View File
@@ -18,10 +18,7 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
const [hovered, setHovered] = useState<number | null>(null);
const W = 800;
const H = height;
const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const padL = 56, padR = 16, padT = 16, padB = 40;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
@@ -69,122 +66,47 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
{gridLines.map((g, i) => (
<g key={i}>
<line
x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
<line x1={padL} y1={g.y} x2={W - padR} y2={g.y} stroke="#E2C9A8" strokeWidth="1" strokeDasharray={i === yTicks ? "0" : "4 3"} />
<text x={padL - 6} y={g.y + 4} textAnchor="end" fontSize="10" fill="#A08060">{formatCurrency(g.val)}</text>
</g>
))}
<path d={areaD} fill="url(#areaGrad)" />
<path
d={pathD}
fill="none"
stroke="#6F4E37"
strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path d={pathD} fill="none" stroke="#6F4E37" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
{points.map((p, i) =>
i % step === 0 ? (
<text
key={i}
x={p.x}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.label}
</text>
<text key={i} x={p.x} y={H - 8} textAnchor="middle" fontSize="10" fill="#A08060">{p.data.label}</text>
) : null,
)}
{points.map((p) => (
<circle
key={p.index}
cx={p.x}
cy={p.y}
cx={p.x} cy={p.y}
r={hovered === p.index ? 5 : 3}
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
stroke="#FDF6EC"
strokeWidth="2"
stroke="#FDF6EC" strokeWidth="2"
style={{ cursor: "pointer", transition: "r 150ms" }}
onMouseEnter={() => setHovered(p.index)}
/>
))}
{hovered !== null &&
(() => {
const p = points[hovered];
const tipW = 120,
tipH = 48;
const tipX = Math.min(
Math.max(p.x - tipW / 2, padL),
W - padR - tipW,
);
const aboveY = p.y - tipH - 10;
const tipY = Math.min(
Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
padT + chartH - tipH,
);
return (
<g>
<rect
x={tipX}
y={tipY}
width={tipW}
height={tipH}
rx="6"
fill="#3D2B1F"
opacity="0.92"
/>
<text
x={tipX + tipW / 2}
y={tipY + 16}
textAnchor="middle"
fontSize="10"
fill="#F0D9A8"
>
{p.data.label}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 30}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="#C8973A"
>
{formatCurrency(p.data.revenue)}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 44}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.orders} đơn
</text>
</g>
);
})()}
{hovered !== null && (() => {
const p = points[hovered];
const tipW = 120, tipH = 48;
const tipX = Math.min(Math.max(p.x - tipW / 2, padL), W - padR - tipW);
const aboveY = p.y - tipH - 10;
const tipY = Math.min(Math.max(aboveY >= padT ? aboveY : p.y + 10, padT), padT + chartH - tipH);
return (
<g>
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="6" fill="#3D2B1F" opacity="0.92" />
<text x={tipX + tipW / 2} y={tipY + 16} textAnchor="middle" fontSize="10" fill="#F0D9A8">{p.data.label}</text>
<text x={tipX + tipW / 2} y={tipY + 30} textAnchor="middle" fontSize="11" fontWeight="600" fill="#C8973A">{formatCurrency(p.data.revenue)}</text>
<text x={tipX + tipW / 2} y={tipY + 44} textAnchor="middle" fontSize="10" fill="#A08060">{p.data.orders} đơn</text>
</g>
);
})()}
</svg>
</div>
);
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useState, useMemo } from "react";
export interface PieSlice {
label: string;
+1 -1
View File
@@ -1,10 +1,10 @@
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import LoginInput from "@/components/atoms/inputs/LoginInput";
import { useAuth } from "@/lib/auth-context";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import Button from "@/components/atoms/buttons/Button";
export default function LoginForm() {
const router = useRouter();
+16
View File
@@ -36,3 +36,19 @@ export type {
DeleteConfirmProps,
StatusBadgeProps,
} from "./manager";
// Shift Schedule
export {
WeeklySchedule,
MonthlyCalendar,
MobileShiftView,
ShiftDetailModal,
ShiftCreateModal,
} from "./shift-schedule";
export type {
WeeklyScheduleProps,
MonthlyCalendarProps,
MobileShiftViewProps,
ShiftDetailModalProps,
ShiftCreateModalProps,
} from "./shift-schedule";
+1 -4
View File
@@ -152,10 +152,7 @@ export default function ProductsTab() {
</tr>
) : (
filtered.map((p) => (
<tr
key={p.id}
className="hover:bg-background transition-colors"
>
<tr key={p.id} className="hover:bg-background transition-colors">
<td className="px-4 py-3">
<div>
<p className="text-foreground font-medium">{p.name}</p>
@@ -0,0 +1,213 @@
"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 = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
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(2026, 3, 10);
return (
d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() &&
d.getFullYear() === today.getFullYear()
);
}
const MONTH_NAMES = [
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4",
"Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8",
"Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12",
];
export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps) {
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(new Date(2026, 3, 10)));
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
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));
}
while (days.length % 7 !== 0) days.push(null);
return days;
}, [currentDate]);
const getDotColors = (date: Date): string[] => {
const dateStr = formatDateISO(date);
const dayShifts = shifts.filter((s) => s.date === dateStr);
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(() => {
return shifts.filter((s) => s.date === selectedDate);
}, [shifts, selectedDate]);
const selectedDateObj = new Date(selectedDate + "T00:00:00");
const dayOfWeek = DAY_HEADERS[(selectedDateObj.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 navigation */}
<div className="mb-3 flex items-center justify-between">
<button
title="Trở lại tháng trước"
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"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<h3 className="text-sm font-bold text-foreground">
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Trở lại tháng sau"
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"
>
<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 dateStr = formatDateISO(date);
const today = isToday(date);
const selected = dateStr === selectedDate;
const dots = getDotColors(date);
return (
<button
type="button"
key={i}
onClick={() => setSelectedDate(dateStr)}
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
selected ? "rounded-lg bg-(--color-primary)/10" : ""
}`}
>
<span
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs ${
today
? "bg-(--color-primary) font-bold text-white"
: selected
? "font-bold text-foreground"
: "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>
{/* 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)">Còn trống</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)">Đã ĐK</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)">Nghỉ phép</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)">Vắng</span>
</div>
</div>
</div>
{/* Selected day shifts */}
<div>
<h3 className="mb-3 text-sm font-bold text-foreground">
{dayOfWeek}, {selectedDateObj.getDate()}/{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
({selectedShifts.length} ca)
</span>
</h3>
{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)">Không ca làm trong ngày này</p>
</div>
) : (
<div className="space-y-3">
{DEPARTMENTS.map((dept) => {
const deptShifts = selectedShifts.filter((s) => s.department === dept.id);
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>
)}
</div>
</div>
);
}
@@ -0,0 +1,159 @@
"use client";
import { useShift } from "@/lib/shift-context";
import { useMemo } from "react";
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
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(2026, 3, 10);
return (
d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() &&
d.getFullYear() === today.getFullYear()
);
}
export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyCalendarProps) {
const { currentDate, shifts } = useShift();
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
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);
}
return days;
}, [currentDate]);
const getShiftSummary = (date: Date) => {
const dateStr = formatDateISO(date);
const dayShifts = shifts.filter((s) => s.date === dateStr);
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, available, registered, leave, absent };
};
return (
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-(--color-border-light) bg-gray-50">
{DAY_HEADERS.map((day) => (
<div
key={day}
className="px-2 py-2.5 text-center text-xs 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="min-h-25 border-b border-r border-(--color-border-light) bg-gray-50/30"
/>
);
}
const summary = getShiftSummary(date);
const today = isToday(date);
return (
<button
type="button"
key={i}
onClick={() => onDateSelect?.(formatDateISO(date))}
className={`min-h-25 cursor-pointer border-b border-r border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${
today ? "bg-(--color-primary)/5" : ""
}`}
>
<span
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
today
? "bg-(--color-primary) font-bold text-white"
: "font-medium text-foreground"
}`}
>
{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} trống
</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} đã ĐK
</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} nghỉ
</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} vắng
</span>
</div>
)}
</div>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context";
import { useEffect, useState } from "react";
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: ShiftCreateModalProps) {
const { createShift } = useShift();
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
const [startTime, setStartTime] = useState("08:00");
const [endTime, setEndTime] = useState("12:00");
const [department, setDepartment] = useState("bar");
const [maxStaff, setMaxStaff] = useState(2);
const [wage, setWage] = useState(120000);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setDate(defaultDate ?? "2026-04-10");
}
}, [defaultDate, isOpen]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate
if (!date || !startTime || !endTime) {
setError("Vui lòng điền đầy đủ thông tin.");
return;
}
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
const startMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
if (endMinutes <= startMinutes) {
setError("Giờ kết thúc phải sau giờ bắt đầu.");
return;
}
const durationHours = (endMinutes - startMinutes) / 60;
createShift({
date,
startTime,
endTime,
durationHours,
wage,
department,
maxStaff,
registeredStaff: [],
status: "available",
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
<div>
<h2 className="text-base font-bold text-foreground">
Tạo ca làm mới
</h2>
<p className="text-xs text-(--color-text-muted)">
Thêm khung giờ ca làm cho nhân viên
</p>
</div>
<button
title="Close"
type="button"
onClick={onClose}
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-xmark"></i>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
{/* Date */}
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Ngày
</label>
<input
title="Date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
{/* Time range */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Giờ bắt đu
</label>
<input
title="Start Time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Giờ kết thúc
</label>
<input
title="End Time"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
</div>
{/* Department */}
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Bộ phận
</label>
<select
title="Department"
value={department}
onChange={(e) => setDepartment(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
>
{DEPARTMENTS.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
{/* Max staff & Wage */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Số nhân viên tối đa
</label>
<input
title="Max Staff"
type="number"
min={1}
max={10}
value={maxStaff}
onChange={(e) => setMaxStaff(Number(e.target.value))}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Lương ca (VND)
</label>
<input
title="Wage"
type="number"
min={0}
step={10000}
value={wage}
onChange={(e) => setWage(Number(e.target.value))}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
</div>
{/* Error */}
{error && (
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
<i className="fa-solid fa-circle-exclamation mr-2"></i>
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<button
type="submit"
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-plus mr-2"></i>
Tạo ca làm
</button>
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
>
Hủy
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,237 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context";
import { useState } from "react";
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetailModalProps) {
const { user } = useAuth();
const { registerShift, unregisterShift, deleteShift } = useShift();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
if (!isOpen || !shift) return null;
const dept = DEPARTMENTS.find((d) => d.id === shift.department);
const isManager = user?.role === "manager";
const isRegistered = user ? shift.registeredStaff.some((s) => s.id === user.id) : false;
const isFull = shift.registeredStaff.length >= shift.maxStaff;
const handleRegister = () => {
if (!user) return;
setError(null);
setSuccess(null);
const result = registerShift(shift.id, user.id, user.name);
if (result.success) {
setSuccess("Đăng ký ca thành công!");
setTimeout(onClose, 1200);
} else {
setError(result.error ?? "Có lỗi xảy ra.");
}
};
const handleUnregister = () => {
if (!user) return;
setError(null);
unregisterShift(shift.id, user.id);
setSuccess("Đã hủy đăng ký ca.");
setTimeout(onClose, 1200);
};
const handleManagerUnregister = (staffId: number) => {
unregisterShift(shift.id, staffId);
setSuccess("Đã xóa nhân viên khỏi ca.");
};
const handleDelete = () => {
deleteShift(shift.id);
onClose();
};
const statusLabel = {
available: "Còn trống",
registered: "Đã đăng ký",
approved_leave: "Nghỉ phép",
absent: "Vắng mặt",
};
const statusColor = {
available: "bg-blue-100 text-blue-700",
registered: "bg-blue-200 text-blue-900",
approved_leave: "bg-purple-100 text-purple-700",
absent: "bg-red-100 text-red-700",
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
<div className="flex items-center gap-3">
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
<div>
<h2 className="text-base font-bold text-foreground">
Chi tiết ca làm
</h2>
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
</div>
</div>
<button
title="Close"
type="button"
onClick={onClose}
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-xmark"></i>
</button>
</div>
{/* Body */}
<div className="space-y-4 px-5 py-4">
{/* Status badge */}
<div className="flex items-center gap-2">
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}>
{statusLabel[shift.status]}
</span>
</div>
{/* Shift info grid */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Ngày</p>
<p className="mt-1 text-sm font-bold text-foreground">
{new Date(shift.date + "T00:00:00").toLocaleDateString("vi-VN", {
weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Giờ làm</p>
<p className="mt-1 text-sm font-bold text-foreground">
{shift.startTime} {shift.endTime}
</p>
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Thời lượng</p>
<p className="mt-1 text-sm font-bold text-foreground">
{shift.durationHours} giờ
</p>
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Lương ca</p>
<p className="mt-1 text-sm font-bold text-(--color-primary)">
{shift.wage.toLocaleString("vi-VN")} VND
</p>
</div>
</div>
{/* Registered staff */}
<div>
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
Nhân viên đã đăng ({shift.registeredStaff.length}/{shift.maxStaff})
</p>
{shift.registeredStaff.length === 0 ? (
<p className="text-xs italic text-(--color-text-muted)">Chưa ai đăng </p>
) : (
<div className="space-y-2">
{shift.registeredStaff.map((staff) => (
<div
key={staff.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-(--color-primary)/10">
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
</div>
<span className="text-sm font-medium text-foreground">
{staff.name}
</span>
</div>
{isManager && (
<button
type="button"
onClick={() => handleManagerUnregister(staff.id)}
className="cursor-pointer rounded-lg border-none bg-transparent px-2 py-1 text-xs text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-user-minus mr-1"></i>
Xóa
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Feedback messages */}
{error && (
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
<i className="fa-solid fa-circle-exclamation mr-2"></i>
{error}
</div>
)}
{success && (
<div className="rounded-xl bg-green-50 px-4 py-3 text-sm text-green-700">
<i className="fa-solid fa-circle-check mr-2"></i>
{success}
</div>
)}
</div>
{/* 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 ca
</button>
)}
{isRegistered && (
<button
type="button"
onClick={handleUnregister}
className="flex-1 cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
>
<i className="fa-solid fa-calendar-minus mr-2"></i>
Hủy đăng
</button>
)}
{isManager && (
<button
type="button"
onClick={handleDelete}
className="cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
>
<i className="fa-solid fa-trash mr-2"></i>
Xóa ca
</button>
)}
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
>
Đóng
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import type { ShiftSlot } from "@/lib/types";
export interface WeeklyScheduleProps {
onShiftClick: (shift: ShiftSlot) => void;
onCreateShift?: (date: string) => void;
mobileCalendarHeader?: boolean;
}
export interface MonthlyCalendarProps {
onShiftClick: (shift: ShiftSlot) => void;
onDateSelect?: (date: string) => void;
}
export interface MobileShiftViewProps {
onShiftClick: (shift: ShiftSlot) => void;
}
export interface ShiftDetailModalProps {
shift: ShiftSlot | null;
isOpen: boolean;
onClose: () => void;
}
export interface ShiftCreateModalProps {
isOpen: boolean;
onClose: () => void;
defaultDate?: string;
}
@@ -0,0 +1,249 @@
"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 { WeeklyScheduleProps } from "./ShiftSchedule.types";
const MONTH_NAMES_EN = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
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(2026, 3, 10);
return (
d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() &&
d.getFullYear() === today.getFullYear()
);
}
export default function WeeklySchedule({
onShiftClick,
onCreateShift,
mobileCalendarHeader = false,
}: WeeklyScheduleProps) {
const { currentDate, getWeekDates, getShiftsForDate, goToNextWeek, goToPrevWeek } = useShift();
const weekDates = getWeekDates();
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(weekDates[0] ?? currentDate));
const statusDotsByDate = useMemo(() => {
const map: Record<string, string[]> = {};
weekDates.forEach((date) => {
const dateStr = formatDateISO(date);
const dayShifts = getShiftsForDate(dateStr);
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[dateStr] = dots.slice(0, 3);
});
return map;
}, [weekDates, getShiftsForDate]);
const selectedDateObj = useMemo(() => {
const inWeek = weekDates.find((d) => formatDateISO(d) === selectedDate);
return inWeek ?? weekDates[0] ?? currentDate;
}, [selectedDate, weekDates, currentDate]);
const selectedDateStr = formatDateISO(selectedDateObj);
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="Tuần trước"
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="Tuần sau"
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 dateStr = formatDateISO(date);
const active = dateStr === selectedDateStr;
const dots = statusDotsByDate[dateStr] ?? [];
return (
<button
key={i}
type="button"
onClick={() => setSelectedDate(dateStr)}
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(date)
? "bg-indigo-500 text-white"
: i >= 5
? "text-pink-500"
: "text-sky-500"
}`}
>
{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(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>
Thêm ca
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
if (mobileCalendarHeader) {
return renderMobileDayView ?? null;
}
return (
<div className="overflow-x-auto">
<table className="w-full min-w-225 border-collapse">
<thead>
<tr>
<th className="w-28 border-b border-r border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
Bộ phận
</th>
{weekDates.map((date, i) => (
<th
key={i}
className={`border-b border-r border-(--color-border-light) px-2 py-3 text-center text-xs ${
isToday(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">
{formatDateShort(date)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{DEPARTMENTS.map((dept) => (
<tr key={dept.id}>
<td className="border-b border-r 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 = formatDateISO(date);
const shifts = getShiftsForDate(dateStr).filter(
(s) => s.department === dept.id,
);
return (
<td
key={i}
className={`border-b border-r border-(--color-border-light) p-1.5 align-top ${
isToday(date) ? "bg-(--color-primary)/5" : ""
}`}
>
<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>
Thêm ca
</button>
)}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,12 @@
export { default as WeeklySchedule } from "./WeeklySchedule";
export { default as MonthlyCalendar } from "./MonthlyCalendar";
export { default as MobileShiftView } from "./MobileShiftView";
export { default as ShiftDetailModal } from "./ShiftDetailModal";
export { default as ShiftCreateModal } from "./ShiftCreateModal";
export type {
WeeklyScheduleProps,
MonthlyCalendarProps,
MobileShiftViewProps,
ShiftDetailModalProps,
ShiftCreateModalProps,
} from "./ShiftSchedule.types";
+4
View File
@@ -13,3 +13,7 @@ export type { FeedLayoutProps } from "./feed-layout";
// Manager Layout
export { ManagerLayout } from "./manager-layout";
export type { ManagerLayoutProps } from "./manager-layout";
// Staff Layout
export { StaffLayout } from "./staff-layout";
export type { StaffLayoutProps } from "./staff-layout";
@@ -0,0 +1,49 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { ShiftProvider } from "@/lib/shift-context";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import type { StaffLayoutProps } from "./StaffLayout.types";
/**
* Staff layout template — wraps content with auth guard and ShiftProvider.
* Allows both staff and manager roles; redirects customers away.
*/
export default function StaffLayout({ children }: StaffLayoutProps) {
const { user } = useAuth();
const router = useRouter();
useEffect(() => {
if (user !== null && user.role === "customer") {
router.replace("/");
}
}, [user, router]);
if (user === null) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
<Link
href="/login"
className="text-sm font-medium text-(--color-primary) hover:underline"
>
Đăng nhập nếu chưa tài khoản
</Link>
</div>
</div>
);
}
if (user.role === "customer") return null;
return (
<ShiftProvider>
<div className="bg-background flex min-h-screen flex-col">{children}</div>
</ShiftProvider>
);
}
@@ -0,0 +1,3 @@
export interface StaffLayoutProps {
children: React.ReactNode;
}
@@ -0,0 +1,2 @@
export { default as StaffLayout } from "./StaffLayout";
export type { StaffLayoutProps } from "./StaffLayout.types";