chore: release [ci skip]
This commit is contained in:
@@ -67,13 +67,15 @@ Dành cho khách hàng:
|
|||||||
|
|
||||||
Dành cho quản lý:
|
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
|
- CRUD sản phẩm, combo, danh mục qua modals
|
||||||
- Auth guard: tự động redirect non-manager về `/`
|
- Auth guard: tự động redirect non-manager về `/`
|
||||||
|
|
||||||
#### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`)
|
#### 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
|
- 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
|
- 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
|
- 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ệ Sử Dụng
|
||||||
|
|
||||||
| Công nghệ | Phiên bản | Mục đích |
|
| Công nghệ | Phiên bản | Mục đích |
|
||||||
| ------------ | --------- | ------------------------------------- |
|
| ---------------- | --------- | ------------------------------------- |
|
||||||
| Next.js | 16.1.7 | React Framework (App Router) |
|
| Next.js | 16.1.7 | React Framework (App Router) |
|
||||||
| React | 19.2.3 | Thư viện UI |
|
| React | 19.2.3 | Thư viện UI |
|
||||||
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
|
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
|
||||||
| Tailwind CSS | ^4.2.2 | Utility-first CSS framework |
|
| Tailwind CSS | ^4.2.2 | Utility-first CSS framework |
|
||||||
| Geist Font | - | Font chữ (Google Fonts via next/font) |
|
| Geist Font | - | Font chữ (Google Fonts via next/font) |
|
||||||
| FontAwesome | 6.7.2 | Icon library (CDN) |
|
| FontAwesome | 6.7.2 | Icon library (CDN) |
|
||||||
| pnpm | - | Package manager |
|
| pnpm | - | Package manager |
|
||||||
| ESLint | ^9 | Linting |
|
| ESLint | ^9 | Linting |
|
||||||
| Prettier | ^3 | Code formatting |
|
| Prettier | ^3 | Code formatting |
|
||||||
| semantic-release | ^25 | Automated versioning & release |
|
| semantic-release | ^25 | Automated versioning & release |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,11 +227,11 @@ frondend/
|
|||||||
|
|
||||||
### Tài Khoản Demo
|
### Tài Khoản Demo
|
||||||
|
|
||||||
| Loại tài khoản | Tên đăng nhập | Mật khẩu |
|
| Loại tài khoản | Tên đăng nhập | Mật khẩu |
|
||||||
| -------------- | -------------- | -------------- |
|
| -------------- | ------------- | ------------- |
|
||||||
| Manager | admin | admin |
|
| Manager | admin | admin |
|
||||||
| Staff | Nguyễn Văn An | Nguyễn Văn An |
|
| Staff | Nguyễn Văn An | Nguyễn Văn An |
|
||||||
| Customer | 0987654321 | user1 |
|
| Customer | 0987654321 | user1 |
|
||||||
|
|
||||||
### Design & Styling
|
### Design & Styling
|
||||||
|
|
||||||
@@ -243,14 +245,18 @@ frondend/
|
|||||||
Dự án tuân theo **Atomic Design** pattern — chi tiết tại `Atomic.md`:
|
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
|
- **Atoms** - Nguyên tố cơ bản: Button, Input, Badge, Text, Heading, Divider
|
||||||
- **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar, PaymentSummaryCard
|
- **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar,
|
||||||
- **Organisms** - Phần UI phức tạp: CategorySidebar, CartFab, ProductGrid, ShopGrid, ReviewModal, analytics charts, manager tabs/modals
|
PaymentSummaryCard
|
||||||
- **Templates** - Bố cục trang: MainLayout, AuthLayout, FeedLayout, ManagerLayout
|
- **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
|
### Data & Integration
|
||||||
|
|
||||||
- Mock data nằm trong `lib/constants.ts`
|
- 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`
|
- ManagerProvider được thêm bởi `app/(manager)/layout.tsx`
|
||||||
- Thay bằng API calls khi backend sẵn sàng
|
- Thay bằng API calls khi backend sẵn sàng
|
||||||
- Ảnh sản phẩm: thêm vào `public/imgs/products/`
|
- Ảnh sản phẩm: thêm vào `public/imgs/products/`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SHOP_INFO } from "@/lib/constants";
|
|
||||||
import LoginForm from "@/components/organisms/forms/LoginForm";
|
import LoginForm from "@/components/organisms/forms/LoginForm";
|
||||||
|
import { SHOP_INFO } from "@/lib/constants";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|||||||
+3
-6
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { SearchBar } from "@/components/molecules/search-bar";
|
||||||
import { CategorySidebar } from "@/components/organisms/navigation";
|
import { CategorySidebar } from "@/components/organisms/navigation";
|
||||||
import { ProductGrid } from "@/components/organisms/product-grid";
|
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 { MENU_CATEGORIES } from "@/lib/constants";
|
||||||
|
import { useMenu } from "@/lib/menu-context";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,10 +72,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Product grid (organism handles mobile category menu + grid) ── */}
|
{/* ── Product grid (organism handles mobile category menu + grid) ── */}
|
||||||
<ProductGrid
|
<ProductGrid searchQuery={searchQuery} isSidebarOpen={isSidebarOpen} />
|
||||||
searchQuery={searchQuery}
|
|
||||||
isSidebarOpen={isSidebarOpen}
|
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function PaymentPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => decreaseQty(item.id)}
|
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}`}
|
aria-label={`Giảm số lượng ${item.name}`}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
@@ -92,7 +92,7 @@ export default function PaymentPage() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => increaseQty(item.id)}
|
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}`}
|
aria-label={`Tăng số lượng ${item.name}`}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4",
|
"Tháng 1",
|
||||||
"Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8",
|
"Tháng 2",
|
||||||
"Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12",
|
"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 {
|
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="border-t border-(--color-border-light) p-3">
|
||||||
<div className="flex items-center gap-3 rounded-xl 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)">
|
<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>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-foreground truncate text-sm font-semibold">
|
<p className="text-foreground truncate text-sm font-semibold">
|
||||||
@@ -212,7 +223,9 @@ export default function StaffSchedulePage() {
|
|||||||
Đăng ký ca làm
|
Đăng ký ca làm
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+42
-25
@@ -1,7 +1,7 @@
|
|||||||
# Components Documentation
|
# Components Documentation
|
||||||
|
|
||||||
> This project follows **Atomic Design** pattern.
|
> This project follows **Atomic Design** pattern. See `Atomic.md` at project
|
||||||
> See `Atomic.md` at project root for full structure guide.
|
> root for full structure guide.
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
@@ -19,17 +19,21 @@ components/
|
|||||||
|
|
||||||
### ProductCard (`molecules/cards/ProductCard.tsx`)
|
### 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)
|
**Atomic level:** Molecule (composed of Button, Caption, Text atoms)
|
||||||
|
|
||||||
### ShopCard (`molecules/cards/ShopCard.tsx`)
|
### 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`)
|
### 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`)
|
### 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`)
|
### 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`)
|
### 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`)
|
### 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`)
|
### 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/`)
|
### 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 |
|
| Component | File | Description |
|
||||||
|-----------|------|-------------|
|
| ------------ | ---------------- | ---------------------------------------------------------- |
|
||||||
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
|
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
|
||||||
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
|
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
|
||||||
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
|
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
|
||||||
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
|
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
|
||||||
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
|
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
|
||||||
|
|
||||||
**Usage:** Imported via `@/components/organisms/analytics` barrel index.
|
**Usage:** Imported via `@/components/organisms/analytics` barrel index.
|
||||||
|
|
||||||
@@ -75,11 +85,13 @@ components/
|
|||||||
|
|
||||||
### MainLayout (`templates/main-layout/MainLayout.tsx`)
|
### 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`)
|
### 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`)
|
### FeedLayout (`templates/feed-layout/FeedLayout.tsx`)
|
||||||
|
|
||||||
@@ -87,7 +99,8 @@ components/
|
|||||||
|
|
||||||
### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`)
|
### 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`)
|
### 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`)
|
### 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`)
|
### 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`)
|
### 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.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Main interactive button component with multiple variants and sizes.
|
|||||||
- All standard HTML button attributes
|
- All standard HTML button attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ import { Button } from "@/components/atoms";
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Styling:**
|
**Styling:**
|
||||||
|
|
||||||
- Primary: Branded color with dark hover
|
- Primary: Branded color with dark hover
|
||||||
- Secondary: Border style with light background on hover
|
- Secondary: Border style with light background on hover
|
||||||
- Danger: Red for destructive actions
|
- Danger: Red for destructive actions
|
||||||
@@ -78,6 +80,7 @@ Button designed specifically for icon-only interactions.
|
|||||||
- All standard HTML button attributes
|
- All standard HTML button attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { IconButton } from "@/components/atoms";
|
import { IconButton } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -102,6 +105,7 @@ General text input field with optional label, error, and icon.
|
|||||||
- All standard HTML input attributes
|
- All standard HTML input attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { TextInput } from "@/components/atoms";
|
import { TextInput } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ Search input with built-in search icon and clear button.
|
|||||||
- All standard HTML input attributes
|
- All standard HTML input attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { SearchInput } from "@/components/atoms";
|
import { SearchInput } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -161,6 +166,7 @@ Multi-line text input with optional label and error.
|
|||||||
- All standard HTML textarea attributes
|
- All standard HTML textarea attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Textarea } from "@/components/atoms";
|
import { Textarea } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -188,6 +194,7 @@ Semantic heading component with level-based sizing.
|
|||||||
- All standard HTML heading attributes
|
- All standard HTML heading attributes
|
||||||
|
|
||||||
**Sizing:**
|
**Sizing:**
|
||||||
|
|
||||||
- Level 1: text-3xl font-bold
|
- Level 1: text-3xl font-bold
|
||||||
- Level 2: text-2xl font-bold
|
- Level 2: text-2xl font-bold
|
||||||
- Level 3: text-xl 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
|
- Level 6: text-sm font-semibold
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Heading } from "@/components/atoms";
|
import { Heading } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -217,12 +225,14 @@ Paragraph text with semantic variants.
|
|||||||
- All standard HTML paragraph attributes
|
- All standard HTML paragraph attributes
|
||||||
|
|
||||||
**Variants:**
|
**Variants:**
|
||||||
|
|
||||||
- body1: text-base (main content)
|
- body1: text-base (main content)
|
||||||
- body2: text-sm (secondary content)
|
- body2: text-sm (secondary content)
|
||||||
- caption: text-xs (smallest text)
|
- caption: text-xs (smallest text)
|
||||||
- label: text-sm font-medium (form labels)
|
- label: text-sm font-medium (form labels)
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Text } from "@/components/atoms";
|
import { Text } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -244,6 +254,7 @@ Small caption/note text component.
|
|||||||
- All standard HTML span attributes
|
- All standard HTML span attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Caption } from "@/components/atoms";
|
import { Caption } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -268,6 +279,7 @@ Labeled badge component for highlighting information.
|
|||||||
- All standard HTML span attributes
|
- All standard HTML span attributes
|
||||||
|
|
||||||
**Variants:**
|
**Variants:**
|
||||||
|
|
||||||
- primary: Branded color
|
- primary: Branded color
|
||||||
- secondary: Light gray
|
- secondary: Light gray
|
||||||
- success: Green
|
- success: Green
|
||||||
@@ -275,6 +287,7 @@ Labeled badge component for highlighting information.
|
|||||||
- warning: Yellow
|
- warning: Yellow
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Badge } from "@/components/atoms";
|
import { Badge } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -296,6 +309,7 @@ Specialized badge for displaying formatted prices.
|
|||||||
- All standard HTML span attributes
|
- All standard HTML span attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { PriceBadge } from "@/components/atoms";
|
import { PriceBadge } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -304,6 +318,7 @@ import { PriceBadge } from "@/components/atoms";
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
- VND: "50.000 ₫"
|
- VND: "50.000 ₫"
|
||||||
- USD: "$99.99"
|
- USD: "$99.99"
|
||||||
|
|
||||||
@@ -321,6 +336,7 @@ Visual separator element.
|
|||||||
- All standard HTML hr attributes
|
- All standard HTML hr attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Divider } from "@/components/atoms";
|
import { Divider } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -414,6 +430,7 @@ import type {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Product Card
|
### Product Card
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<Heading level={3}>Product Name</Heading>
|
<Heading level={3}>Product Name</Heading>
|
||||||
@@ -426,6 +443,7 @@ import type {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Rating Display
|
### Rating Display
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<div>
|
<div>
|
||||||
<Heading level={4}>Reviews</Heading>
|
<Heading level={4}>Reviews</Heading>
|
||||||
@@ -439,6 +457,7 @@ import type {
|
|||||||
## 🧪 Testing Atoms
|
## 🧪 Testing Atoms
|
||||||
|
|
||||||
### Props Validation
|
### Props Validation
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// ✅ Valid
|
// ✅ Valid
|
||||||
<Button variant="primary" size="sm">Click</Button>
|
<Button variant="primary" size="sm">Click</Button>
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ export default function PaymentSummaryCard({
|
|||||||
isCustomer = false,
|
isCustomer = false,
|
||||||
backHref,
|
backHref,
|
||||||
}: PaymentSummaryCardProps) {
|
}: PaymentSummaryCardProps) {
|
||||||
|
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
const handlePayment = () => {
|
||||||
const handlePayment = () => {
|
|
||||||
// UI-only: open review modal after "payment"
|
// UI-only: open review modal after "payment"
|
||||||
if (isCustomer) {
|
if (isCustomer) {
|
||||||
setIsReviewOpen(true);
|
setIsReviewOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<aside className="shrink-0 xl:w-85">
|
<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">
|
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import type { ShiftSlot } from "@/lib/types";
|
|||||||
|
|
||||||
import type { ShiftCardProps } from "./ShiftCard.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: {
|
available: {
|
||||||
bg: "bg-blue-50 border-blue-200",
|
bg: "bg-blue-50 border-blue-200",
|
||||||
text: "text-blue-700",
|
text: "text-blue-700",
|
||||||
@@ -34,7 +37,11 @@ function formatWage(wage: number): string {
|
|||||||
return wage.toLocaleString("vi-VN");
|
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];
|
const style = STATUS_STYLES[shift.status];
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
@@ -86,7 +93,7 @@ export default function ShiftCard({ shift, compact = false, onClick }: ShiftCard
|
|||||||
|
|
||||||
{shift.registeredStaff.length > 0 && (
|
{shift.registeredStaff.length > 0 && (
|
||||||
<div className="mt-2 border-t border-current/10 pt-2">
|
<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})
|
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ interface BarChartProps {
|
|||||||
* Tooltip auto-flips above/below bar top to stay inside the viewBox.
|
* Tooltip auto-flips above/below bar top to stay inside the viewBox.
|
||||||
*/
|
*/
|
||||||
export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
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 W = 800;
|
||||||
const H = height;
|
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 chartW = W - padL - padR;
|
||||||
const chartH = H - padT - padB;
|
const chartH = H - padT - padB;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
|
|||||||
const [hovered, setHovered] = useState<number | null>(null);
|
const [hovered, setHovered] = useState<number | null>(null);
|
||||||
const W = 800;
|
const W = 800;
|
||||||
const H = height;
|
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 chartW = W - padL - padR;
|
||||||
const chartH = H - padT - padB;
|
const chartH = H - padT - padB;
|
||||||
|
|
||||||
@@ -66,47 +69,122 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
|
|||||||
|
|
||||||
{gridLines.map((g, i) => (
|
{gridLines.map((g, i) => (
|
||||||
<g key={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"} />
|
<line
|
||||||
<text x={padL - 6} y={g.y + 4} textAnchor="end" fontSize="10" fill="#A08060">{formatCurrency(g.val)}</text>
|
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>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<path d={areaD} fill="url(#areaGrad)" />
|
<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) =>
|
{points.map((p, i) =>
|
||||||
i % step === 0 ? (
|
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,
|
) : null,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{points.map((p) => (
|
{points.map((p) => (
|
||||||
<circle
|
<circle
|
||||||
key={p.index}
|
key={p.index}
|
||||||
cx={p.x} cy={p.y}
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
r={hovered === p.index ? 5 : 3}
|
r={hovered === p.index ? 5 : 3}
|
||||||
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
|
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
|
||||||
stroke="#FDF6EC" strokeWidth="2"
|
stroke="#FDF6EC"
|
||||||
|
strokeWidth="2"
|
||||||
style={{ cursor: "pointer", transition: "r 150ms" }}
|
style={{ cursor: "pointer", transition: "r 150ms" }}
|
||||||
onMouseEnter={() => setHovered(p.index)}
|
onMouseEnter={() => setHovered(p.index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{hovered !== null && (() => {
|
{hovered !== null &&
|
||||||
const p = points[hovered];
|
(() => {
|
||||||
const tipW = 120, tipH = 48;
|
const p = points[hovered];
|
||||||
const tipX = Math.min(Math.max(p.x - tipW / 2, padL), W - padR - tipW);
|
const tipW = 120,
|
||||||
const aboveY = p.y - tipH - 10;
|
tipH = 48;
|
||||||
const tipY = Math.min(Math.max(aboveY >= padT ? aboveY : p.y + 10, padT), padT + chartH - tipH);
|
const tipX = Math.min(
|
||||||
return (
|
Math.max(p.x - tipW / 2, padL),
|
||||||
<g>
|
W - padR - tipW,
|
||||||
<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>
|
const aboveY = p.y - tipH - 10;
|
||||||
<text x={tipX + tipW / 2} y={tipY + 30} textAnchor="middle" fontSize="11" fontWeight="600" fill="#C8973A">{formatCurrency(p.data.revenue)}</text>
|
const tipY = Math.min(
|
||||||
<text x={tipX + tipW / 2} y={tipY + 44} textAnchor="middle" fontSize="10" fill="#A08060">{p.data.orders} đơn</text>
|
Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
|
||||||
</g>
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
export interface PieSlice {
|
export interface PieSlice {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import Button from "@/components/atoms/buttons/Button";
|
||||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||||
import LoginInput from "@/components/atoms/inputs/LoginInput";
|
import LoginInput from "@/components/atoms/inputs/LoginInput";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import Button from "@/components/atoms/buttons/Button";
|
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -152,7 +152,10 @@ export default function ProductsTab() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((p) => (
|
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">
|
<td className="px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-foreground font-medium">{p.name}</p>
|
<p className="text-foreground font-medium">{p.name}</p>
|
||||||
|
|||||||
@@ -26,14 +26,27 @@ function isToday(d: Date): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4",
|
"Tháng 1",
|
||||||
"Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8",
|
"Tháng 2",
|
||||||
"Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12",
|
"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 { 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 calendarDays = useMemo(() => {
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
@@ -57,9 +70,12 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
const dateStr = formatDateISO(date);
|
const dateStr = formatDateISO(date);
|
||||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||||
const dots: string[] = [];
|
const dots: string[] = [];
|
||||||
if (dayShifts.some((s) => s.status === "available")) dots.push("bg-amber-400");
|
if (dayShifts.some((s) => s.status === "available"))
|
||||||
if (dayShifts.some((s) => s.status === "registered")) dots.push("bg-green-500");
|
dots.push("bg-amber-400");
|
||||||
if (dayShifts.some((s) => s.status === "approved_leave")) dots.push("bg-purple-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");
|
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
|
||||||
return dots;
|
return dots;
|
||||||
};
|
};
|
||||||
@@ -85,7 +101,7 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
>
|
>
|
||||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
<h3 className="text-sm font-bold text-foreground">
|
<h3 className="text-foreground text-sm font-bold">
|
||||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
@@ -101,7 +117,10 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
<div className="mb-1 grid grid-cols-7">
|
<div className="mb-1 grid grid-cols-7">
|
||||||
{DAY_HEADERS.map((day) => (
|
{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}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -133,7 +152,7 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
today
|
today
|
||||||
? "bg-(--color-primary) font-bold text-white"
|
? "bg-(--color-primary) font-bold text-white"
|
||||||
: selected
|
: selected
|
||||||
? "font-bold text-foreground"
|
? "text-foreground font-bold"
|
||||||
: "text-foreground"
|
: "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="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||||
@@ -161,7 +182,9 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
<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 */}
|
{/* Selected day shifts */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-bold text-foreground">
|
<h3 className="text-foreground mb-3 text-sm font-bold">
|
||||||
{dayOfWeek}, {selectedDateObj.getDate()}/{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
{dayOfWeek}, {selectedDateObj.getDate()}/
|
||||||
|
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
||||||
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
||||||
({selectedShifts.length} ca)
|
({selectedShifts.length} ca)
|
||||||
</span>
|
</span>
|
||||||
@@ -182,24 +206,34 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
{selectedShifts.length === 0 ? (
|
{selectedShifts.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
<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>
|
<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 có ca làm trong ngày này</p>
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
|
Không có ca làm trong ngày này
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{DEPARTMENTS.map((dept) => {
|
{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;
|
if (deptShifts.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={dept.id}>
|
<div key={dept.id}>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<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)">
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{deptShifts.map((shift) => (
|
{deptShifts.map((shift) => (
|
||||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
<ShiftCard
|
||||||
|
key={shift.id}
|
||||||
|
shift={shift}
|
||||||
|
onClick={onShiftClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 { currentDate, shifts } = useShift();
|
||||||
|
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
@@ -60,7 +63,9 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
|
|||||||
const dateStr = formatDateISO(date);
|
const dateStr = formatDateISO(date);
|
||||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||||
const available = dayShifts.filter((s) => s.status === "available").length;
|
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 leave = dayShifts.filter((s) => s.status === "approved_leave").length;
|
||||||
const absent = dayShifts.filter((s) => s.status === "absent").length;
|
const absent = dayShifts.filter((s) => s.status === "absent").length;
|
||||||
return { total: dayShifts.length, available, registered, leave, absent };
|
return { total: dayShifts.length, available, registered, leave, absent };
|
||||||
@@ -87,7 +92,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`empty-${i}`}
|
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"
|
type="button"
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onDateSelect?.(formatDateISO(date))}
|
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" : ""
|
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 ${
|
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
|
||||||
today
|
today
|
||||||
? "bg-(--color-primary) font-bold text-white"
|
? "bg-(--color-primary) font-bold text-white"
|
||||||
: "font-medium text-foreground"
|
: "text-foreground font-medium"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
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 { createShift } = useShift();
|
||||||
|
|
||||||
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
||||||
@@ -72,7 +76,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-foreground">
|
<h2 className="text-foreground text-base font-bold">
|
||||||
Tạo ca làm mới
|
Tạo ca làm mới
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
@@ -101,7 +105,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +120,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
type="time"
|
type="time"
|
||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
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>
|
||||||
<div>
|
<div>
|
||||||
@@ -128,7 +132,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
type="time"
|
type="time"
|
||||||
value={endTime}
|
value={endTime}
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +146,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
title="Department"
|
title="Department"
|
||||||
value={department}
|
value={department}
|
||||||
onChange={(e) => setDepartment(e.target.value)}
|
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) => (
|
{DEPARTMENTS.map((dept) => (
|
||||||
<option key={dept.id} value={dept.id}>
|
<option key={dept.id} value={dept.id}>
|
||||||
@@ -165,7 +169,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
max={10}
|
max={10}
|
||||||
value={maxStaff}
|
value={maxStaff}
|
||||||
onChange={(e) => setMaxStaff(Number(e.target.value))}
|
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>
|
||||||
<div>
|
<div>
|
||||||
@@ -179,7 +183,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
step={10000}
|
step={10000}
|
||||||
value={wage}
|
value={wage}
|
||||||
onChange={(e) => setWage(Number(e.target.value))}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
|
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 { user } = useAuth();
|
||||||
const { registerShift, unregisterShift, deleteShift } = useShift();
|
const { registerShift, unregisterShift, deleteShift } = useShift();
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 dept = DEPARTMENTS.find((d) => d.id === shift.department);
|
||||||
const isManager = user?.role === "manager";
|
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 isFull = shift.registeredStaff.length >= shift.maxStaff;
|
||||||
|
|
||||||
const handleRegister = () => {
|
const handleRegister = () => {
|
||||||
@@ -68,10 +74,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
className="absolute inset-0 bg-black/40"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
|
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-foreground">
|
<h2 className="text-foreground text-base font-bold">
|
||||||
Chi tiết ca làm
|
Chi tiết ca làm
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
|
<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">
|
<div className="space-y-4 px-5 py-4">
|
||||||
{/* Status badge */}
|
{/* Status badge */}
|
||||||
<div className="flex items-center gap-2">
|
<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]}
|
{statusLabel[shift.status]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,30 +113,41 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
{/* Shift info grid */}
|
{/* Shift info grid */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl bg-gray-50 p-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="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
<p className="mt-1 text-sm font-bold text-foreground">
|
Ngày
|
||||||
{new Date(shift.date + "T00:00:00").toLocaleDateString("vi-VN", {
|
</p>
|
||||||
weekday: "long",
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
day: "2-digit",
|
{new Date(shift.date + "T00:00:00").toLocaleDateString(
|
||||||
month: "2-digit",
|
"vi-VN",
|
||||||
year: "numeric",
|
{
|
||||||
})}
|
weekday: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<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="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
<p className="mt-1 text-sm font-bold text-foreground">
|
Giờ làm
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{shift.startTime} – {shift.endTime}
|
{shift.startTime} – {shift.endTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<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="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
<p className="mt-1 text-sm font-bold text-foreground">
|
Thời lượng
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{shift.durationHours} giờ
|
{shift.durationHours} giờ
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<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)">
|
<p className="mt-1 text-sm font-bold text-(--color-primary)">
|
||||||
{shift.wage.toLocaleString("vi-VN")} VND
|
{shift.wage.toLocaleString("vi-VN")} VND
|
||||||
</p>
|
</p>
|
||||||
@@ -141,10 +157,13 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
{/* Registered staff */}
|
{/* Registered staff */}
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Nhân viên đã đăng ký ({shift.registeredStaff.length}/{shift.maxStaff})
|
Nhân viên đã đăng ký ({shift.registeredStaff.length}/
|
||||||
|
{shift.maxStaff})
|
||||||
</p>
|
</p>
|
||||||
{shift.registeredStaff.length === 0 ? (
|
{shift.registeredStaff.length === 0 ? (
|
||||||
<p className="text-xs italic text-(--color-text-muted)">Chưa có ai đăng ký</p>
|
<p className="text-xs text-(--color-text-muted) italic">
|
||||||
|
Chưa có ai đăng ký
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{shift.registeredStaff.map((staff) => (
|
{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">
|
<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>
|
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-foreground text-sm font-medium">
|
||||||
{staff.name}
|
{staff.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,16 +212,19 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
|
|
||||||
{/* Footer actions */}
|
{/* Footer actions */}
|
||||||
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
|
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
|
||||||
{!isRegistered && !isFull && shift.status !== "approved_leave" && shift.status !== "absent" && (
|
{!isRegistered &&
|
||||||
<button
|
!isFull &&
|
||||||
type="button"
|
shift.status !== "approved_leave" &&
|
||||||
onClick={handleRegister}
|
shift.status !== "absent" && (
|
||||||
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"
|
<button
|
||||||
>
|
type="button"
|
||||||
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
onClick={handleRegister}
|
||||||
Đăng ký ca
|
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"
|
||||||
</button>
|
>
|
||||||
)}
|
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||||
|
Đăng ký ca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isRegistered && (
|
{isRegistered && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ import { useMemo, useState } from "react";
|
|||||||
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
const MONTH_NAMES_EN = [
|
const MONTH_NAMES_EN = [
|
||||||
"January", "February", "March", "April", "May", "June",
|
"January",
|
||||||
"July", "August", "September", "October", "November", "December",
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
];
|
];
|
||||||
|
|
||||||
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||||
@@ -40,10 +50,18 @@ export default function WeeklySchedule({
|
|||||||
onCreateShift,
|
onCreateShift,
|
||||||
mobileCalendarHeader = false,
|
mobileCalendarHeader = false,
|
||||||
}: WeeklyScheduleProps) {
|
}: WeeklyScheduleProps) {
|
||||||
const { currentDate, getWeekDates, getShiftsForDate, goToNextWeek, goToPrevWeek } = useShift();
|
const {
|
||||||
|
currentDate,
|
||||||
|
getWeekDates,
|
||||||
|
getShiftsForDate,
|
||||||
|
goToNextWeek,
|
||||||
|
goToPrevWeek,
|
||||||
|
} = useShift();
|
||||||
const weekDates = getWeekDates();
|
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 statusDotsByDate = useMemo(() => {
|
||||||
const map: Record<string, string[]> = {};
|
const map: Record<string, string[]> = {};
|
||||||
@@ -51,10 +69,14 @@ export default function WeeklySchedule({
|
|||||||
const dateStr = formatDateISO(date);
|
const dateStr = formatDateISO(date);
|
||||||
const dayShifts = getShiftsForDate(dateStr);
|
const dayShifts = getShiftsForDate(dateStr);
|
||||||
const dots: string[] = [];
|
const dots: string[] = [];
|
||||||
if (dayShifts.some((s) => s.status === "available")) dots.push("bg-sky-300");
|
if (dayShifts.some((s) => s.status === "available"))
|
||||||
if (dayShifts.some((s) => s.status === "registered")) dots.push("bg-blue-600");
|
dots.push("bg-sky-300");
|
||||||
if (dayShifts.some((s) => s.status === "approved_leave")) dots.push("bg-purple-400");
|
if (dayShifts.some((s) => s.status === "registered"))
|
||||||
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-rose-400");
|
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);
|
map[dateStr] = dots.slice(0, 3);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
@@ -106,7 +128,9 @@ export default function WeeklySchedule({
|
|||||||
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
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]}
|
{DAY_LABELS_EN[i]}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -122,7 +146,10 @@ export default function WeeklySchedule({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex min-h-2 items-center gap-0.5">
|
<div className="flex min-h-2 items-center gap-0.5">
|
||||||
{dots.map((dot, idx) => (
|
{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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -133,19 +160,30 @@ export default function WeeklySchedule({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{DEPARTMENTS.map((dept) => {
|
{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;
|
if (deptShifts.length === 0 && !onCreateShift) return null;
|
||||||
return (
|
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">
|
<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)">
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{deptShifts.map((shift) => (
|
{deptShifts.map((shift) => (
|
||||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
<ShiftCard
|
||||||
|
key={shift.id}
|
||||||
|
shift={shift}
|
||||||
|
onClick={onShiftClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{onCreateShift && (
|
{onCreateShift && (
|
||||||
<button
|
<button
|
||||||
@@ -174,13 +212,13 @@ export default function WeeklySchedule({
|
|||||||
<table className="w-full min-w-225 border-collapse">
|
<table className="w-full min-w-225 border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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
|
Bộ phận
|
||||||
</th>
|
</th>
|
||||||
{weekDates.map((date, i) => (
|
{weekDates.map((date, i) => (
|
||||||
<th
|
<th
|
||||||
key={i}
|
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)
|
isToday(date)
|
||||||
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
|
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
|
||||||
: "bg-gray-50 font-semibold text-(--color-text-muted)"
|
: "bg-gray-50 font-semibold text-(--color-text-muted)"
|
||||||
@@ -197,9 +235,11 @@ export default function WeeklySchedule({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{DEPARTMENTS.map((dept) => (
|
{DEPARTMENTS.map((dept) => (
|
||||||
<tr key={dept.id}>
|
<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">
|
<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)">
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -213,7 +253,7 @@ export default function WeeklySchedule({
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={i}
|
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" : ""
|
isToday(date) ? "bg-(--color-primary)/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: frontend-container
|
- name: frontend-container
|
||||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.0
|
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.2
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+34
-6
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"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 { MOCK_SHIFT_SLOTS } from "./constants";
|
||||||
import type { ShiftSlot, ShiftStatus } from "./types";
|
import type { ShiftSlot, ShiftStatus } from "./types";
|
||||||
@@ -24,7 +30,11 @@ interface ShiftContextType {
|
|||||||
goToToday: () => void;
|
goToToday: () => void;
|
||||||
|
|
||||||
// Shift actions
|
// 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;
|
unregisterShift: (shiftId: string, staffId: number) => void;
|
||||||
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
||||||
updateShift: (shift: ShiftSlot) => void;
|
updateShift: (shift: ShiftSlot) => void;
|
||||||
@@ -34,7 +44,13 @@ interface ShiftContextType {
|
|||||||
getShiftsForDate: (date: string) => ShiftSlot[];
|
getShiftsForDate: (date: string) => ShiftSlot[];
|
||||||
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
||||||
getWeekDates: () => Date[];
|
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;
|
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." };
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.",
|
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.id === shiftId
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
registeredStaff: [...s.registeredStaff, { id: staffId, name: staffName }],
|
registeredStaff: [
|
||||||
|
...s.registeredStaff,
|
||||||
|
{ id: staffId, name: staffName },
|
||||||
|
],
|
||||||
status: "registered" as ShiftStatus,
|
status: "registered" as ShiftStatus,
|
||||||
}
|
}
|
||||||
: s,
|
: s,
|
||||||
@@ -230,7 +257,8 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
registeredStaff: updated,
|
registeredStaff: updated,
|
||||||
status: updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
status:
|
||||||
|
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-1
@@ -116,7 +116,11 @@ export interface Combo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== SHIFT / SCHEDULE TYPES =====
|
// ===== SHIFT / SCHEDULE TYPES =====
|
||||||
export type ShiftStatus = "available" | "registered" | "approved_leave" | "absent";
|
export type ShiftStatus =
|
||||||
|
| "available"
|
||||||
|
| "registered"
|
||||||
|
| "approved_leave"
|
||||||
|
| "absent";
|
||||||
|
|
||||||
export interface RegisteredStaff {
|
export interface RegisteredStaff {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@types/node": "^20.19.37",
|
"@types/node": "^20.19.37",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.1.7",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.2",
|
||||||
"prettier-plugin-embed": "^0.5.1",
|
"prettier-plugin-embed": "^0.5.1",
|
||||||
"prettier-plugin-groovy": "^0.2.1",
|
"prettier-plugin-groovy": "^0.2.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
|||||||
Generated
+228
-202
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,21 @@
|
|||||||
|
|
||||||
## Tổng Quan
|
## 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** và **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** và **Staff**, với giao diện responsive thích ứng theo kích thước màn
|
||||||
|
hình.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mục Đích
|
## 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.
|
- 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
|
||||||
- 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.
|
ký vào ca mà họ muốn 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.
|
- 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.
|
- 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
|
## Vai Trò & Phân Quyền
|
||||||
|
|
||||||
### Manager
|
### Manager
|
||||||
|
|
||||||
- Tạo, chỉnh sửa, xóa khung thời gian ca làm (shift slots).
|
- 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.
|
- 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.
|
- 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.
|
- Gán ca làm cho nhân viên cụ thể nếu cần.
|
||||||
|
|
||||||
### Staff (Nhân Viên)
|
### Staff (Nhân Viên)
|
||||||
|
|
||||||
- Xem danh sách ca làm khả dụng trong tuần hoặc tháng.
|
- 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.
|
- Đă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.
|
- 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
|
### 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 (Mon–Sun).
|
- Mỗi cột đại diện cho một ngày trong tuần (Mon–Sun).
|
||||||
- Mỗi hàng đại diện cho một nhân viên hoặc một khung giờ.
|
- 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:
|
- 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`)
|
- 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`)
|
- Số giờ làm & mức lương dự kiến (ví dụ: `4h · 120k`)
|
||||||
- Trạng thái: `Available`, `Registered`, `Approved Leave`, `Absent`
|
- 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.
|
- 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,...).
|
- 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.
|
- 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)
|
#### Chế Độ Xem Theo Tháng (Month View)
|
||||||
|
|
||||||
- Hiển thị dạng **calendar grid** (lưới 7 cột × ~5 hàng).
|
- 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).
|
- 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ự
|
||||||
- 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.
|
ả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
|
### 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:
|
- 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
|
- 🟡 Vàng: Ca đang mở / khả dụng
|
||||||
- 🟢 Xanh lá: Ca đã được đăng ký
|
- 🟢 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 Ca Làm (Shift Status)
|
||||||
|
|
||||||
| Trạng Thái | Màu | Mô Tả |
|
| Trạng Thái | Màu | Mô Tả |
|
||||||
|----------------|-----------------|-----------------------------------|
|
| -------------- | --------------- | --------------------------------- |
|
||||||
| Available | Xanh dương nhạt | Ca đang mở, chưa có ai đăng ký |
|
| 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 |
|
| 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 |
|
| 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
|
## Luồng Chức Năng Chính
|
||||||
|
|
||||||
### Staff — Đăng Ký Ca
|
### Staff — Đăng Ký Ca
|
||||||
|
|
||||||
1. Truy cập màn hình Register Shift.
|
1. Truy cập màn hình Register Shift.
|
||||||
2. Chọn tuần hoặc tháng muốn xem.
|
2. Chọn tuần hoặc tháng muốn xem.
|
||||||
3. Xem các ca làm khả dụng (Available).
|
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**.
|
6. Ca chuyển sang trạng thái **Registered**.
|
||||||
|
|
||||||
### Manager — Tạo Ca Làm Mới
|
### Manager — Tạo Ca Làm Mới
|
||||||
|
|
||||||
1. Vào giao diện dashboard của manager.
|
1. Vào giao diện dashboard của manager.
|
||||||
2. Vào giao diện lịch của nhóm/department.
|
2. Vào giao diện lịch của nhóm/department.
|
||||||
3. Click vào ô ngày muốn tạo ca.
|
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**.
|
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
|
### 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.
|
- **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
|
## Dữ Liệu & Logic
|
||||||
|
|
||||||
### Shift Slot Object
|
### Shift Slot Object
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "shift_001",
|
"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,
|
"wage": 80,
|
||||||
"department": "Bar Staff",
|
"department": "Bar Staff",
|
||||||
"maxStaff": 2,
|
"maxStaff": 2,
|
||||||
"registeredStaff": [
|
"registeredStaff": [{ "id": "staff_01", "name": "Carol Saragosa" }],
|
||||||
{ "id": "staff_01", "name": "Carol Saragosa" }
|
|
||||||
],
|
|
||||||
"status": "registered"
|
"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
|
## 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.
|
- 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.
|
- Hệ thống cần hiển thị **cảnh báo xung đột** khi nhân viên cố đăng ký ca bị
|
||||||
- Cần hỗ trợ **thông báo (notification)** khi ca làm bị thay đổi hoặc bị hủy bởi manager.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user