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
+29 -23
View File
@@ -67,13 +67,15 @@ Dành cho khách hàng:
Dành cho quản lý:
- Sidebar desktop: Brand, tab navigation (Thực đơn / Combo / Danh mục), link tới Analytics
- Sidebar desktop: Brand, tab navigation (Thực đơn / Combo / Danh mục), link tới
Analytics
- CRUD sản phẩm, combo, danh mục qua modals
- Auth guard: tự động redirect non-manager về `/`
#### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`)
- Summary cards: Doanh thu, đơn hàng, lợi nhuận, giá trị đơn trung bình (so sánh kỳ trước)
- Summary cards: Doanh thu, đơn hàng, lợi nhuận, giá trị đơn trung bình (so sánh
kỳ trước)
- Bộ chọn kỳ: Ngày / Tuần / Tháng / Năm
- Biểu đồ SVG thuần: Line, Bar, Pie — hover tooltips tương tác
- Bảng top 5 sản phẩm và bảng chi tiết có thể sắp xếp
@@ -196,18 +198,18 @@ frondend/
## Công Nghệ Sử Dụng
| Công nghệ | Phiên bản | Mục đích |
| ------------ | --------- | ------------------------------------- |
| Next.js | 16.1.7 | React Framework (App Router) |
| React | 19.2.3 | Thư viện UI |
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
| Tailwind CSS | ^4.2.2 | Utility-first CSS framework |
| Geist Font | - | Font chữ (Google Fonts via next/font) |
| FontAwesome | 6.7.2 | Icon library (CDN) |
| pnpm | - | Package manager |
| ESLint | ^9 | Linting |
| Prettier | ^3 | Code formatting |
| semantic-release | ^25 | Automated versioning & release |
| Công nghệ | Phiên bản | Mục đích |
| ---------------- | --------- | ------------------------------------- |
| Next.js | 16.1.7 | React Framework (App Router) |
| React | 19.2.3 | Thư viện UI |
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
| Tailwind CSS | ^4.2.2 | Utility-first CSS framework |
| Geist Font | - | Font chữ (Google Fonts via next/font) |
| FontAwesome | 6.7.2 | Icon library (CDN) |
| pnpm | - | Package manager |
| ESLint | ^9 | Linting |
| Prettier | ^3 | Code formatting |
| semantic-release | ^25 | Automated versioning & release |
---
@@ -225,11 +227,11 @@ frondend/
### Tài Khoản Demo
| Loại tài khoản | Tên đăng nhập | Mật khẩu |
| -------------- | -------------- | -------------- |
| Manager | admin | admin |
| Staff | Nguyễn Văn An | Nguyễn Văn An |
| Customer | 0987654321 | user1 |
| Loại tài khoản | Tên đăng nhập | Mật khẩu |
| -------------- | ------------- | ------------- |
| Manager | admin | admin |
| Staff | Nguyễn Văn An | Nguyễn Văn An |
| Customer | 0987654321 | user1 |
### Design & Styling
@@ -243,14 +245,18 @@ frondend/
Dự án tuân theo **Atomic Design** pattern — chi tiết tại `Atomic.md`:
- **Atoms** - Nguyên tố cơ bản: Button, Input, Badge, Text, Heading, Divider
- **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar, PaymentSummaryCard
- **Organisms** - Phần UI phức tạp: CategorySidebar, CartFab, ProductGrid, ShopGrid, ReviewModal, analytics charts, manager tabs/modals
- **Templates** - Bố cục trang: MainLayout, AuthLayout, FeedLayout, ManagerLayout
- **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar,
PaymentSummaryCard
- **Organisms** - Phần UI phức tạp: CategorySidebar, CartFab, ProductGrid,
ShopGrid, ReviewModal, analytics charts, manager tabs/modals
- **Templates** - Bố cục trang: MainLayout, AuthLayout, FeedLayout,
ManagerLayout
### Data & Integration
- Mock data nằm trong `lib/constants.ts`
- Context providers trong `app/providers.tsx`: AuthProvider, MenuProvider, CartProvider
- Context providers trong `app/providers.tsx`: AuthProvider, MenuProvider,
CartProvider
- ManagerProvider được thêm bởi `app/(manager)/layout.tsx`
- Thay bằng API calls khi backend sẵn sàng
- Ảnh sản phẩm: thêm vào `public/imgs/products/`
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { SHOP_INFO } from "@/lib/constants";
import LoginForm from "@/components/organisms/forms/LoginForm";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
export default function LoginPage() {
@@ -28,7 +28,7 @@ export default function LoginPage() {
Đăng nhập vào hệ thống
</p>
</div>
{/* Login Form */}
<LoginForm />
+3 -6
View File
@@ -1,10 +1,10 @@
"use client";
import { SearchBar } from "@/components/molecules/search-bar";
import { CategorySidebar } from "@/components/organisms/navigation";
import { ProductGrid } from "@/components/organisms/product-grid";
import { SearchBar } from "@/components/molecules/search-bar";
import { useMenu } from "@/lib/menu-context";
import { MENU_CATEGORIES } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import { useEffect, useState } from "react";
/**
@@ -72,10 +72,7 @@ export default function Home() {
</div>
{/* ── Product grid (organism handles mobile category menu + grid) ── */}
<ProductGrid
searchQuery={searchQuery}
isSidebarOpen={isSidebarOpen}
/>
<ProductGrid searchQuery={searchQuery} isSidebarOpen={isSidebarOpen} />
</main>
</div>
);
+2 -2
View File
@@ -75,7 +75,7 @@ export default function PaymentPage() {
<div className="flex items-center gap-2">
<button
onClick={() => decreaseQty(item.id)}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Giảm số lượng ${item.name}`}
>
-
@@ -92,7 +92,7 @@ export default function PaymentPage() {
/>
<button
onClick={() => increaseQty(item.id)}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Tăng số lượng ${item.name}`}
>
+
+18 -5
View File
@@ -12,9 +12,18 @@ import Link from "next/link";
import { useState } from "react";
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",
];
function getMonday(d: Date): Date {
@@ -172,7 +181,9 @@ export default function StaffSchedulePage() {
<div className="border-t border-(--color-border-light) p-3">
<div className="flex items-center gap-3 rounded-xl p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}></i>
<i
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
></i>
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold">
@@ -212,7 +223,9 @@ export default function StaffSchedulePage() {
Đăng ca làm
</h1>
<p className="text-xs text-(--color-text-muted)">
{view === "week" ? weekLabel : `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
{view === "week"
? weekLabel
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
</p>
</div>
+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" : ""
}`}
>
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec:
containers:
- name: frontend-container
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.0
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.2
ports:
- containerPort: 3000
resources:
+34 -6
View File
@@ -1,6 +1,12 @@
"use client";
import { ReactNode, createContext, useCallback, useContext, useState } from "react";
import {
ReactNode,
createContext,
useCallback,
useContext,
useState,
} from "react";
import { MOCK_SHIFT_SLOTS } from "./constants";
import type { ShiftSlot, ShiftStatus } from "./types";
@@ -24,7 +30,11 @@ interface ShiftContextType {
goToToday: () => void;
// Shift actions
registerShift: (shiftId: string, staffId: number, staffName: string) => { success: boolean; error?: string };
registerShift: (
shiftId: string,
staffId: number,
staffName: string,
) => { success: boolean; error?: string };
unregisterShift: (shiftId: string, staffId: number) => void;
createShift: (shift: Omit<ShiftSlot, "id">) => void;
updateShift: (shift: ShiftSlot) => void;
@@ -34,7 +44,13 @@ interface ShiftContextType {
getShiftsForDate: (date: string) => ShiftSlot[];
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
getWeekDates: () => Date[];
hasConflict: (date: string, startTime: string, endTime: string, staffId: number, excludeShiftId?: string) => boolean;
hasConflict: (
date: string,
startTime: string,
endTime: string,
staffId: number,
excludeShiftId?: string,
) => boolean;
getWeeklyBudget: () => number;
}
@@ -198,7 +214,15 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
}
if (hasConflict(shift.date, shift.startTime, shift.endTime, staffId, shiftId)) {
if (
hasConflict(
shift.date,
shift.startTime,
shift.endTime,
staffId,
shiftId,
)
) {
return {
success: false,
error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.",
@@ -210,7 +234,10 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
s.id === shiftId
? {
...s,
registeredStaff: [...s.registeredStaff, { id: staffId, name: staffName }],
registeredStaff: [
...s.registeredStaff,
{ id: staffId, name: staffName },
],
status: "registered" as ShiftStatus,
}
: s,
@@ -230,7 +257,8 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
return {
...s,
registeredStaff: updated,
status: updated.length === 0 ? ("available" as ShiftStatus) : s.status,
status:
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
};
}),
);
+5 -1
View File
@@ -116,7 +116,11 @@ export interface Combo {
}
// ===== SHIFT / SCHEDULE TYPES =====
export type ShiftStatus = "available" | "registered" | "approved_leave" | "absent";
export type ShiftStatus =
| "available"
| "registered"
| "approved_leave"
| "absent";
export interface RegisteredStaff {
id: number;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "temp",
"version": "1.1.0",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "temp",
"version": "1.1.0",
"version": "1.1.2",
"dependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.37",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "temp",
"version": "1.1.0",
"version": "1.1.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -29,7 +29,7 @@
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.4",
"eslint-config-next": "16.1.7",
"prettier": "^3.8.1",
"prettier": "^3.8.2",
"prettier-plugin-embed": "^0.5.1",
"prettier-plugin-groovy": "^0.2.1",
"prettier-plugin-tailwindcss": "^0.7.2",
+228 -202
View File
File diff suppressed because it is too large Load Diff
+39 -18
View File
@@ -2,15 +2,21 @@
## Tổng Quan
Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng ký ca làm việc theo khung thời gian do quản lý tạo ra. Tính năng hỗ trợ cả hai vai trò: **Manager****Staff**, với giao diện responsive thích ứng theo kích thước màn hình.
Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng ký ca làm
việc theo khung thời gian do quản lý tạo ra. Tính năng hỗ trợ cả hai vai trò:
**Manager****Staff**, với giao diện responsive thích ứng theo kích thước màn
hình.
---
## Mục Đích
- Cho phép nhân viên xem danh sách các khung thời gian ca làm khả dụng và đăng ký vào ca mà họ muốn làm.
- Quản lý (Manager) có toàn quyền tạo, chỉnh sửa và xóa các khung thời gian ca làm.
- Nhân viên có thể thấy ca làm của đồng nghiệp để tránh xung đột hoặc điều chỉnh lịch phù hợp.
- Cho phép nhân viên xem danh sách các khung thời gian ca làm khả dụng và đăng
ký vào ca mà họ muốn làm.
- Quản lý (Manager) có toàn quyền tạo, chỉnh sửa và xóa các khung thời gian ca
làm.
- Nhân viên có thể thấy ca làm của đồng nghiệp để tránh xung đột hoặc điều chỉnh
lịch phù hợp.
- Cả Manager và Staff đều có thể chỉnh sửa hoặc hủy ca đã đăng ký của nhân viên.
---
@@ -18,6 +24,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
## Vai Trò & Phân Quyền
### Manager
- Tạo, chỉnh sửa, xóa khung thời gian ca làm (shift slots).
- Xem lịch làm việc của toàn bộ nhân viên theo tuần hoặc tháng.
- Phê duyệt hoặc từ chối yêu cầu đăng ký ca.
@@ -25,10 +32,12 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
- Gán ca làm cho nhân viên cụ thể nếu cần.
### Staff (Nhân Viên)
- Xem danh sách ca làm khả dụng trong tuần hoặc tháng.
- Đăng ký vào ca mà mình muốn làm.
- Xem ca đã được đăng ký bởi các nhân viên khác.
- Hủy hoặc chỉnh sửa ca đã đăng ký của bản thân (trong giới hạn thời gian cho phép).
- Hủy hoặc chỉnh sửa ca đã đăng ký của bản thân (trong giới hạn thời gian cho
phép).
---
@@ -36,28 +45,34 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
### Desktop (≥ 1024px) — Dạng Bảng Lịch Tuần/Tháng
- Hiển thị lịch làm việc dạng **table theo tuần** (mặc định), với khả năng chuyển sang **xem theo tháng**.
- Hiển thị lịch làm việc dạng **table theo tuần** (mặc định), với khả năng
chuyển sang **xem theo tháng**.
- Mỗi cột đại diện cho một ngày trong tuần (MonSun).
- Mỗi hàng đại diện cho một nhân viên hoặc một khung giờ.
- Các ca làm được hiển thị dưới dạng **card màu** trong ô tương ứng, gồm:
- Tên ca / Khung giờ (ví dụ: `08:00 12:00`)
- Số giờ làm & mức lương dự kiến (ví dụ: `4h · 120k`)
- Trạng thái: `Available`, `Registered`, `Approved Leave`, `Absent`
- **Bộ lọc tuần/tháng** ở góc trên, cho phép điều hướng qua lại giữa các tuần/tháng.
- **Bộ lọc tuần/tháng** ở góc trên, cho phép điều hướng qua lại giữa các
tuần/tháng.
- Hiển thị **tổng ngân sách tuần** (Weekly Budget) ở đầu bảng.
- Nhóm nhân viên theo **department/role** (ví dụ: Bar Staff, Janitors,...).
- Mỗi ca làm có thể click để xem chi tiết, chỉnh sửa hoặc xóa.
#### Chế Độ Xem Theo Tháng (Month View)
- Hiển thị dạng **calendar grid** (lưới 7 cột × ~5 hàng).
- Mỗi ô ngày hiển thị số ca đã đăng ký hoặc dấu chấm màu trạng thái (tương tự ảnh tham khảo).
- Cho phép nhân viên và manager tính toán ngày có thể nghỉ hoặc đăng ký ca xa hơn trong tháng.
- Mỗi ô ngày hiển thị số ca đã đăng ký hoặc dấu chấm màu trạng thái (tương tự
ảnh tham khảo).
- Cho phép nhân viên và manager tính toán ngày có thể nghỉ hoặc đăng ký ca xa
hơn trong tháng.
---
### Mobile (< 768px) — Dạng Lịch Dọc + Dot Indicator
- Hiển thị **calendar theo tháng dạng nhỏ gọn** (compact calendar) ở đầu màn hình.
- Hiển thị **calendar theo tháng dạng nhỏ gọn** (compact calendar) ở đầu màn
hình.
- Các ngày có ca làm được đánh dấu bằng **dot màu** phía dưới số ngày:
- 🟡 Vàng: Ca đang mở / khả dụng
- 🟢 Xanh lá: Ca đã được đăng ký
@@ -75,7 +90,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
## Trạng Thái Ca Làm (Shift Status)
| Trạng Thái | Màu | Mô Tả |
|----------------|-----------------|-----------------------------------|
| -------------- | --------------- | --------------------------------- |
| Available | Xanh dương nhạt | Ca đang mở, chưa có ai đăng ký |
| Registered | Xanh dương đậm | Nhân viên đã đăng ký ca này |
| Approved Leave | Tím/Lavender | Nhân viên đã được duyệt nghỉ phép |
@@ -86,6 +101,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
## Luồng Chức Năng Chính
### Staff — Đăng Ký Ca
1. Truy cập màn hình Register Shift.
2. Chọn tuần hoặc tháng muốn xem.
3. Xem các ca làm khả dụng (Available).
@@ -94,21 +110,26 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
6. Ca chuyển sang trạng thái **Registered**.
### Manager — Tạo Ca Làm Mới
1. Vào giao diện dashboard của manager.
2. Vào giao diện lịch của nhóm/department.
3. Click vào ô ngày muốn tạo ca.
4. Nhập thông tin: Khung giờ bắt đầu kết thúc, số lượng nhân viên cần, mức lương.
4. Nhập thông tin: Khung giờ bắt đầu kết thúc, số lượng nhân viên cần, mức
lương.
5. Lưu → Ca hiển thị trên lịch với trạng thái **Available**.
### Xóa / Chỉnh Sửa Ca
- **Manager**: Có thể chỉnh sửa hoặc xóa bất kỳ ca nào của bất kỳ nhân viên nào.
- **Staff**: Chỉ có thể hủy ca của bản thân, trong phạm vi thời gian cho phép (ví dụ: trước 24h so với giờ bắt đầu ca).
- **Staff**: Chỉ có thể hủy ca của bản thân, trong phạm vi thời gian cho phép
(ví dụ: trước 24h so với giờ bắt đầu ca).
---
## Dữ Liệu & Logic
### Shift Slot Object
```json
{
"id": "shift_001",
@@ -119,9 +140,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
"wage": 80,
"department": "Bar Staff",
"maxStaff": 2,
"registeredStaff": [
{ "id": "staff_01", "name": "Carol Saragosa" }
],
"registeredStaff": [{ "id": "staff_01", "name": "Carol Saragosa" }],
"status": "registered"
}
```
@@ -131,5 +150,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
## Ghi Chú Bổ Sung
- Nhân viên **không được đăng ký 2 ca trùng giờ** trong cùng một ngày.
- Hệ thống cần hiển thị **cảnh báo xung đột** khi nhân viên cố đăng ký ca bị trùng lịch.
- Cần hỗ trợ **thông báo (notification)** khi ca làm bị thay đổi hoặc bị hủy bởi manager.
- Hệ thống cần hiển thị **cảnh báo xung đột** khi nhân viên cố đăng ký ca bị
trùng lịch.
- Cần hỗ trợ **thông báo (notification)** khi ca làm bị thay đổi hoặc bị hủy bởi
manager.