chore: release [ci skip]

This commit is contained in:
gitea-actions
2026-04-14 03:05:52 +00:00
parent 86d55540b4
commit b9c0e6a59f
26 changed files with 745 additions and 416 deletions
+42 -25
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,17 +19,21 @@ 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.
---
@@ -37,35 +41,41 @@ components/
### 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.
@@ -75,11 +85,13 @@ components/
### 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`)
@@ -87,7 +99,8 @@ components/
### 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.
---
@@ -95,16 +108,20 @@ components/
### 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,6 +42,7 @@ Main interactive button component with multiple variants and sizes.
- All standard HTML button attributes
**Usage:**
```tsx
import { Button } from "@/components/atoms";
@@ -59,6 +60,7 @@ 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
@@ -78,6 +80,7 @@ Button designed specifically for icon-only interactions.
- All standard HTML button attributes
**Usage:**
```tsx
import { IconButton } from "@/components/atoms";
@@ -102,6 +105,7 @@ General text input field with optional label, error, and icon.
- All standard HTML input attributes
**Usage:**
```tsx
import { TextInput } from "@/components/atoms";
@@ -135,6 +139,7 @@ Search input with built-in search icon and clear button.
- All standard HTML input attributes
**Usage:**
```tsx
import { SearchInput } from "@/components/atoms";
@@ -161,6 +166,7 @@ Multi-line text input with optional label and error.
- All standard HTML textarea attributes
**Usage:**
```tsx
import { Textarea } from "@/components/atoms";
@@ -188,6 +194,7 @@ 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
@@ -196,6 +203,7 @@ Semantic heading component with level-based sizing.
- Level 6: text-sm font-semibold
**Usage:**
```tsx
import { Heading } from "@/components/atoms";
@@ -217,12 +225,14 @@ 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";
@@ -244,6 +254,7 @@ Small caption/note text component.
- All standard HTML span attributes
**Usage:**
```tsx
import { Caption } from "@/components/atoms";
@@ -268,6 +279,7 @@ Labeled badge component for highlighting information.
- All standard HTML span attributes
**Variants:**
- primary: Branded color
- secondary: Light gray
- success: Green
@@ -275,6 +287,7 @@ Labeled badge component for highlighting information.
- warning: Yellow
**Usage:**
```tsx
import { Badge } from "@/components/atoms";
@@ -296,6 +309,7 @@ Specialized badge for displaying formatted prices.
- All standard HTML span attributes
**Usage:**
```tsx
import { PriceBadge } from "@/components/atoms";
@@ -304,6 +318,7 @@ import { PriceBadge } from "@/components/atoms";
```
**Output:**
- VND: "50.000 ₫"
- USD: "$99.99"
@@ -321,6 +336,7 @@ Visual separator element.
- All standard HTML hr attributes
**Usage:**
```tsx
import { Divider } from "@/components/atoms";
@@ -414,6 +430,7 @@ import type {
```
### Product Card
```tsx
<div className="rounded-lg border p-4">
<Heading level={3}>Product Name</Heading>
@@ -426,6 +443,7 @@ import type {
```
### Rating Display
```tsx
<div>
<Heading level={4}>Reviews</Heading>
@@ -439,6 +457,7 @@ import type {
## 🧪 Testing Atoms
### Props Validation
```tsx
// ✅ Valid
<Button variant="primary" size="sm">Click</Button>
@@ -13,14 +13,13 @@ 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">
+10 -3
View File
@@ -4,7 +4,10 @@ import type { ShiftSlot } from "@/lib/types";
import type { ShiftCardProps } from "./ShiftCard.types";
const STATUS_STYLES: Record<ShiftSlot["status"], { bg: string; text: string; label: string }> = {
const STATUS_STYLES: Record<
ShiftSlot["status"],
{ bg: string; text: string; label: string }
> = {
available: {
bg: "bg-blue-50 border-blue-200",
text: "text-blue-700",
@@ -34,7 +37,11 @@ function formatWage(wage: number): string {
return wage.toLocaleString("vi-VN");
}
export default function ShiftCard({ shift, compact = false, onClick }: ShiftCardProps) {
export default function ShiftCard({
shift,
compact = false,
onClick,
}: ShiftCardProps) {
const style = STATUS_STYLES[shift.status];
if (compact) {
@@ -86,7 +93,7 @@ export default function ShiftCard({ shift, compact = false, onClick }: ShiftCard
{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">
<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">
+8 -2
View File
@@ -16,10 +16,16 @@ 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;
+100 -22
View File
@@ -18,7 +18,10 @@ 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;
@@ -66,47 +69,122 @@ 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 { useState, useMemo } from "react";
import { useMemo, useState } 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();
+4 -1
View File
@@ -152,7 +152,10 @@ 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>
@@ -26,14 +26,27 @@ function isToday(d: Date): boolean {
}
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",
"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) {
export default function MobileShiftView({
onShiftClick,
}: MobileShiftViewProps) {
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(new Date(2026, 3, 10)));
const [selectedDate, setSelectedDate] = useState<string>(
formatDateISO(new Date(2026, 3, 10)),
);
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
@@ -57,9 +70,12 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
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 === "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;
};
@@ -85,7 +101,7 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<h3 className="text-sm font-bold text-foreground">
<h3 className="text-foreground text-sm font-bold">
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
@@ -101,7 +117,10 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
{/* 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">
<div
key={day}
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"
>
{day}
</div>
))}
@@ -133,7 +152,7 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
today
? "bg-(--color-primary) font-bold text-white"
: selected
? "font-bold text-foreground"
? "text-foreground font-bold"
: "text-foreground"
}`}
>
@@ -153,7 +172,9 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
<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>
<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>
@@ -161,7 +182,9 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
</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>
<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>
@@ -172,8 +195,9 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
{/* Selected day shifts */}
<div>
<h3 className="mb-3 text-sm font-bold text-foreground">
{dayOfWeek}, {selectedDateObj.getDate()}/{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
<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} ca)
</span>
@@ -182,24 +206,34 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
{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>
<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);
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>
<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} />
<ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))}
</div>
</div>
@@ -23,7 +23,10 @@ function isToday(d: Date): boolean {
);
}
export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyCalendarProps) {
export default function MonthlyCalendar({
onShiftClick,
onDateSelect,
}: MonthlyCalendarProps) {
const { currentDate, shifts } = useShift();
const calendarDays = useMemo(() => {
@@ -60,7 +63,9 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
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 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 };
@@ -87,7 +92,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
return (
<div
key={`empty-${i}`}
className="min-h-25 border-b border-r border-(--color-border-light) bg-gray-50/30"
className="min-h-25 border-r border-b border-(--color-border-light) bg-gray-50/30"
/>
);
}
@@ -100,7 +105,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
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 ${
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" : ""
}`}
>
@@ -108,7 +113,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
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"
: "text-foreground font-medium"
}`}
>
{date.getDate()}
@@ -6,7 +6,11 @@ import { useEffect, useState } from "react";
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: ShiftCreateModalProps) {
export default function ShiftCreateModal({
isOpen,
onClose,
defaultDate,
}: ShiftCreateModalProps) {
const { createShift } = useShift();
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
@@ -72,7 +76,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
{/* 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">
<h2 className="text-foreground text-base font-bold">
Tạo ca làm mới
</h2>
<p className="text-xs text-(--color-text-muted)">
@@ -101,7 +105,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
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)"
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
@@ -116,7 +120,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
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)"
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
<div>
@@ -128,7 +132,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
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)"
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
</div>
@@ -142,7 +146,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
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)"
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
>
{DEPARTMENTS.map((dept) => (
<option key={dept.id} value={dept.id}>
@@ -165,7 +169,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
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)"
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
<div>
@@ -179,7 +183,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
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)"
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/>
</div>
</div>
@@ -7,7 +7,11 @@ import { useState } from "react";
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetailModalProps) {
export default function ShiftDetailModal({
shift,
isOpen,
onClose,
}: ShiftDetailModalProps) {
const { user } = useAuth();
const { registerShift, unregisterShift, deleteShift } = useShift();
const [error, setError] = useState<string | null>(null);
@@ -17,7 +21,9 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
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 isRegistered = user
? shift.registeredStaff.some((s) => s.id === user.id)
: false;
const isFull = shift.registeredStaff.length >= shift.maxStaff;
const handleRegister = () => {
@@ -68,10 +74,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
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}
/>
<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">
@@ -80,7 +83,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
<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">
<h2 className="text-foreground text-base font-bold">
Chi tiết ca làm
</h2>
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
@@ -100,7 +103,9 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
<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]}`}>
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
>
{statusLabel[shift.status]}
</span>
</div>
@@ -108,30 +113,41 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
{/* 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 className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Ngày
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{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">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Giờ làm
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{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">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Thời lượng
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{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="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>
@@ -141,10 +157,13 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
{/* 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})
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>
<p className="text-xs text-(--color-text-muted) italic">
Chưa ai đăng
</p>
) : (
<div className="space-y-2">
{shift.registeredStaff.map((staff) => (
@@ -156,7 +175,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
<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">
<span className="text-foreground text-sm font-medium">
{staff.name}
</span>
</div>
@@ -193,16 +212,19 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
{/* 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 &&
!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"
@@ -8,8 +8,18 @@ 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",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
@@ -40,10 +50,18 @@ 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<string>(formatDateISO(weekDates[0] ?? currentDate));
const [selectedDate, setSelectedDate] = useState<string>(
formatDateISO(weekDates[0] ?? currentDate),
);
const statusDotsByDate = useMemo(() => {
const map: Record<string, string[]> = {};
@@ -51,10 +69,14 @@ export default function WeeklySchedule({
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");
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;
@@ -106,7 +128,9 @@ export default function WeeklySchedule({
active ? "bg-(--color-primary)/10" : "bg-transparent"
}`}
>
<span className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}>
<span
className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
>
{DAY_LABELS_EN[i]}
</span>
<span
@@ -122,7 +146,10 @@ export default function WeeklySchedule({
</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}`} />
<span
key={idx}
className={`h-1.5 w-1.5 rounded-full ${dot}`}
/>
))}
</div>
</button>
@@ -133,19 +160,30 @@ export default function WeeklySchedule({
<div className="space-y-3">
{DEPARTMENTS.map((dept) => {
const deptShifts = getShiftsForDate(selectedDateStr).filter((s) => s.department === dept.id);
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
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>
<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} />
<ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))}
{onCreateShift && (
<button
@@ -174,13 +212,13 @@ export default function WeeklySchedule({
<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">
<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">
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 ${
className={`border-r border-b 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)"
@@ -197,9 +235,11 @@ export default function WeeklySchedule({
<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">
<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>
<i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name}
</span>
@@ -213,7 +253,7 @@ export default function WeeklySchedule({
return (
<td
key={i}
className={`border-b border-r border-(--color-border-light) p-1.5 align-top ${
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
isToday(date) ? "bg-(--color-primary)/5" : ""
}`}
>