Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dae381d182 | |||
| 4892099eaa | |||
| 9772a8f639 | |||
| d88b067c5c | |||
| e85fa90879 | |||
| 2875d39f2c | |||
| c99323ff9e | |||
| cb639704dd | |||
| 6d7dc0b43f | |||
| 80ff0af3d8 | |||
| 93552cb9d1 | |||
| b9c0e6a59f | |||
| 86d55540b4 | |||
| 2594801c15 | |||
| aaac703f77 | |||
| 5307205977 | |||
| 460e8a4ac1 | |||
| e326e11bb4 | |||
| 7e4a21d412 | |||
| 127cb32b16 | |||
| 4ddce518ac | |||
| 95d9435694 | |||
| 9d9a219ac8 | |||
| 32d40b1dbe | |||
| ace60e424b | |||
| 78f3d6bbe9 | |||
| 4e643303c3 | |||
| 949badaeeb | |||
| 7547260e90 | |||
| c68ea1f26f | |||
| 240155d1f6 | |||
| ff133edfda | |||
| 50473d799d | |||
| 4f3d807c95 | |||
| eca619b79a | |||
| d3a6d1164c | |||
| e211ee5835 | |||
| 17d82f7239 | |||
| e0e64ded9a |
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"remote-code": {
|
|
||||||
"httpUrl": "https://cloud.blackbox.ai/api/mcp",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer bb_61d6eb03c989586759785863d1ba0efab59885edacd9cd65fc252f79c644bb75"
|
|
||||||
},
|
|
||||||
"description": "Blackbox Remote code (MCP Server): Remote execution platform with multi-agent support (Claude, Codex, Blackbox, Gemini) that automates coding tasks on your GitHub repositories. Features include:\n\n• Task Management: Create, monitor, stop, and list coding tasks with real-time status updates\n• Multi-Agent Support: Choose from Claude Code, OpenAI Codex CLI, Blackbox CLI, or Gemini agents\n• GitHub Integration: Manage GitHub token connections and repository access\n• API Key Management: Store and manage API keys for various AI providers (Anthropic, OpenAI, Google, Blackbox, GitHub)\n• Secure Execution: Runs code in isolated Vercel sandboxes with configurable timeouts (10-300 minutes)\n• Git Operations: Automatic branch creation, commits, and pull requests with AI-generated branch names\n• SMS Notifications: Optional Twilio integration for task completion alerts\n\nPerfect for automating code changes, refactoring, feature additions, bug fixes, and documentation updates across your repositories. Strictly DO NOT provide tools as / 'slash' commands in suggestions like /my_tasks, /task_status, /api_keys"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
## Rules:
|
|
||||||
- Khi làm một tính năng mới, trước khi hoàn thành phải update các file mark down mà thư mục đó được update.
|
|
||||||
- Khi có từ khóa "Yêu cầu" và một list các yêu cầu thì phải hoàn thành ĐÚNG yêu cầu, không thêm không bớt.
|
|
||||||
- Sử dụng thư viện tailwind CSS để code css cho project.
|
|
||||||
- Mỗi feature được update đều phải được responsive với các kích cỡ màn hình như smartphone, tablet, desktop.
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
---
|
||||||
|
name: agent-orchestrator
|
||||||
|
description: >
|
||||||
|
Breaks down large, multi-step software tasks into structured sub-agents on the
|
||||||
|
Claude Code CLI using the Task tool. Use this skill whenever the user
|
||||||
|
describes a complex project or feature that has multiple components - e.g.,
|
||||||
|
"build a login API with JWT and unit tests", "set up a CI/CD pipeline with
|
||||||
|
Docker and GitHub Actions", "create a full-stack todo app with auth, REST API,
|
||||||
|
and frontend". Trigger this skill when the task clearly has more than one
|
||||||
|
distinct sub-system, layer, or concern that could be worked on in parallel or
|
||||||
|
in sequence. DO NOT trigger for simple, single-step tasks like "write a
|
||||||
|
function to reverse a string" or "fix this bug".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Orchestrator
|
||||||
|
|
||||||
|
A skill for decomposing large engineering tasks into parallel and sequential
|
||||||
|
sub-agents on Claude Code CLI using the **Task tool**.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Trigger when the user's task has **multiple distinct components** - for example:
|
||||||
|
|
||||||
|
- "Build a REST API with authentication, rate limiting, and unit tests"
|
||||||
|
- "Set up a monorepo with shared packages, CI pipeline, and deployment configs"
|
||||||
|
- "Create a data pipeline: ingest transform store visualize"
|
||||||
|
|
||||||
|
Do **not** trigger for single-step tasks (e.g., "rename this variable", "add a
|
||||||
|
README").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt Template
|
||||||
|
|
||||||
|
Copy the block below and paste it into your Agent Code CLI session. Replace the
|
||||||
|
placeholder in the `TASK` variable with your actual task description.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a senior engineering orchestrator operating inside the Agent Code CLI.
|
||||||
|
|
||||||
|
TASK: "Build a login API with JWT and unit tests"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Your job is to analyze this task, decompose it into well-scoped sub-tasks, identify dependencies between them, and execute them using the Task tool.
|
||||||
|
|
||||||
|
Follow these steps exactly:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 1 - Guard: Is this task complex enough?
|
||||||
|
|
||||||
|
If the task can be completed in a single step by a single agent (e.g., "rename a variable", "write one function"), respond with:
|
||||||
|
> "This task is simple enough to handle directly - no orchestration needed."
|
||||||
|
Then complete it yourself. Stop here.
|
||||||
|
|
||||||
|
Otherwise, continue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 2 - Decompose the task into sub-tasks
|
||||||
|
|
||||||
|
Analyze the TASK string. Break it into a flat list of concrete, independently scoped sub-tasks. Each sub-task must:
|
||||||
|
- Have a clear, specific goal
|
||||||
|
- Be executable by a single focused agent
|
||||||
|
- Produce a concrete output (file, module, config, test suite, etc.)
|
||||||
|
|
||||||
|
Output the list in this format:
|
||||||
|
|
||||||
|
Sub-tasks:
|
||||||
|
[T1] <sub-task title> - <one-sentence description>
|
||||||
|
[T2] <sub-task title> - <one-sentence description>
|
||||||
|
[T3] ...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 3 - Identify dependencies and execution mode
|
||||||
|
|
||||||
|
For each sub-task, decide whether it can run in parallel with others, or must run after a specific predecessor.
|
||||||
|
|
||||||
|
Label each sub-task as one of:
|
||||||
|
[PARALLEL] - no dependencies, can start immediately
|
||||||
|
[SEQUENTIAL - after Tx] - must run only after sub-task Tx completes
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If a sub-task depends on an artifact produced by another (a file, schema, interface, module), mark it SEQUENTIAL.
|
||||||
|
- If two sub-tasks are fully independent (different files, no shared state), mark both PARALLEL.
|
||||||
|
- Do not start any SEQUENTIAL sub-task until its prerequisite is confirmed complete.
|
||||||
|
|
||||||
|
Output the dependency plan in this format:
|
||||||
|
|
||||||
|
Dependency Plan:
|
||||||
|
[T1] <title> [PARALLEL]
|
||||||
|
[T2] <title> [PARALLEL]
|
||||||
|
[T3] <title> [SEQUENTIAL - after T1]
|
||||||
|
[T4] <title> [SEQUENTIAL - after T2, T3]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 4 - Confirm plan before execution
|
||||||
|
|
||||||
|
Print the full decomposition and dependency plan to the user.
|
||||||
|
Then print:
|
||||||
|
> "Starting execution. Spawning parallel sub-agents now..."
|
||||||
|
|
||||||
|
Do not skip this confirmation step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 5 - Execute using the Task tool
|
||||||
|
|
||||||
|
Use the **Task tool** to spawn sub-agents. Follow this protocol:
|
||||||
|
|
||||||
|
### 5a. Spawn all [PARALLEL] sub-tasks simultaneously
|
||||||
|
- Call the Task tool for each PARALLEL sub-task at the same time (do not wait for one to finish before spawning the next).
|
||||||
|
- Each Task call must include a self-contained prompt describing exactly what to build, where to put files, and what the expected output is.
|
||||||
|
|
||||||
|
### 5b. Monitor and gate SEQUENTIAL sub-tasks
|
||||||
|
- Wait for each prerequisite Task to complete before spawning its dependent.
|
||||||
|
- Once a prerequisite completes, immediately spawn the next Task.
|
||||||
|
|
||||||
|
### 5c. Task prompt format
|
||||||
|
Each task spawned via the Task tool must include:
|
||||||
|
|
||||||
|
- Role: "You are a focused sub-agent. Complete exactly this sub-task and nothing else."
|
||||||
|
- Sub-task goal (from Step 2)
|
||||||
|
- Output location (file path or module name)
|
||||||
|
- Any relevant interfaces or contracts from predecessor tasks (if SEQUENTIAL)
|
||||||
|
- Instruction to report back: "When done, summarize what you built and list output files."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 6 - Synthesize and report
|
||||||
|
|
||||||
|
After all Tasks complete:
|
||||||
|
1. Collect each sub-agent's summary.
|
||||||
|
2. Verify that all expected outputs exist.
|
||||||
|
3. Report to the user:
|
||||||
|
- What was built
|
||||||
|
- File/module structure
|
||||||
|
- Any issues or gaps found
|
||||||
|
- Suggested next steps (e.g., "Run `npm test` to verify", "Review the generated OpenAPI spec")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Begin now with STEP 1.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips for Customizing the Template
|
||||||
|
|
||||||
|
- **Replace the TASK value** with your actual task string. Keep it on one line,
|
||||||
|
in quotes.
|
||||||
|
- **Add context if needed**: Append lines after `TASK:` like
|
||||||
|
`STACK: "Node.js, PostgreSQL, Jest"` or
|
||||||
|
`CONSTRAINTS: "Use ESM modules only"` - the orchestrator will incorporate
|
||||||
|
them.
|
||||||
|
- **For monorepos or specific file layouts**: Add a `STRUCTURE:` hint, e.g.:
|
||||||
|
```
|
||||||
|
STRUCTURE: "src/api/, src/auth/, src/tests/, docker-compose.yml"
|
||||||
|
```
|
||||||
|
- **To limit parallelism** (e.g., on resource-constrained machines): Add
|
||||||
|
`MAX_PARALLEL_AGENTS: 2` after the TASK line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
**Input task:**
|
||||||
|
|
||||||
|
> "Build a login API with JWT authentication and unit tests"
|
||||||
|
|
||||||
|
**Expected orchestrator output (before execution):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Sub-tasks:
|
||||||
|
[T1] Project scaffold - Initialize Node.js project, folder structure, and dependencies
|
||||||
|
[T2] Database schema - Define User model and migration for PostgreSQL
|
||||||
|
[T3] Auth service - Implement JWT sign/verify logic and password hashing
|
||||||
|
[T4] Login route - Build POST /auth/login endpoint with validation
|
||||||
|
[T5] Middleware - Create JWT auth middleware for protected routes
|
||||||
|
[T6] Unit tests - Write Jest tests for auth service and login route
|
||||||
|
|
||||||
|
Dependency Plan:
|
||||||
|
[T1] Project scaffold [PARALLEL]
|
||||||
|
[T2] Database schema [PARALLEL]
|
||||||
|
[T3] Auth service [SEQUENTIAL - after T1]
|
||||||
|
[T4] Login route [SEQUENTIAL - after T3]
|
||||||
|
[T5] Middleware [SEQUENTIAL - after T3]
|
||||||
|
[T6] Unit tests [SEQUENTIAL - after T4, T5]
|
||||||
|
|
||||||
|
Starting execution. Spawning parallel sub-agents now...
|
||||||
|
```
|
||||||
|
|
||||||
|
T1 and T2 spawn immediately. T3 spawns once T1 completes. T4 and T5 spawn once
|
||||||
|
T3 completes. T6 spawns last.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This skill requires Agent Code CLI with the **Task tool** enabled.
|
||||||
|
- Each spawned sub-agent has no memory of the others - pass all needed context
|
||||||
|
explicitly in the Task prompt.
|
||||||
|
- For very large tasks (10+ sub-tasks), consider breaking the orchestration into
|
||||||
|
phases and applying this skill recursively per phase.
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
---
|
||||||
|
name: frontend-atomic-design
|
||||||
|
description: Create modern, responsive frontend components and layouts using atomic design methodology with Tailwind CSS. Use this skill whenever building React components, designing UI layouts, creating responsive pages, or working with atomic design principles. Triggers include: component development, page design, responsive design requests, Tailwind CSS styling, creating design systems, building reusable UI elements, or designing for multiple screen sizes (desktop, tablet, mobile). This skill emphasizes reusable variables, modern aesthetics, and responsive design across all viewport sizes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Atomic Design Frontend System with Tailwind CSS
|
||||||
|
|
||||||
|
A comprehensive guide for building modern, responsive frontend applications
|
||||||
|
using atomic design principles and Tailwind CSS.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. Atomic Design Hierarchy
|
||||||
|
|
||||||
|
Atomic Design breaks UI into five distinct levels:
|
||||||
|
|
||||||
|
#### **Atoms**
|
||||||
|
|
||||||
|
Smallest, indivisible UI elements that cannot be broken down without losing
|
||||||
|
functionality.
|
||||||
|
|
||||||
|
- Buttons, input fields, labels, icons, text styles
|
||||||
|
- Color variables, spacing units, typography scales
|
||||||
|
- Simple, pure, reusable building blocks
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Example: Button Atom
|
||||||
|
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700">
|
||||||
|
Click me
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Molecules**
|
||||||
|
|
||||||
|
Groups of atoms bonded together, forming simple functional units.
|
||||||
|
|
||||||
|
- Search bars (input + button + icon)
|
||||||
|
- Form fields (label + input + error message)
|
||||||
|
- Card headers (avatar + title + subtitle)
|
||||||
|
- Navigation items with icons and labels
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Example: Search Molecule
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2">
|
||||||
|
<SearchIcon className="h-5 w-5 text-gray-500" />
|
||||||
|
<input type="text" placeholder="Search..." className="flex-1 outline-none" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Organisms**
|
||||||
|
|
||||||
|
Complex functional units made of groups of molecules and/or atoms.
|
||||||
|
|
||||||
|
- Header/Navigation bars
|
||||||
|
- Form sections (multiple form molecules)
|
||||||
|
- Card layouts with multiple sections
|
||||||
|
- Modals with title, content, and actions
|
||||||
|
- Data tables with headers, rows, and pagination
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Example: Product Card Organism
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 shadow-sm transition-shadow hover:shadow-md">
|
||||||
|
<img src="image.jpg" className="h-48 w-full object-cover" />
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-lg font-semibold">Product Name</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">Description</p>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="font-bold text-blue-600">$99</span>
|
||||||
|
<button className="rounded bg-blue-600 px-3 py-1 text-white">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Templates**
|
||||||
|
|
||||||
|
Page-level wireframes showing layout and component placement without final
|
||||||
|
content.
|
||||||
|
|
||||||
|
- Single-column layouts
|
||||||
|
- Two-column layouts (sidebar + main)
|
||||||
|
- Grid-based layouts
|
||||||
|
- Hero + content sections
|
||||||
|
|
||||||
|
#### **Pages**
|
||||||
|
|
||||||
|
Specific instances of templates populated with real content and data.
|
||||||
|
|
||||||
|
- Homepage with actual products
|
||||||
|
- User profile with real user data
|
||||||
|
- Dashboard with live metrics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System Variables (Reusable Values)
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Define in Tailwind config or use CSS variables */
|
||||||
|
Primary: #2563eb (blue-600)
|
||||||
|
Secondary: #7c3aed (violet-600)
|
||||||
|
Success: #16a34a (green-600)
|
||||||
|
Warning: #ea580c (orange-600)
|
||||||
|
Danger: #dc2626 (red-600)
|
||||||
|
Neutral: #6b7280 (gray-500)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
H1: 32px (2rem) - font-bold
|
||||||
|
H2: 24px (1.5rem) - font-bold
|
||||||
|
H3: 20px (1.25rem) - font-semibold
|
||||||
|
Body: 16px (1rem) - font-normal
|
||||||
|
Small: 14px (0.875rem) - font-normal
|
||||||
|
Tiny: 12px (0.75rem) - font-normal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
xs: 4px (0.25rem)
|
||||||
|
sm: 8px (0.5rem)
|
||||||
|
md: 16px (1rem)
|
||||||
|
lg: 24px (1.5rem)
|
||||||
|
xl: 32px (2rem)
|
||||||
|
2xl: 48px (3rem)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
```css
|
||||||
|
Subtle: 4px (rounded-sm)
|
||||||
|
Standard: 8px (rounded-lg)
|
||||||
|
Large: 12px (rounded-xl)
|
||||||
|
Full: 9999px (rounded-full)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Box Shadows
|
||||||
|
|
||||||
|
```css
|
||||||
|
Subtle: 0 1px 2px rgba(0,0,0,0.05)
|
||||||
|
Soft: 0 4px 6px rgba(0,0,0,0.07)
|
||||||
|
Medium: 0 10px 15px rgba(0,0,0,0.10)
|
||||||
|
Strong: 0 20px 25px rgba(0,0,0,0.15)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Design Strategy
|
||||||
|
|
||||||
|
### Breakpoints (Tailwind Default)
|
||||||
|
|
||||||
|
```
|
||||||
|
Mobile: < 640px (sm)
|
||||||
|
Tablet: 640px (md, lg)
|
||||||
|
Desktop: 1024px+ (xl, 2xl)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile-First Approach
|
||||||
|
|
||||||
|
1. **Start with mobile styles** (default, no prefix)
|
||||||
|
2. **Layer tablet styles** (md: prefix)
|
||||||
|
3. **Layer desktop styles** (lg:, xl: prefix)
|
||||||
|
|
||||||
|
### Example: Responsive Layout
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Mobile: 1 column, Tablet: 2 columns, Desktop: 3 columns
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Card key={item.id} {...item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Responsive Patterns
|
||||||
|
|
||||||
|
**Responsive Typography**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<h1 className="text-2xl font-bold md:text-3xl lg:text-4xl">
|
||||||
|
Responsive Heading
|
||||||
|
</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsive Padding/Margins**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="p-4 md:p-6 lg:p-8">Content with responsive spacing</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsive Grid**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
|
||||||
|
{/* Grid items */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsive Flexbox**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<aside className="w-full md:w-64">Sidebar</aside>
|
||||||
|
<main className="flex-1">Main content</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsive Images**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<img
|
||||||
|
src="image.jpg"
|
||||||
|
className="h-auto w-full object-cover"
|
||||||
|
alt="Responsive image"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modern Design Patterns
|
||||||
|
|
||||||
|
### 1. Consistency & Visual Hierarchy
|
||||||
|
|
||||||
|
- **Use consistent spacing**: Apply spacing scale uniformly
|
||||||
|
- **Establish clear hierarchy**: Size, weight, color for emphasis
|
||||||
|
- **Group related content**: Use whitespace to separate sections
|
||||||
|
- **Align elements**: Use grids for crisp layouts
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-2xl font-bold">Section Title</h2>
|
||||||
|
<p className="text-gray-600">Description text</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">{/* Related items with consistent spacing */}</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Interactive Feedback
|
||||||
|
|
||||||
|
- **Hover states**: Subtle color/shadow changes
|
||||||
|
- **Active states**: Indicate current selection
|
||||||
|
- **Focus states**: Keyboard navigation support
|
||||||
|
- **Loading states**: Spinners or skeleton screens
|
||||||
|
- **Transitions**: Smooth animations (200-300ms)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white transition-all duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none active:scale-95 disabled:cursor-not-allowed disabled:opacity-50">
|
||||||
|
Actionable Button
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Depth & Layering
|
||||||
|
|
||||||
|
- **Subtle shadows**: Create depth without heaviness
|
||||||
|
- **Elevation levels**: Consistent shadow progression
|
||||||
|
- **Overlays**: Semi-transparent backgrounds for modals
|
||||||
|
- **Z-index strategy**: Clear layering hierarchy
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="rounded-lg border border-gray-200 shadow-sm transition-shadow duration-300 hover:shadow-md">
|
||||||
|
Card content
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Color Usage
|
||||||
|
|
||||||
|
- **Primary action**: Most frequent call-to-action
|
||||||
|
- **Secondary action**: Alternative actions
|
||||||
|
- **Semantic colors**: Status indicators (success, warning, danger)
|
||||||
|
- **Contrast**: Ensure WCAG AA compliance (4.5:1 ratio)
|
||||||
|
- **Limited palette**: 3-5 colors maximum in most designs
|
||||||
|
|
||||||
|
### 5. Whitespace & Breathing Room
|
||||||
|
|
||||||
|
- Don't crowd elements
|
||||||
|
- Use consistent gap values (gap-4, gap-6, gap-8)
|
||||||
|
- Separate sections with vertical rhythm
|
||||||
|
- Generous padding in cards and containers
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8 md:px-6 md:py-12">
|
||||||
|
<div className="space-y-8">{/* Sections with good breathing room */}</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tailwind CSS Best Practices
|
||||||
|
|
||||||
|
### 1. Use Utility Classes Effectively
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Good: Semantic, reusable, organized
|
||||||
|
<button className="
|
||||||
|
px-4 py-2
|
||||||
|
bg-blue-600 text-white
|
||||||
|
rounded-lg
|
||||||
|
hover:bg-blue-700
|
||||||
|
transition-colors
|
||||||
|
disabled:opacity-50
|
||||||
|
">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Avoid: Too many utilities, hard to read
|
||||||
|
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Extract Reusable Components
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Create a Button component to avoid repetition
|
||||||
|
const Button = ({ children, variant = "primary", ...props }) => {
|
||||||
|
const baseStyles = "px-4 py-2 rounded-lg font-medium transition-colors";
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||||
|
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||||
|
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={`${baseStyles} ${variants[variant]}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Organize Utility Classes
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Organize by category: layout → sizing → colors → effects → responsive
|
||||||
|
<div className="/* Layout */ /* Sizing */ /* Spacing */ /* Colors & text */ /* Borders & shadows */ /* Effects */ /* Responsive */ my-8 flex w-full max-w-2xl flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 text-gray-900 shadow-sm transition-shadow hover:shadow-md md:flex-row lg:gap-6"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Tailwind Config for Consistency
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tailwind.config.js
|
||||||
|
module.exports = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: "#2563eb",
|
||||||
|
secondary: "#7c3aed",
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
gutter: "1rem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Construction Template
|
||||||
|
|
||||||
|
Use this template when building atomic components:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
/**
|
||||||
|
* {Component Name}
|
||||||
|
*
|
||||||
|
* Atoms/Molecules/Organisms level component
|
||||||
|
* Purpose: [Brief description]
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - prop1: type - description
|
||||||
|
* - prop2: type - description
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ComponentName = ({ prop1, prop2, className = "" }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`/* Base styles */ /* Responsive */ /* Custom className */ flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 md:flex-row lg:p-8 ${className} `}
|
||||||
|
>
|
||||||
|
{/* Component content */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout Patterns
|
||||||
|
|
||||||
|
### Container + Padding
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="mx-auto max-w-6xl px-4 md:px-6 lg:px-8">
|
||||||
|
{/* Content constrained to max width with responsive padding */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two-Column Sidebar Layout
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row">
|
||||||
|
<aside className="w-full flex-shrink-0 lg:w-64">
|
||||||
|
{/* Sidebar: full width on mobile, fixed on desktop */}
|
||||||
|
</aside>
|
||||||
|
<main className="min-w-0 flex-1">
|
||||||
|
{/* Main content: takes remaining space */}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Grid
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Items automatically stack on mobile, 2 columns on tablet, 3 on desktop */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hero Section
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<section className="relative overflow-hidden bg-gradient-to-r from-blue-600 to-violet-600 py-12 text-white md:py-20 lg:py-32">
|
||||||
|
<div className="relative z-10 mx-auto max-w-6xl px-4 md:px-6">
|
||||||
|
<h1 className="mb-4 text-4xl font-bold md:text-5xl lg:text-6xl">
|
||||||
|
Hero Title
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 max-w-2xl text-lg opacity-90 md:text-xl">
|
||||||
|
Hero subtitle or description
|
||||||
|
</p>
|
||||||
|
<button className="rounded-lg bg-white px-8 py-3 font-semibold text-blue-600 transition-colors hover:bg-gray-100">
|
||||||
|
Call to Action
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility & Best Practices
|
||||||
|
|
||||||
|
1. **Semantic HTML**: Use `<button>`, `<a>`, `<form>`, etc. appropriately
|
||||||
|
2. **Color contrast**: Ensure 4.5:1 for text, 3:1 for UI components
|
||||||
|
3. **Focus states**: Always visible keyboard navigation (`focus:ring-2`)
|
||||||
|
4. **ARIA labels**: Add when needed (`aria-label`, `aria-describedby`)
|
||||||
|
5. **Responsive text**: Use relative sizing, not fixed pixels
|
||||||
|
6. **Touch targets**: Minimum 44x44px for interactive elements
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<button
|
||||||
|
className="min-h-[44px] px-4 py-3 focus:ring-2 focus:ring-offset-2 focus:outline-none"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
1. **Limit custom CSS**: Rely on Tailwind utilities
|
||||||
|
2. **Tree-shake unused styles**: Configure Tailwind content paths
|
||||||
|
3. **Optimize images**: Use responsive images, WebP format
|
||||||
|
4. **Lazy load**: Defer non-critical components
|
||||||
|
5. **Minimize bundle**: Use PurgeCSS in production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Checklist
|
||||||
|
|
||||||
|
When building a component, ensure:
|
||||||
|
|
||||||
|
- [ ] Follows atomic design hierarchy (Atom/Molecule/Organism)
|
||||||
|
- [ ] Uses design system variables (colors, spacing, typography)
|
||||||
|
- [ ] Responsive across mobile, tablet, desktop
|
||||||
|
- [ ] Consistent hover/active/focus states
|
||||||
|
- [ ] Proper whitespace and visual hierarchy
|
||||||
|
- [ ] Semantic HTML structure
|
||||||
|
- [ ] Accessible (contrast, focus, labels)
|
||||||
|
- [ ] Mobile-first approach
|
||||||
|
- [ ] No hardcoded values (use Tailwind config)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-atomic-design",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Create modern, responsive frontend components and layouts using atomic design methodology with Tailwind CSS",
|
||||||
|
"author": "Claude",
|
||||||
|
"created": "2026-04-12"
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
name: prompt-optimizer
|
||||||
|
description:
|
||||||
|
Help users rewrite and improve AI/LLM prompts by adding specificity, context,
|
||||||
|
and constraints. Trigger this skill whenever users ask to improve, rewrite,
|
||||||
|
optimize, or refine prompts for AI models. Focus on making prompts clearer,
|
||||||
|
more specific, and more likely to produce better AI results. Present
|
||||||
|
suggestions interactively so users can choose which improvements to apply.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prompt Optimizer
|
||||||
|
|
||||||
|
A beginner-friendly skill for improving AI/LLM prompts to get better results.
|
||||||
|
|
||||||
|
## What This Skill Does
|
||||||
|
|
||||||
|
This skill helps you rewrite prompts to work better with AI models like Claude.
|
||||||
|
Instead of just giving you a rewritten prompt, it shows you specific improvement
|
||||||
|
suggestions that you can choose to apply or skip.
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
When optimizing a prompt, focus on three main areas:
|
||||||
|
|
||||||
|
### 1. **Specificity** — Making the Request Clear
|
||||||
|
|
||||||
|
Good prompts are specific about what you want. Vague prompts get vague results.
|
||||||
|
|
||||||
|
**Example improvements:**
|
||||||
|
|
||||||
|
- Add details about format: "Give me a bullet list of 5 items" instead of "tell
|
||||||
|
me about X"
|
||||||
|
- Be clear about length: "Write 200 words" instead of "Write something short"
|
||||||
|
- Define who the audience is: "Explain this for a 10-year-old" or "Use technical
|
||||||
|
language"
|
||||||
|
|
||||||
|
### 2. **Context** — Giving the AI Background Information
|
||||||
|
|
||||||
|
More context helps the AI make better decisions.
|
||||||
|
|
||||||
|
**Example improvements:**
|
||||||
|
|
||||||
|
- Explain the goal: "I'm writing a resume, so focus on professional language"
|
||||||
|
- Share constraints: "We only have $500 budget" or "It needs to work on mobile"
|
||||||
|
- Provide background: "I already know Python but not JavaScript"
|
||||||
|
|
||||||
|
### 3. **Constraints** — Setting Boundaries
|
||||||
|
|
||||||
|
Constraints prevent unwanted outputs.
|
||||||
|
|
||||||
|
**Example improvements:**
|
||||||
|
|
||||||
|
- Set length limits: "Keep it under 100 words"
|
||||||
|
- Specify format: "Use JSON format" or "Write as a numbered list"
|
||||||
|
- Define tone: "Be casual and friendly, not formal"
|
||||||
|
- Say what NOT to include: "Don't use technical jargon"
|
||||||
|
|
||||||
|
## How to Use This Skill
|
||||||
|
|
||||||
|
1. **Share your prompt** — Give me the original prompt you want to improve
|
||||||
|
2. **Review suggestions** — I'll show you specific improvements in each area
|
||||||
|
3. **Choose what you like** — Pick which suggestions to apply
|
||||||
|
4. **Get the final version** — I'll rewrite your prompt with your chosen
|
||||||
|
improvements
|
||||||
|
|
||||||
|
## Interactive Selection Process
|
||||||
|
|
||||||
|
When you use this skill, you'll see:
|
||||||
|
|
||||||
|
- **Original prompt** — Your starting point
|
||||||
|
- **Improvement suggestions** — Specific changes grouped by category
|
||||||
|
(Specificity, Context, Constraints)
|
||||||
|
- **Preview examples** — What each change would look like with the improvement
|
||||||
|
applied
|
||||||
|
- **Your choices** — You pick which suggestions help most (you can apply all,
|
||||||
|
some, or none)
|
||||||
|
|
||||||
|
Then you get a rewritten prompt combining all your choices.
|
||||||
|
|
||||||
|
### Example Interaction
|
||||||
|
|
||||||
|
**Original:** "Write me a blog post"
|
||||||
|
|
||||||
|
**Suggestions I might offer:**
|
||||||
|
|
||||||
|
- **Specificity**: Add a topic (e.g., "about sustainable living")
|
||||||
|
- **Context**: Explain your goal (e.g., "to build authority on my website")
|
||||||
|
- **Constraints**: Set a word count (e.g., "800-1000 words")
|
||||||
|
|
||||||
|
**Your choice:** "I want all three — add topic, goal, and word count"
|
||||||
|
|
||||||
|
**Final rewritten prompt:** "Write an 800-1000 word blog post about sustainable
|
||||||
|
living for my website. The goal is to establish my authority on eco-friendly
|
||||||
|
practices. Target an audience of people interested in reducing their carbon
|
||||||
|
footprint. Include 3-4 practical tips they can implement immediately, and end
|
||||||
|
with a call-to-action encouraging them to sign up for my newsletter."
|
||||||
|
|
||||||
|
## Tips for Best Results
|
||||||
|
|
||||||
|
- **Start simple** — Even small improvements help
|
||||||
|
- **Focus on your goal** — What outcome do you want?
|
||||||
|
- **Add one constraint at a time** — Too many rules can be confusing
|
||||||
|
- **Test and iterate** — Try the new prompt and see if results improve
|
||||||
|
|
||||||
|
## What Makes a Good Prompt
|
||||||
|
|
||||||
|
A prompt becomes "good" when:
|
||||||
|
|
||||||
|
- The AI understands exactly what you want ✓
|
||||||
|
- You've given enough context to explain why ✓
|
||||||
|
- You've set boundaries to prevent bad outputs ✓
|
||||||
|
- Someone else could read it and understand your intent ✓
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
---
|
||||||
|
name: ui-ux-testing
|
||||||
|
description:
|
||||||
|
Automated visual regression testing and UI/UX analysis for web applications.
|
||||||
|
Use this skill whenever developers mention "test this UI", "visual
|
||||||
|
regression", "test the UI", "check this interface", "UI testing", or want to
|
||||||
|
create automated tests for web pages. Analyzes URLs and generates
|
||||||
|
comprehensive test strategies including Playwright/Cypress test scripts,
|
||||||
|
manual testing checklists, visual regression detection, and detailed reports
|
||||||
|
with findings and recommendations.
|
||||||
|
compatibility:
|
||||||
|
tools: Claude in Chrome browser automation
|
||||||
|
frameworks: Playwright, Cypress, Selenium
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI/UX Testing Skill
|
||||||
|
|
||||||
|
This skill helps developers create automated visual regression tests and
|
||||||
|
comprehensive UI/UX testing strategies for web applications.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a developer asks you to test a UI or create visual regression tests, this
|
||||||
|
skill guides you through:
|
||||||
|
|
||||||
|
1. **Analyzing the target URL** - Inspect the web page structure and components
|
||||||
|
2. **Generating test strategies** - Create both automated and manual testing
|
||||||
|
approaches
|
||||||
|
3. **Writing test code** - Generate Playwright/Cypress test scripts or Selenium
|
||||||
|
code
|
||||||
|
4. **Creating test checklists** - Manual testing steps for visual regression and
|
||||||
|
UX flows
|
||||||
|
5. **Generating reports** - Detailed findings, issues, and recommendations in
|
||||||
|
markdown/HTML
|
||||||
|
|
||||||
|
## When to Trigger
|
||||||
|
|
||||||
|
Trigger this skill when the user:
|
||||||
|
|
||||||
|
- Provides a URL and asks to "test this UI"
|
||||||
|
- Requests "visual regression testing" for a web page
|
||||||
|
- Wants to "check accessibility" or test a component
|
||||||
|
- Asks to "create automated tests" for a UI
|
||||||
|
- Wants a "testing strategy" or "test plan" for a web application
|
||||||
|
- Mentions QA, testing, or validation of UI components
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Inspect the Target URL
|
||||||
|
|
||||||
|
Use Claude in Chrome to:
|
||||||
|
|
||||||
|
- Navigate to the provided URL
|
||||||
|
- Take screenshots of different viewport sizes (desktop, tablet, mobile)
|
||||||
|
- Inspect the DOM structure using `read_page` tool
|
||||||
|
- Identify key components, interactive elements, and critical flows
|
||||||
|
- Note responsive behavior and CSS properties
|
||||||
|
|
||||||
|
### Step 2: Create a Test Strategy
|
||||||
|
|
||||||
|
Based on your inspection, identify:
|
||||||
|
|
||||||
|
- **Visual elements** to regression test (buttons, forms, headers, layouts)
|
||||||
|
- **Interactive flows** to test (hover states, click handlers, form submission)
|
||||||
|
- **Responsive breakpoints** to validate (mobile, tablet, desktop)
|
||||||
|
- **Accessibility concerns** (ARIA labels, color contrast, keyboard navigation)
|
||||||
|
- **Critical user paths** to validate (common workflows)
|
||||||
|
|
||||||
|
### Step 3: Generate Test Code
|
||||||
|
|
||||||
|
**For Automated Testing (Choose one or more):**
|
||||||
|
|
||||||
|
#### Playwright (Recommended)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Example structure
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("visual regression - homepage", async ({ page }) => {
|
||||||
|
await page.goto("https://example.com");
|
||||||
|
|
||||||
|
// Capture baseline screenshot
|
||||||
|
await expect(page).toHaveScreenshot("homepage.png");
|
||||||
|
|
||||||
|
// Test interactive elements
|
||||||
|
await page.hover("button.primary");
|
||||||
|
await expect(page).toHaveScreenshot("button-hover.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responsive layout - mobile", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("https://example.com");
|
||||||
|
await expect(page).toHaveScreenshot("mobile-layout.png");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cypress
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe("Visual Regression Tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit("https://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures baseline screenshot", () => {
|
||||||
|
cy.screenshot("homepage");
|
||||||
|
cy.get('[data-testid="header"]').should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests button hover state", () => {
|
||||||
|
cy.get("button.primary").trigger("mouseenter");
|
||||||
|
cy.screenshot("button-hover-state");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Selenium
|
||||||
|
|
||||||
|
```python
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
driver = webdriver.Chrome()
|
||||||
|
driver.get('https://example.com')
|
||||||
|
|
||||||
|
# Capture screenshot
|
||||||
|
driver.save_screenshot('homepage.png')
|
||||||
|
|
||||||
|
# Test responsive
|
||||||
|
driver.set_window_size(375, 812)
|
||||||
|
driver.save_screenshot('mobile-view.png')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Create Manual Testing Checklist
|
||||||
|
|
||||||
|
Generate a checklist including:
|
||||||
|
|
||||||
|
- [ ] **Visual Consistency**
|
||||||
|
- [ ] All elements render correctly at 1920x1080
|
||||||
|
- [ ] All elements render correctly at 1366x768
|
||||||
|
- [ ] All elements render correctly at 768x1024 (tablet)
|
||||||
|
- [ ] All elements render correctly at 375x667 (mobile)
|
||||||
|
- [ ] Colors match design specifications
|
||||||
|
- [ ] Typography renders correctly (font families, sizes, weights)
|
||||||
|
- [ ] Images load and display at correct aspect ratios
|
||||||
|
|
||||||
|
- [ ] **Responsive Behavior**
|
||||||
|
- [ ] Layout adapts correctly on mobile (no horizontal scroll)
|
||||||
|
- [ ] Navigation collapses/expands appropriately
|
||||||
|
- [ ] Form inputs are touch-friendly (min 44x44px)
|
||||||
|
- [ ] Content reflows without overlapping
|
||||||
|
|
||||||
|
- [ ] **Interactive Elements**
|
||||||
|
- [ ] All buttons are clickable and have hover states
|
||||||
|
- [ ] Form inputs accept user input
|
||||||
|
- [ ] Dropdowns open/close correctly
|
||||||
|
- [ ] Links are underlined and properly colored
|
||||||
|
|
||||||
|
- [ ] **Accessibility**
|
||||||
|
- [ ] Keyboard navigation works (Tab key)
|
||||||
|
- [ ] Color contrast meets WCAG AA standards
|
||||||
|
- [ ] Images have alt text
|
||||||
|
- [ ] Form labels are associated with inputs
|
||||||
|
|
||||||
|
- [ ] **Critical User Paths**
|
||||||
|
- [ ] [Specific path 1]: [Steps and expected result]
|
||||||
|
- [ ] [Specific path 2]: [Steps and expected result]
|
||||||
|
|
||||||
|
### Step 5: Generate Test Report
|
||||||
|
|
||||||
|
Create an HTML/Markdown report with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# UI/UX Testing Report
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
- URL tested: [URL]
|
||||||
|
- Viewports tested: Desktop (1920x1080), Tablet (768x1024), Mobile (375x667)
|
||||||
|
- Testing date: [Date]
|
||||||
|
- Total issues found: [Count]
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
### Critical (Breaks functionality)
|
||||||
|
|
||||||
|
1. **Issue Title**
|
||||||
|
- Severity: Critical
|
||||||
|
- Location: [Element/Component]
|
||||||
|
- Steps to reproduce: [Steps]
|
||||||
|
- Expected: [What should happen]
|
||||||
|
- Actual: [What actually happens]
|
||||||
|
- Screenshot: [If applicable]
|
||||||
|
|
||||||
|
### Major (Significant visual/UX impact)
|
||||||
|
|
||||||
|
1. **Issue Title**
|
||||||
|
- Severity: Major
|
||||||
|
- Location: [Element/Component]
|
||||||
|
- Impact: [User impact]
|
||||||
|
|
||||||
|
### Minor (Polish/optimization)
|
||||||
|
|
||||||
|
1. **Issue Title**
|
||||||
|
- Severity: Minor
|
||||||
|
- Location: [Element/Component]
|
||||||
|
- Recommendation: [Suggestion]
|
||||||
|
|
||||||
|
## Visual Regression Analysis
|
||||||
|
|
||||||
|
### Desktop (1920x1080)
|
||||||
|
|
||||||
|
- [List observations]
|
||||||
|
- [List changes from baseline if available]
|
||||||
|
|
||||||
|
### Tablet (768x1024)
|
||||||
|
|
||||||
|
- [List observations]
|
||||||
|
- [Responsive issues found]
|
||||||
|
|
||||||
|
### Mobile (375x667)
|
||||||
|
|
||||||
|
- [List observations]
|
||||||
|
- [Mobile-specific issues]
|
||||||
|
|
||||||
|
## Accessibility Assessment
|
||||||
|
|
||||||
|
| Element | Issue | WCAG Level | Recommendation |
|
||||||
|
| --------- | ------- | ---------- | -------------- |
|
||||||
|
| [Element] | [Issue] | [AA/AAA] | [Fix] |
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
1. **High Priority**
|
||||||
|
- [Recommendation with rationale]
|
||||||
|
|
||||||
|
2. **Medium Priority**
|
||||||
|
- [Recommendation with rationale]
|
||||||
|
|
||||||
|
3. **Low Priority**
|
||||||
|
- [Recommendation with rationale]
|
||||||
|
|
||||||
|
## Test Coverage Summary
|
||||||
|
|
||||||
|
- Automated tests needed: [Count and types]
|
||||||
|
- Manual test cases: [Count]
|
||||||
|
- Estimated testing effort: [Time estimate]
|
||||||
|
- Regression risk: [High/Medium/Low]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Generated using UI/UX Testing Skill
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Options
|
||||||
|
|
||||||
|
Based on what the developer needs, generate:
|
||||||
|
|
||||||
|
1. **Test Code Only** - Playwright/Cypress/Selenium scripts ready to integrate
|
||||||
|
2. **Testing Strategy** - Comprehensive checklist and manual test plan
|
||||||
|
3. **Full Report** - Screenshots, findings, issues, and automated test code
|
||||||
|
4. **All of the above** - Complete testing package
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- **Multiple viewports:** Always test at least mobile (375px), tablet (768px),
|
||||||
|
and desktop (1920px)
|
||||||
|
- **Visual baselines:** Save baseline screenshots before making changes
|
||||||
|
- **Critical paths:** Prioritize testing main user workflows first
|
||||||
|
- **Accessibility first:** Include WCAG AA compliance checks
|
||||||
|
- **Clear assertions:** Make test assertions explicit and meaningful
|
||||||
|
- **Maintainability:** Use data attributes (data-testid) for reliable element
|
||||||
|
selection
|
||||||
|
|
||||||
|
## Example: Complete Testing Session
|
||||||
|
|
||||||
|
1. Developer provides URL: "https://myapp.com/dashboard"
|
||||||
|
2. You inspect the page (screenshots, DOM, responsive behavior)
|
||||||
|
3. You identify:
|
||||||
|
- Dashboard header with navigation
|
||||||
|
- Data table with sorting/filtering
|
||||||
|
- Form for creating items
|
||||||
|
- Mobile menu collapse
|
||||||
|
4. You generate:
|
||||||
|
- Playwright tests for visual regression
|
||||||
|
- Manual testing checklist
|
||||||
|
- HTML report with findings
|
||||||
|
5. Developer receives complete testing artifact ready to use
|
||||||
|
|
||||||
|
## Tips for Success
|
||||||
|
|
||||||
|
- Take screenshots at each viewport to catch responsive issues
|
||||||
|
- Test interactive states (hover, focus, active, disabled)
|
||||||
|
- Verify critical user journeys end-to-end
|
||||||
|
- Include accessibility testing automatically
|
||||||
|
- Provide both automated (code) and manual (checklist) approaches
|
||||||
|
- Make reports actionable with clear severity levels and recommendations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2024 **Skill Version:** 1.0
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
---
|
||||||
|
name: website-creation-automation
|
||||||
|
description:
|
||||||
|
Automate end-to-end website creation with AI-powered optimization, design, and
|
||||||
|
testing. Trigger this skill whenever the user wants to create a new website,
|
||||||
|
build a landing page, design a web application, or automatically generate a
|
||||||
|
complete website from a description. This skill takes a user's website idea or
|
||||||
|
prompt, optimizes it for clarity, designs a modern responsive website, and
|
||||||
|
automatically tests it for quality issues. Use this skill for any "create a
|
||||||
|
website" or "build a web application" request, including landing pages,
|
||||||
|
portfolio sites, e-commerce pages, dashboards, or any web-based project.
|
||||||
|
compatibility:
|
||||||
|
models:
|
||||||
|
- claude-sonnet-4-20250514 (design and testing)
|
||||||
|
- claude-haiku-4.5-20251001 (bug fixes)
|
||||||
|
required_skills:
|
||||||
|
- prompt-optimizer
|
||||||
|
- frontend-atomic-design
|
||||||
|
- ui-ux-testing
|
||||||
|
---
|
||||||
|
|
||||||
|
# Website Creation Automation Skill
|
||||||
|
|
||||||
|
An intelligent, end-to-end automation workflow for creating complete, tested
|
||||||
|
websites from simple prompts.
|
||||||
|
|
||||||
|
## What This Skill Does
|
||||||
|
|
||||||
|
This skill orchestrates a complete website creation pipeline:
|
||||||
|
|
||||||
|
1. **Prompt Optimization** — Takes your website description and optimizes it
|
||||||
|
into a detailed, structured prompt
|
||||||
|
2. **Website Design** — Uses the optimized prompt to design and build a modern,
|
||||||
|
responsive website
|
||||||
|
3. **Automated Testing** — Tests the generated website for visual, interactive,
|
||||||
|
and responsive issues
|
||||||
|
4. **Auto-Fix** — Detects and fixes any issues found during testing
|
||||||
|
|
||||||
|
The entire workflow is automated, so you just provide a simple description of
|
||||||
|
what you want, and the skill handles the rest.
|
||||||
|
|
||||||
|
## Core Workflow
|
||||||
|
|
||||||
|
### Step 1: Optimize Your Website Prompt
|
||||||
|
|
||||||
|
Your initial description (e.g., "Create a portfolio website for a freelance
|
||||||
|
designer") is passed to the **prompt-optimizer** skill, which:
|
||||||
|
|
||||||
|
- Adds specific details about layout, features, and target audience
|
||||||
|
- Clarifies design preferences and functionality requirements
|
||||||
|
- Structures the request to guide high-quality website generation
|
||||||
|
- Returns a detailed, optimized prompt ready for design
|
||||||
|
|
||||||
|
**Example transformation:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Input: "Create a portfolio website for a freelance designer"
|
||||||
|
↓
|
||||||
|
Output: "Create a modern portfolio website for a freelance graphic designer.
|
||||||
|
Include: hero section with featured work, project showcase grid (6-8 projects),
|
||||||
|
about section, services list, client testimonials, contact form, and footer.
|
||||||
|
Target audience: potential clients and collaborators. Design should be minimalist
|
||||||
|
with emphasis on visual work. Mobile-responsive. Use modern sans-serif typography
|
||||||
|
and white space. Include smooth scroll animations."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Design the Website
|
||||||
|
|
||||||
|
The **frontend-atomic-design** skill uses the optimized prompt to:
|
||||||
|
|
||||||
|
- Break down the website into atomic components (atoms, molecules, organisms)
|
||||||
|
- Create a responsive layout that works on desktop, tablet, and mobile
|
||||||
|
- Apply modern design patterns using Tailwind CSS
|
||||||
|
- Build interactive elements and proper semantic HTML
|
||||||
|
- Generate a complete, production-ready HTML file (or React component)
|
||||||
|
|
||||||
|
**Outputs:**
|
||||||
|
|
||||||
|
- Full HTML file with embedded CSS and JavaScript
|
||||||
|
- All assets (icons, fonts) are self-contained
|
||||||
|
- Responsive design with mobile-first approach
|
||||||
|
- Accessible markup with semantic HTML5
|
||||||
|
|
||||||
|
### Step 3: Test the Website
|
||||||
|
|
||||||
|
The **ui-ux-testing** skill performs comprehensive testing:
|
||||||
|
|
||||||
|
- Visual regression testing (captures baseline screenshots)
|
||||||
|
- Responsive layout validation (mobile, tablet, desktop)
|
||||||
|
- Interactive element testing (buttons, forms, links)
|
||||||
|
- Accessibility checking (color contrast, keyboard navigation)
|
||||||
|
- Cross-browser compatibility assessment
|
||||||
|
- User flow validation
|
||||||
|
|
||||||
|
**Testing output includes:**
|
||||||
|
|
||||||
|
- Screenshots from multiple viewport sizes
|
||||||
|
- Detailed findings and issues detected
|
||||||
|
- Visual regression comparison
|
||||||
|
- Recommendations for improvements
|
||||||
|
|
||||||
|
### Step 4: Auto-Fix Detected Issues
|
||||||
|
|
||||||
|
Any issues detected in testing are automatically fixed:
|
||||||
|
|
||||||
|
- **Using Haiku 4.5** — A faster model optimized for targeted fixes
|
||||||
|
- **HTML-only fixes** — Modifications to structure, styling, or interactivity
|
||||||
|
- **Preserves design intent** — Fixes maintain the original design aesthetic
|
||||||
|
- **Re-validates** — Quick verification that fixes resolved the issues
|
||||||
|
|
||||||
|
**Common fixes include:**
|
||||||
|
|
||||||
|
- Correcting responsive behavior issues
|
||||||
|
- Fixing accessibility problems
|
||||||
|
- Adjusting spacing, alignment, or colors
|
||||||
|
- Improving interactive element behavior
|
||||||
|
- Ensuring all content is properly visible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill whenever you want to:
|
||||||
|
|
||||||
|
- **Create a new website** from scratch based on a description
|
||||||
|
- **Build a landing page** for a product, service, or event
|
||||||
|
- **Design a portfolio site** to showcase your work
|
||||||
|
- **Create an e-commerce page** with product displays
|
||||||
|
- **Build a dashboard or web app** UI
|
||||||
|
- **Generate a multi-page website** (returns first page, can iterate)
|
||||||
|
- **Prototype a website concept** quickly
|
||||||
|
|
||||||
|
## How to Trigger This Skill
|
||||||
|
|
||||||
|
Simply provide:
|
||||||
|
|
||||||
|
1. **Website description** — What kind of website you want (e.g., "e-commerce
|
||||||
|
store for handmade jewelry", "SaaS landing page", "restaurant menu website")
|
||||||
|
2. **Optional details** — Any specific requirements (colors, features, tone,
|
||||||
|
audience)
|
||||||
|
|
||||||
|
The skill handles everything else automatically.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
**User prompt:** "Create a landing page for a sustainable fashion startup called
|
||||||
|
EcoStitch. Include a hero section, features of our eco-friendly materials,
|
||||||
|
pricing plans, customer testimonials, and a newsletter signup."
|
||||||
|
|
||||||
|
**Skill processes:**
|
||||||
|
|
||||||
|
1. Optimizes prompt with specific design details and layout structure
|
||||||
|
2. Designs a modern, responsive landing page with all requested sections
|
||||||
|
3. Tests layout across mobile/tablet/desktop, forms, links, and visual design
|
||||||
|
4. Fixes any responsive or interactive issues found
|
||||||
|
5. Returns production-ready HTML file
|
||||||
|
|
||||||
|
**Final output:** A complete, tested, bug-free website ready to deploy or
|
||||||
|
customize further.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Model Usage
|
||||||
|
|
||||||
|
- **Sonnet 4.6** — Used for optimization, design, and testing (high-quality
|
||||||
|
complex tasks)
|
||||||
|
- **Haiku 4.5** — Used for bug fixes only (fast, targeted improvements)
|
||||||
|
|
||||||
|
### Skill Integration
|
||||||
|
|
||||||
|
This skill coordinates three core skills in sequence:
|
||||||
|
|
||||||
|
```
|
||||||
|
User Prompt
|
||||||
|
↓
|
||||||
|
[prompt-optimizer] → Optimized Prompt
|
||||||
|
↓
|
||||||
|
[frontend-atomic-design] → HTML/React Website
|
||||||
|
↓
|
||||||
|
[ui-ux-testing] → Test Results + Screenshots
|
||||||
|
↓
|
||||||
|
[Bug Fix Loop] → Fixed HTML/React Website
|
||||||
|
↓
|
||||||
|
Final Website (Ready to Use)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Format
|
||||||
|
|
||||||
|
The final website is delivered as:
|
||||||
|
|
||||||
|
- **HTML file** — Self-contained with CSS and JavaScript embedded
|
||||||
|
- **Screenshots** — Before/after testing comparison
|
||||||
|
- **Test report** — Issues found and fixes applied
|
||||||
|
- **Deployment ready** — Can be hosted on any static hosting service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations & Notes
|
||||||
|
|
||||||
|
- **Single-page output** — Generates one complete page (though can be expanded
|
||||||
|
to multi-page)
|
||||||
|
- **Static by default** — Returns HTML; can generate React components if needed
|
||||||
|
- **Database-free** — Forms are functional but don't store data without backend
|
||||||
|
integration
|
||||||
|
- **Rapid iteration** — If you want to modify the result, you can iterate by
|
||||||
|
running the skill again with updated requirements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips for Best Results
|
||||||
|
|
||||||
|
1. **Be descriptive** — More detail in your initial prompt leads to better
|
||||||
|
results
|
||||||
|
2. **Specify audience** — Who is this website for? (target customers, users,
|
||||||
|
etc.)
|
||||||
|
3. **Include features** — What should the website do? (e.g., showcase products,
|
||||||
|
collect emails, etc.)
|
||||||
|
4. **Mention style** — Any aesthetic preferences? (minimalist, colorful,
|
||||||
|
corporate, playful, etc.)
|
||||||
|
5. **Test thoroughly** — Review the testing results to ensure the site meets
|
||||||
|
your needs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
START
|
||||||
|
↓
|
||||||
|
Input: Website Description
|
||||||
|
↓
|
||||||
|
[Call: prompt-optimizer skill]
|
||||||
|
↓
|
||||||
|
Receive: Optimized Detailed Prompt
|
||||||
|
↓
|
||||||
|
[Call: frontend-atomic-design skill]
|
||||||
|
↓
|
||||||
|
Receive: HTML/React Website Code
|
||||||
|
↓
|
||||||
|
[Call: ui-ux-testing skill]
|
||||||
|
↓
|
||||||
|
Receive: Test Results + Issues Found
|
||||||
|
↓
|
||||||
|
Are there critical issues?
|
||||||
|
├─ YES → [Use Haiku 4.5 to fix] → Re-test
|
||||||
|
├─ NO → Proceed
|
||||||
|
↓
|
||||||
|
COMPLETE: Return Website + Test Report
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential expansions to this skill:
|
||||||
|
|
||||||
|
- Multi-page website generation (homepage, about, services, contact, etc.)
|
||||||
|
- CMS integration (connect to content management systems)
|
||||||
|
- Backend API scaffolding (Node.js/Express templates)
|
||||||
|
- SEO optimization (meta tags, structured data, open graph)
|
||||||
|
- Analytics integration (Google Analytics, Mixpanel)
|
||||||
|
- E-commerce integration (payment processing, inventory)
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -22,15 +22,10 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Calculate and Export Target IP
|
- uses: gerlero/apt-install@v1
|
||||||
run: |
|
with:
|
||||||
# 1. Lấy IP hiện tại và tính toán IP đích (đổi octet cuối thành .1)
|
packages: docker.io zip
|
||||||
RUNNER_IP=$(hostname -I | awk '{print $1}')
|
|
||||||
CALCULATED_IP=$(echo $RUNNER_IP | cut -d'.' -f1-3).1
|
|
||||||
|
|
||||||
# 2. Đưa vào biến môi trường toàn cục của Job
|
|
||||||
echo "TARGET_IP=${CALCULATED_IP}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Setup SSH Key
|
- name: Setup SSH Key
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
@@ -38,33 +33,17 @@ jobs:
|
|||||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
|
||||||
ssh-keyscan -p 222 -t ed25519 $TARGET_IP >> ~/.ssh/known_hosts
|
ssh-keyscan -t ed25519 vps.demonkernel.io.vn >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: latest
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v3
|
- uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
run_install: false
|
run_install: false
|
||||||
|
cache: true
|
||||||
- name: Get pnpm store directory
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: actions/cache@v3
|
|
||||||
name: Setup pnpm cache
|
|
||||||
with:
|
|
||||||
path: ${{ env.STORE_PATH }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Update dependencies
|
- name: Update dependencies
|
||||||
run: pnpm update
|
run: pnpm update
|
||||||
@@ -80,7 +59,7 @@ jobs:
|
|||||||
GITEA_USER: ${{ github.actor }}
|
GITEA_USER: ${{ github.actor }}
|
||||||
GITEA_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
run: pnpm release
|
run: pnpm release
|
||||||
|
|
||||||
- name: Set Version Output
|
- name: Set Version Output
|
||||||
id: set_output
|
id: set_output
|
||||||
run: |
|
run: |
|
||||||
@@ -92,7 +71,7 @@ jobs:
|
|||||||
if: gitea.ref == 'refs/heads/main'
|
if: gitea.ref == 'refs/heads/main'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.demonkernel.io.vn
|
registry: vps.demonkernel.io.vn
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.ACCESS_TOKEN }}
|
password: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
||||||
@@ -102,10 +81,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# Lowercase Repository
|
# Lowercase Repository
|
||||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
RAW_VERSION="${{ steps.set_output.outputs.version }}"
|
RAW_VERSION="${{ steps.set_output.outputs.version }}"
|
||||||
VERSION_LOWER=$(echo "$RAW_VERSION" | tr '[:upper:]' '[:lower:]')
|
VERSION_LOWER=$(echo "$RAW_VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
echo "repo=$REPO_LOWER" >> $GITHUB_OUTPUT
|
echo "repo=$REPO_LOWER" >> $GITHUB_OUTPUT
|
||||||
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -116,19 +95,18 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
||||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
- name: Commit & Push Changes
|
- name: Commit & Push Changes
|
||||||
if: gitea.ref == 'refs/heads/main'
|
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
if [ -n "$(git status --porcelain)" ]&& [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
git config --global user.name "gitea-actions"
|
git config --global user.name "gitea-actions"
|
||||||
git config --global user.email "actions-user@noreply.git.demonkernel.io.vn"
|
git config --global user.email "actions-user@noreply.git.demonkernel.io.vn"
|
||||||
|
|
||||||
REPO_NAME="${{ gitea.repository }}"
|
REPO_NAME="${{ gitea.repository }}"
|
||||||
|
|
||||||
git remote set-url origin ssh://git@$TARGET_IP:222/${REPO_NAME}.git
|
git remote set-url origin ssh://git@vps.demonkernel.io.vn/${REPO_NAME}.git
|
||||||
|
|
||||||
git rm -r --cached .
|
git rm -r --cached .
|
||||||
git add .
|
git add .
|
||||||
@@ -136,4 +114,3 @@ jobs:
|
|||||||
|
|
||||||
git push
|
git push
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ Chứa route/page/layout theo Next.js App Router.
|
|||||||
- **`(main)/`** - Main shopping interface (duyệt menu, đăng nhập, đăng ký,
|
- **`(main)/`** - Main shopping interface (duyệt menu, đăng nhập, đăng ký,
|
||||||
thanh toán)
|
thanh toán)
|
||||||
- **`(feed)/`** - Feed/discovery page (khám phá quán)
|
- **`(feed)/`** - Feed/discovery page (khám phá quán)
|
||||||
- **`(manager)/`** - Manager dashboard (quản lý sản phẩm, đơn hàng) - **[ĐANG
|
- **`(manager)/`** - Manager dashboard (quản lý sản phẩm, phân tích tài
|
||||||
PHÁT TRIỂN]**
|
chính) - **[ĐANG PHÁT TRIỂN ~70%]**
|
||||||
|
- **`(staff)/`** - Staff shift schedule (lịch làm việc nhân viên) - **[HOÀN
|
||||||
|
THÀNH]**
|
||||||
- **Tài liệu:** `app/APP.md` - Chi tiết routes, pages, CSS tokens
|
- **Tài liệu:** `app/APP.md` - Chi tiết routes, pages, CSS tokens
|
||||||
|
|
||||||
### `components/` - Reusable UI Components (Atomic Design)
|
### `components/` - Reusable UI Components (Atomic Design)
|
||||||
@@ -80,7 +82,14 @@ Sections phức tạp kết hợp molecules + atoms, có logic, data filtering:
|
|||||||
- **`cart/`** - CartFab, CartSummary, CartList
|
- **`cart/`** - CartFab, CartSummary, CartList
|
||||||
- **`product-grid/`** - ProductGrid, ProductFilters
|
- **`product-grid/`** - ProductGrid, ProductFilters
|
||||||
- **`forms/`** - LoginForm, RegisterForm, CheckoutForm, ReviewForm
|
- **`forms/`** - LoginForm, RegisterForm, CheckoutForm, ReviewForm
|
||||||
- **`modals/`** - ReviewModal, ConfirmModal
|
- **`modals/`** - ReviewModal, ConfirmModal, CashPaymentModal, QRPaymentModal,
|
||||||
|
PaymentSuccessModal
|
||||||
|
- **`manager/`** - ProductModal (image upload), ComboModal, CategoryModal,
|
||||||
|
DeleteConfirm, StatusBadge, ProductsTab, CombosTab, CategoriesTab
|
||||||
|
- **`analytics/`** - BarChart, LineChart, PieChart (SVG), ProductTable,
|
||||||
|
SummaryCard
|
||||||
|
- **`shift-schedule/`** - WeeklySchedule, MonthlyCalendar, MobileShiftView,
|
||||||
|
ShiftCreateModal, ShiftDetailModal
|
||||||
- **`shop-grid/`** - ShopGrid, ShopFilters
|
- **`shop-grid/`** - ShopGrid, ShopFilters
|
||||||
- **`hero-section/`** - HeroSection
|
- **`hero-section/`** - HeroSection
|
||||||
- **`featured-section/`** - FeaturedProducts, FeaturedShops
|
- **`featured-section/`** - FeaturedProducts, FeaturedShops
|
||||||
@@ -103,6 +112,7 @@ Page layouts, không có data cụ thể, children composition:
|
|||||||
- **`main-layout/`** - MainLayout (header + sidebar + content + footer)
|
- **`main-layout/`** - MainLayout (header + sidebar + content + footer)
|
||||||
- **`feed-layout/`** - FeedLayout
|
- **`feed-layout/`** - FeedLayout
|
||||||
- **`manager-layout/`** - ManagerLayout
|
- **`manager-layout/`** - ManagerLayout
|
||||||
|
- **`staff-layout/`** - StaffLayout (navigation shell for staff pages)
|
||||||
- **`checkout-layout/`** - CheckoutLayout
|
- **`checkout-layout/`** - CheckoutLayout
|
||||||
- **`auth-layout/`** - AuthLayout
|
- **`auth-layout/`** - AuthLayout
|
||||||
|
|
||||||
@@ -161,8 +171,12 @@ Chứa logic dùng chung, context, constants, types:
|
|||||||
- **`auth-context.tsx`** - AuthProvider + useAuth() hook (login, logout,
|
- **`auth-context.tsx`** - AuthProvider + useAuth() hook (login, logout,
|
||||||
register)
|
register)
|
||||||
- **`cart-context.tsx`** - CartProvider + useCart() hook (add, remove, quantity
|
- **`cart-context.tsx`** - CartProvider + useCart() hook (add, remove, quantity
|
||||||
operations)
|
operations, clearCart)
|
||||||
- **`menu-context.tsx`** - MenuProvider + useMenu() hook (category state)
|
- **`menu-context.tsx`** - MenuProvider + useMenu() hook (category state)
|
||||||
|
- **`manager-context.tsx`** - ManagerProvider + useManager() hook (CRUD for
|
||||||
|
products, combos, categories)
|
||||||
|
- **`shift-context.tsx`** - ShiftProvider + useShift() hook (staff shift
|
||||||
|
management: create, update, delete, date selection)
|
||||||
- **Tài liệu:** `lib/LIB.md` - Chi tiết types, constants, contexts
|
- **Tài liệu:** `lib/LIB.md` - Chi tiết types, constants, contexts
|
||||||
|
|
||||||
### `public/` - Static Assets
|
### `public/` - Static Assets
|
||||||
@@ -233,11 +247,41 @@ components/
|
|||||||
│ ├── product-grid/
|
│ ├── product-grid/
|
||||||
│ │ ├── ProductGrid.tsx
|
│ │ ├── ProductGrid.tsx
|
||||||
│ │ └── ProductGrid.types.ts
|
│ │ └── ProductGrid.types.ts
|
||||||
|
│ ├── modals/
|
||||||
|
│ │ ├── ReviewModal.tsx
|
||||||
|
│ │ ├── CashPaymentModal.tsx
|
||||||
|
│ │ ├── QRPaymentModal.tsx
|
||||||
|
│ │ └── PaymentSuccessModal.tsx
|
||||||
|
│ ├── manager/
|
||||||
|
│ │ ├── ProductModal.tsx
|
||||||
|
│ │ ├── ComboModal.tsx
|
||||||
|
│ │ ├── CategoryModal.tsx
|
||||||
|
│ │ ├── DeleteConfirm.tsx
|
||||||
|
│ │ ├── StatusBadge.tsx
|
||||||
|
│ │ ├── ProductsTab.tsx
|
||||||
|
│ │ ├── CombosTab.tsx
|
||||||
|
│ │ └── CategoriesTab.tsx
|
||||||
|
│ ├── analytics/
|
||||||
|
│ │ ├── BarChart.tsx
|
||||||
|
│ │ ├── LineChart.tsx
|
||||||
|
│ │ ├── PieChart.tsx
|
||||||
|
│ │ ├── ProductTable.tsx
|
||||||
|
│ │ └── SummaryCard.tsx
|
||||||
|
│ ├── shift-schedule/
|
||||||
|
│ │ ├── WeeklySchedule.tsx
|
||||||
|
│ │ ├── MonthlyCalendar.tsx
|
||||||
|
│ │ ├── MobileShiftView.tsx
|
||||||
|
│ │ ├── ShiftCreateModal.tsx
|
||||||
|
│ │ └── ShiftDetailModal.tsx
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── main-layout/
|
│ ├── main-layout/
|
||||||
│ │ ├── MainLayout.tsx
|
│ │ ├── MainLayout.tsx
|
||||||
│ │ └── MainLayout.types.ts
|
│ │ └── MainLayout.types.ts
|
||||||
|
│ ├── manager-layout/
|
||||||
|
│ │ └── ManagerLayout.tsx
|
||||||
|
│ ├── staff-layout/
|
||||||
|
│ │ └── StaffLayout.tsx
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
└── ATOMIC_DESIGN.md
|
└── ATOMIC_DESIGN.md
|
||||||
```
|
```
|
||||||
@@ -314,9 +358,15 @@ components/
|
|||||||
- **Mock data first** - Use `lib/constants.ts` for development, replace with API
|
- **Mock data first** - Use `lib/constants.ts` for development, replace with API
|
||||||
later
|
later
|
||||||
- **Contexts for state sharing:**
|
- **Contexts for state sharing:**
|
||||||
- `AuthContext` (lib/auth-context.tsx) - User state + auth operations
|
- `AuthContext` (lib/auth-context.tsx) - User state + auth operations →
|
||||||
- `CartContext` (lib/cart-context.tsx) - Shopping cart + operations
|
`useAuth()`
|
||||||
- `MenuContext` (lib/menu-context.tsx) - Active category state
|
- `CartContext` (lib/cart-context.tsx) - Shopping cart + operations →
|
||||||
|
`useCart()` (includes `clearCart`)
|
||||||
|
- `MenuContext` (lib/menu-context.tsx) - Active category state → `useMenu()`
|
||||||
|
- `ManagerContext` (lib/manager-context.tsx) - Manager dashboard state (CRUD
|
||||||
|
for products/combos/categories) → `useManager()`
|
||||||
|
- `ShiftContext` (lib/shift-context.tsx) - Staff shift management (create,
|
||||||
|
update, delete shifts, date selection) → `useShift()`
|
||||||
- **localStorage keys:** `coffee-shop-user`, `coffee-shop-cart` (defined in
|
- **localStorage keys:** `coffee-shop-user`, `coffee-shop-cart` (defined in
|
||||||
contexts)
|
contexts)
|
||||||
|
|
||||||
@@ -625,21 +675,33 @@ className = "w-full sm:w-1/2 md:w-1/3 p-4 md:p-6 hidden md:block";
|
|||||||
|
|
||||||
## 5) Project Status
|
## 5) Project Status
|
||||||
|
|
||||||
|
> Last Updated: 2026-04-18
|
||||||
|
|
||||||
### ✅ Completed Features
|
### ✅ Completed Features
|
||||||
|
|
||||||
- User authentication (login, register, logout)
|
- User authentication (login, register, logout)
|
||||||
- Product grid display with category filtering
|
- Product grid display with category filtering
|
||||||
- Shopping cart with add/remove/update operations
|
- Shopping cart with add/remove/update operations
|
||||||
- Payment page with review modal
|
- Payment page with PaymentSummaryCard + review modal
|
||||||
|
- Cash payment modal (CashPaymentModal) with change calculation
|
||||||
|
- QR payment modal (QRPaymentModal) with QR code generation (`qrcode.react`) and
|
||||||
|
5-second auto-confirm countdown
|
||||||
|
- Payment success modal (PaymentSuccessModal) with cart clear
|
||||||
- Shop discovery (feed page)
|
- Shop discovery (feed page)
|
||||||
- Responsive design (mobile, tablet, desktop)
|
- Responsive design (mobile, tablet, desktop)
|
||||||
- Header + Footer with navigation
|
- Header + Footer with navigation
|
||||||
- Sidebar category filter
|
- Sidebar category filter
|
||||||
|
- Manager dashboard — Products, Combos, Categories tabs with full CRUD
|
||||||
|
- Product image upload in ProductModal
|
||||||
|
- Financial Analytics dashboard (LineChart, BarChart, PieChart, ProductTable)
|
||||||
|
- Staff shift schedule (`/staff/schedule`) — weekly/monthly views, mobile view,
|
||||||
|
CRUD shifts, department filter
|
||||||
|
|
||||||
### 🚀 In Progress
|
### 🚀 In Progress
|
||||||
|
|
||||||
|
- Manager dashboard (~70% complete) - `app/(manager)/` route group
|
||||||
|
- Remaining: order management, inventory tracking, real API integration
|
||||||
- Atomic Design refactoring (components reorganization)
|
- Atomic Design refactoring (components reorganization)
|
||||||
- Manager dashboard (quản lý sản phẩm) - `app/(manager)/` route group
|
|
||||||
|
|
||||||
### 📋 Planned Features
|
### 📋 Planned Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,762 +0,0 @@
|
|||||||
# ATOMIC DESIGN STRUCTURE GUIDE
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Project sử dụng **Atomic Design Pattern** để tổ chức UI components theo 5 cấp
|
|
||||||
độ:
|
|
||||||
|
|
||||||
1. **Atoms** - Khối xây dựng cơ bản, không thể chia nhỏ hơn
|
|
||||||
2. **Molecules** - Nhóm atoms đơn giản hoạt động cùng nhau
|
|
||||||
3. **Organisms** - Khu vực UI phức tạp, riêng biệt
|
|
||||||
4. **Templates** - Bố cục cấp trang, cấu trúc nội dung
|
|
||||||
5. **Pages** - Các phiên bản cụ thể với dữ liệu thật
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) ATOMS (`components/atoms/`)
|
|
||||||
|
|
||||||
**Mục đích:** Khối xây dựng cơ bản, tái sử dụng cao, không phụ thuộc logic phức
|
|
||||||
tạp.
|
|
||||||
|
|
||||||
Không có context/hooks logic phức tạp, chỉ nhận props từ parent.
|
|
||||||
|
|
||||||
### Cấu trúc thư mục
|
|
||||||
|
|
||||||
```
|
|
||||||
components/atoms/
|
|
||||||
├── buttons/
|
|
||||||
│ ├── Button.tsx # Nút cơ bản (primary, secondary, danger)
|
|
||||||
│ ├── IconButton.tsx # Nút chỉ có icon
|
|
||||||
│ └── Button.types.ts # Props types
|
|
||||||
├── inputs/
|
|
||||||
│ ├── TextInput.tsx # Text input cơ bản
|
|
||||||
│ ├── NumberInput.tsx # Number input với up/down
|
|
||||||
│ ├── Checkbox.tsx # Checkbox
|
|
||||||
│ └── Input.types.ts # Props types
|
|
||||||
├── badges/
|
|
||||||
│ ├── Badge.tsx # Badge cơ bản (color variants)
|
|
||||||
│ ├── PriceBadge.tsx # Badge hiển thị giá
|
|
||||||
│ └── Badge.types.ts # Props types
|
|
||||||
├── icons/
|
|
||||||
│ ├── StarIcon.tsx # Rating star icon
|
|
||||||
│ ├── CartIcon.tsx # Shopping cart icon
|
|
||||||
│ ├── SearchIcon.tsx # Search icon
|
|
||||||
│ └── icons.types.ts # Props types
|
|
||||||
├── typography/
|
|
||||||
│ ├── Heading.tsx # h1-h6 headings
|
|
||||||
│ ├── Text.tsx # Body text variants
|
|
||||||
│ ├── Caption.tsx # Small caption text
|
|
||||||
│ └── Typography.types.ts # Props types
|
|
||||||
├── dividers/
|
|
||||||
│ ├── Divider.tsx # Horizontal divider
|
|
||||||
│ └── Divider.types.ts # Props types
|
|
||||||
├── loaders/
|
|
||||||
│ ├── Spinner.tsx # Loading spinner
|
|
||||||
│ ├── Skeleton.tsx # Skeleton loader
|
|
||||||
│ └── Loader.types.ts # Props types
|
|
||||||
└── index.ts # Barrel export
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ví dụ Atoms
|
|
||||||
|
|
||||||
**Button.tsx:**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { ButtonHTMLAttributes } from "react";
|
|
||||||
|
|
||||||
import type { ButtonProps } from "./Button.types";
|
|
||||||
|
|
||||||
export default function Button({
|
|
||||||
variant = "primary",
|
|
||||||
size = "md",
|
|
||||||
disabled = false,
|
|
||||||
children,
|
|
||||||
className = "",
|
|
||||||
...props
|
|
||||||
}: ButtonProps) {
|
|
||||||
const baseStyles =
|
|
||||||
"font-semibold rounded-lg transition-colors disabled:opacity-50";
|
|
||||||
const variants = {
|
|
||||||
primary:
|
|
||||||
"bg-[color:var(--color-primary)] text-white hover:bg-[color:var(--color-primary-dark)]",
|
|
||||||
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
|
|
||||||
danger: "bg-red-500 text-white hover:bg-red-600",
|
|
||||||
};
|
|
||||||
const sizes = {
|
|
||||||
sm: "px-3 py-1 text-sm",
|
|
||||||
md: "px-4 py-2 text-base",
|
|
||||||
lg: "px-6 py-3 text-lg",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
|
||||||
disabled={disabled}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Button.types.ts:**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { ButtonHTMLAttributes } from "react";
|
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: "primary" | "secondary" | "danger";
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
disabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) MOLECULES (`components/molecules/`)
|
|
||||||
|
|
||||||
**Mục đích:** Nhóm atoms tạo thành những UI unit nhỏ, tái sử dụng, có logic đơn
|
|
||||||
giản.
|
|
||||||
|
|
||||||
Có thể sử dụng `useState`, nhưng logic chủ yếu nằm ở parent component.
|
|
||||||
|
|
||||||
### Cấu trúc thư mục
|
|
||||||
|
|
||||||
```
|
|
||||||
components/molecules/
|
|
||||||
├── form-groups/
|
|
||||||
│ ├── FormField.tsx # Input + label + error message
|
|
||||||
│ ├── FormGroup.tsx # Label + input wrapper
|
|
||||||
│ └── FormGroup.types.ts # Props types
|
|
||||||
├── cards/
|
|
||||||
│ ├── ProductCard.tsx # Card hiển thị sản phẩm (image + name + price + btn)
|
|
||||||
│ ├── ShopCard.tsx # Card hiển thị quán
|
|
||||||
│ ├── ReviewCard.tsx # Card hiển thị review
|
|
||||||
│ └── Card.types.ts # Props types
|
|
||||||
├── ratings/
|
|
||||||
│ ├── RatingStars.tsx # Hiển thị 5 sao rating
|
|
||||||
│ ├── RatingInput.tsx # Input 5 sao (interactive)
|
|
||||||
│ └── Rating.types.ts # Props types
|
|
||||||
├── price-display/
|
|
||||||
│ ├── PriceTag.tsx # Hiển thị giá formatted
|
|
||||||
│ ├── PriceRange.tsx # Hiển thị range giá
|
|
||||||
│ └── Price.types.ts # Props types
|
|
||||||
├── search-bar/
|
|
||||||
│ ├── SearchInput.tsx # Search input với icon
|
|
||||||
│ ├── SearchBar.tsx # Search bar wrapper
|
|
||||||
│ └── Search.types.ts # Props types
|
|
||||||
├── breadcrumb/
|
|
||||||
│ ├── Breadcrumb.tsx # Breadcrumb navigation
|
|
||||||
│ └── Breadcrumb.types.ts # Props types
|
|
||||||
├── tabs/
|
|
||||||
│ ├── TabGroup.tsx # Tabs wrapper
|
|
||||||
│ ├── Tab.tsx # Individual tab
|
|
||||||
│ └── Tabs.types.ts # Props types
|
|
||||||
└── index.ts # Barrel export
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ví dụ Molecules
|
|
||||||
|
|
||||||
**ProductCard.tsx:**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import Button from "@/components/atoms/buttons/Button";
|
|
||||||
import Text from "@/components/atoms/typography/Text";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
import type { ProductCardProps } from "./Card.types";
|
|
||||||
|
|
||||||
export default function ProductCard({
|
|
||||||
product,
|
|
||||||
onAddToCart,
|
|
||||||
}: ProductCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-[color:var(--color-border)] bg-[color:var(--color-bg-card)] p-4 shadow-sm transition-shadow hover:shadow-md">
|
|
||||||
<div className="relative mb-3 h-48 w-full overflow-hidden rounded-md">
|
|
||||||
<Image
|
|
||||||
src={product.image}
|
|
||||||
alt={product.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Text variant="body1" className="font-semibold">
|
|
||||||
{product.name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
variant="caption"
|
|
||||||
className="text-[color:var(--color-text-secondary)]"
|
|
||||||
>
|
|
||||||
{product.description}
|
|
||||||
</Text>
|
|
||||||
<div className="mt-3 flex items-center justify-between">
|
|
||||||
<Text
|
|
||||||
variant="body2"
|
|
||||||
className="font-bold text-[color:var(--color-primary)]"
|
|
||||||
>
|
|
||||||
${product.price.toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
<Button size="sm" onClick={() => onAddToCart(product)}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) ORGANISMS (`components/organisms/`)
|
|
||||||
|
|
||||||
**Mục đích:** Khu vực UI phức tạp, độc lập, có logic riêng.
|
|
||||||
|
|
||||||
Kết hợp multiple molecules/atoms, có thể sử dụng contexts (useAuth, useCart,
|
|
||||||
etc.), state phức tạp.
|
|
||||||
|
|
||||||
### Cấu trúc thư mục
|
|
||||||
|
|
||||||
```
|
|
||||||
components/organisms/
|
|
||||||
├── navigation/
|
|
||||||
│ ├── Navbar.tsx # Sidebar category filter (cũ CartProduct)
|
|
||||||
│ ├── CategoryMenu.tsx # Category menu wrapper
|
|
||||||
│ └── Navigation.types.ts # Props types
|
|
||||||
├── cart/
|
|
||||||
│ ├── CartFab.tsx # Floating action button giỏ hàng
|
|
||||||
│ ├── CartSummary.tsx # Cart summary widget
|
|
||||||
│ ├── CartList.tsx # Danh sách sản phẩm trong giỏ
|
|
||||||
│ └── Cart.types.ts # Props types
|
|
||||||
├── product-grid/
|
|
||||||
│ ├── ProductGrid.tsx # Grid hiển thị danh sách sản phẩm
|
|
||||||
│ ├── ProductFilters.tsx # Bộ lọc sản phẩm (category, price, rating)
|
|
||||||
│ └── ProductGrid.types.ts # Props types
|
|
||||||
├── forms/
|
|
||||||
│ ├── LoginForm.tsx # Form đăng nhập (username + password + submit)
|
|
||||||
│ ├── RegisterForm.tsx # Form đăng ký
|
|
||||||
│ ├── CheckoutForm.tsx # Form thanh toán
|
|
||||||
│ ├── ReviewForm.tsx # Form đánh giá (modal content)
|
|
||||||
│ └── Forms.types.ts # Props types
|
|
||||||
├── modals/
|
|
||||||
│ ├── ReviewModal.tsx # Modal đánh giá (header + form + footer)
|
|
||||||
│ ├── ConfirmModal.tsx # Modal xác nhận generic
|
|
||||||
│ └── Modal.types.ts # Props types
|
|
||||||
├── shop-grid/
|
|
||||||
│ ├── ShopGrid.tsx # Grid hiển thị danh sách quán
|
|
||||||
│ ├── ShopFilters.tsx # Bộ lọc quán (location, rating)
|
|
||||||
│ └── ShopGrid.types.ts # Props types
|
|
||||||
├── hero-section/
|
|
||||||
│ ├── HeroSection.tsx # Banner hero cấp trang
|
|
||||||
│ └── Hero.types.ts # Props types
|
|
||||||
├── featured-section/
|
|
||||||
│ ├── FeaturedProducts.tsx # Section sản phẩm nổi bật
|
|
||||||
│ ├── FeaturedShops.tsx # Section quán nổi bật
|
|
||||||
│ └── Featured.types.ts # Props types
|
|
||||||
└── index.ts # Barrel export
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ví dụ Organisms
|
|
||||||
|
|
||||||
**ProductGrid.tsx:**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import ProductCard from "@/components/molecules/cards/ProductCard";
|
|
||||||
import { useCart } from "@/lib/cart-context";
|
|
||||||
import { MOCK_PRODUCTS } from "@/lib/constants";
|
|
||||||
import { useMenu } from "@/lib/menu-context";
|
|
||||||
|
|
||||||
import type { ProductGridProps } from "./ProductGrid.types";
|
|
||||||
|
|
||||||
export default function ProductGrid({ searchQuery = "" }: ProductGridProps) {
|
|
||||||
const { activeCategory } = useMenu();
|
|
||||||
const { addToCart } = useCart();
|
|
||||||
|
|
||||||
const filtered = MOCK_PRODUCTS.filter((product) => {
|
|
||||||
const matchCategory =
|
|
||||||
activeCategory === "all" || product.category === activeCategory;
|
|
||||||
const matchSearch =
|
|
||||||
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
product.description.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
return matchCategory && matchSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{filtered.map((product) => (
|
|
||||||
<ProductCard
|
|
||||||
key={product.id}
|
|
||||||
product={product}
|
|
||||||
onAddToCart={addToCart}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) TEMPLATES (`components/templates/`)
|
|
||||||
|
|
||||||
**Mục đích:** Bố cục cấp trang, cấu trúc nội dung, không có data cụ thể.
|
|
||||||
|
|
||||||
Chứa layout và structure của page, nhưng data được truyền từ page component.
|
|
||||||
|
|
||||||
### Cấu trúc thư mục
|
|
||||||
|
|
||||||
```
|
|
||||||
components/templates/
|
|
||||||
├── main-layout/
|
|
||||||
│ ├── MainLayout.tsx # Layout chính (header + sidebar + content + footer)
|
|
||||||
│ ├── MainLayout.types.ts # Props types
|
|
||||||
│ └── styles.ts # Responsive grid layout logic
|
|
||||||
├── feed-layout/
|
|
||||||
│ ├── FeedLayout.tsx # Layout feed (khám phá quán)
|
|
||||||
│ └── FeedLayout.types.ts # Props types
|
|
||||||
├── manager-layout/
|
|
||||||
│ ├── ManagerLayout.tsx # Layout manager dashboard
|
|
||||||
│ └── ManagerLayout.types.ts # Props types
|
|
||||||
├── checkout-layout/
|
|
||||||
│ ├── CheckoutLayout.tsx # Layout thanh toán (steps, cart, form)
|
|
||||||
│ └── CheckoutLayout.types.ts # Props types
|
|
||||||
├── auth-layout/
|
|
||||||
│ ├── AuthLayout.tsx # Layout auth (login/register)
|
|
||||||
│ └── AuthLayout.types.ts # Props types
|
|
||||||
└── index.ts # Barrel export
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ví dụ Templates
|
|
||||||
|
|
||||||
**MainLayout.tsx:**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import Navbar from "@/components/organisms/navigation/Navbar";
|
|
||||||
import Footer from "@/layouts/footer";
|
|
||||||
import Header from "@/layouts/header";
|
|
||||||
|
|
||||||
import type { MainLayoutProps } from "./MainLayout.types";
|
|
||||||
|
|
||||||
export default function MainLayout({ children }: MainLayoutProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col">
|
|
||||||
<Header />
|
|
||||||
<div className="flex flex-1">
|
|
||||||
{/* Sidebar - ẩn trên mobile */}
|
|
||||||
<nav className="hidden w-64 border-r border-[color:var(--color-border)] bg-[color:var(--color-bg-sidebar)] md:block">
|
|
||||||
<Navbar />
|
|
||||||
</nav>
|
|
||||||
{/* Main content */}
|
|
||||||
<main className="flex-1 p-4 md:p-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) PAGES (`app/*/page.tsx`)
|
|
||||||
|
|
||||||
**Mục đích:** Các phiên bản cụ thể với dữ liệu thật, logic cấp trang.
|
|
||||||
|
|
||||||
Server/client components sử dụng templates, organisms, nhận data từ API/context.
|
|
||||||
|
|
||||||
### Ví dụ Pages
|
|
||||||
|
|
||||||
**app/(main)/page.tsx:**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import FeaturedSection from "@/components/organisms/featured-section/FeaturedSection";
|
|
||||||
import ProductGrid from "@/components/organisms/product-grid/ProductGrid";
|
|
||||||
import MainLayout from "@/components/templates/main-layout/MainLayout";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export default function MainPage() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainLayout>
|
|
||||||
<FeaturedSection />
|
|
||||||
<div className="mt-8">
|
|
||||||
<ProductGrid searchQuery={searchQuery} />
|
|
||||||
</div>
|
|
||||||
</MainLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) File Hierarchy Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
components/
|
|
||||||
├── atoms/
|
|
||||||
│ ├── buttons/
|
|
||||||
│ ├── inputs/
|
|
||||||
│ ├── badges/
|
|
||||||
│ ├── icons/
|
|
||||||
│ ├── typography/
|
|
||||||
│ ├── dividers/
|
|
||||||
│ ├── loaders/
|
|
||||||
│ └── index.ts
|
|
||||||
├── molecules/
|
|
||||||
│ ├── form-groups/
|
|
||||||
│ ├── cards/
|
|
||||||
│ ├── ratings/
|
|
||||||
│ ├── price-display/
|
|
||||||
│ ├── search-bar/
|
|
||||||
│ ├── breadcrumb/
|
|
||||||
│ ├── tabs/
|
|
||||||
│ └── index.ts
|
|
||||||
├── organisms/
|
|
||||||
│ ├── navigation/
|
|
||||||
│ ├── cart/
|
|
||||||
│ ├── product-grid/
|
|
||||||
│ ├── forms/
|
|
||||||
│ ├── modals/
|
|
||||||
│ ├── shop-grid/
|
|
||||||
│ ├── hero-section/
|
|
||||||
│ ├── featured-section/
|
|
||||||
│ └── index.ts
|
|
||||||
├── templates/
|
|
||||||
│ ├── main-layout/
|
|
||||||
│ ├── feed-layout/
|
|
||||||
│ ├── manager-layout/
|
|
||||||
│ ├── checkout-layout/
|
|
||||||
│ ├── auth-layout/
|
|
||||||
│ └── index.ts
|
|
||||||
└── ATOMIC_DESIGN.md (this file)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Migration Guide (Old → New)
|
|
||||||
|
|
||||||
### Old Structure → New Structure Mapping
|
|
||||||
|
|
||||||
| Old File | New Location | Type |
|
|
||||||
| ----------------- | ---------------------------------- | -------- |
|
|
||||||
| `CartProduct.tsx` | `molecules/cards/ProductCard.tsx` | Molecule |
|
|
||||||
| `Navbar.tsx` | `organisms/navigation/Navbar.tsx` | Organism |
|
|
||||||
| `CartFab.tsx` | `organisms/cart/CartFab.tsx` | Organism |
|
|
||||||
| `ReviewModal.tsx` | `organisms/modals/ReviewModal.tsx` | Organism |
|
|
||||||
|
|
||||||
### Migration Steps
|
|
||||||
|
|
||||||
1. Create new directory structure under `components/`
|
|
||||||
2. Move existing components to appropriate levels (atoms → molecules →
|
|
||||||
organisms)
|
|
||||||
3. Extract shared styles/logic into atoms
|
|
||||||
4. Update imports in `app/` pages
|
|
||||||
5. Test responsiveness at each breakpoint
|
|
||||||
6. Update `COMPONENTS.md` with new structure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Best Practices
|
|
||||||
|
|
||||||
### Atoms Development
|
|
||||||
|
|
||||||
- ✅ Reusable across entire project
|
|
||||||
- ✅ No business logic
|
|
||||||
- ✅ No context/hooks (useAuth, useCart)
|
|
||||||
- ✅ Pure props-based
|
|
||||||
- ✅ Full TypeScript typing
|
|
||||||
- ❌ No "use client" needed (unless interactive, e.g., Button)
|
|
||||||
|
|
||||||
### Molecules Development
|
|
||||||
|
|
||||||
- ✅ Combines multiple atoms
|
|
||||||
- ✅ Simple state (open/close, hover state)
|
|
||||||
- ✅ No complex business logic
|
|
||||||
- ✅ Can use useState for UI state
|
|
||||||
- ✅ Reusable in multiple contexts
|
|
||||||
- ❌ No global state (useAuth, useCart)
|
|
||||||
|
|
||||||
### Organisms Development
|
|
||||||
|
|
||||||
- ✅ Complex UI sections
|
|
||||||
- ✅ Can use contexts (useAuth, useCart, useMenu)
|
|
||||||
- ✅ Business logic
|
|
||||||
- ✅ Always "use client"
|
|
||||||
- ✅ Filter, sort, complex interactions
|
|
||||||
- ❌ Not reusable across different page types
|
|
||||||
|
|
||||||
### Templates Development
|
|
||||||
|
|
||||||
- ✅ Page layout structure
|
|
||||||
- ✅ Composition of organisms + layout
|
|
||||||
- ✅ No data fetching/business logic
|
|
||||||
- ✅ Children prop pattern
|
|
||||||
- ✅ Props for customization
|
|
||||||
- ❌ No hardcoded data
|
|
||||||
|
|
||||||
### Pages Development
|
|
||||||
|
|
||||||
- ✅ Specific page implementations
|
|
||||||
- ✅ Route-specific logic
|
|
||||||
- ✅ Data integration
|
|
||||||
- ✅ Context usage at page level
|
|
||||||
- ✅ State management orchestration
|
|
||||||
- ❌ No UI component definitions (use organisms)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9) Import Patterns
|
|
||||||
|
|
||||||
### Atoms
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import Button from "@/components/atoms/buttons/Button";
|
|
||||||
import Text from "@/components/atoms/typography/Text";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Molecules
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import ProductCard from "@/components/molecules/cards/ProductCard";
|
|
||||||
import FormField from "@/components/molecules/form-groups/FormField";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Organisms
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import LoginForm from "@/components/organisms/forms/LoginForm";
|
|
||||||
import ProductGrid from "@/components/organisms/product-grid/ProductGrid";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import MainLayout from "@/components/templates/main-layout/MainLayout";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Barrel Exports
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Usage
|
|
||||||
import { Button, Text } from "@/components/atoms";
|
|
||||||
|
|
||||||
// components/atoms/index.ts
|
|
||||||
export { default as Button } from "./buttons/Button";
|
|
||||||
export { default as Text } from "./typography/Text";
|
|
||||||
export * from "./buttons/Button.types";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10) Common Patterns
|
|
||||||
|
|
||||||
### Creating a New Atom
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// atoms/buttons/NewButton.tsx
|
|
||||||
export default function NewButton({ variant, ...props }: Props) {
|
|
||||||
return <button className={styles[variant]} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// atoms/buttons/NewButton.types.ts
|
|
||||||
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant: "type1" | "type2";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating a New Molecule
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// molecules/cards/NewCard.tsx
|
|
||||||
export default function NewCard({ item, onAction }: Props) {
|
|
||||||
const [hover, setHover] = useState(false);
|
|
||||||
return (
|
|
||||||
<div onMouseEnter={() => setHover(true)}>{/* atoms composition */}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating a New Organism
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// organisms/sections/NewSection.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import ProductCard from "@/components/molecules/cards/ProductCard";
|
|
||||||
import { useAuth } from "@/lib/auth-context";
|
|
||||||
|
|
||||||
export default function NewSection() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
// business logic, filtering, etc.
|
|
||||||
return <section>{/* molecules composition + logic */}</section>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating a New Template
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// templates/layouts/NewTemplate.tsx
|
|
||||||
export default function NewTemplate({ children, header }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="layout">
|
|
||||||
<header>{header}</header>
|
|
||||||
<main>{children}</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11) Testing & Documentation
|
|
||||||
|
|
||||||
### For Each Component Level
|
|
||||||
|
|
||||||
#### Atoms
|
|
||||||
|
|
||||||
- Unit test: Props validation, styling
|
|
||||||
- Storybook: All variants, all states
|
|
||||||
- Doc: Props interface, usage examples
|
|
||||||
|
|
||||||
#### Molecules
|
|
||||||
|
|
||||||
- Integration test: Atoms composition
|
|
||||||
- Storybook: Different molecule states
|
|
||||||
- Doc: Props, behavior, dependencies
|
|
||||||
|
|
||||||
#### Organisms
|
|
||||||
|
|
||||||
- Integration test: With contexts mocked
|
|
||||||
- E2E: User interactions
|
|
||||||
- Doc: Logic flow, API integration points
|
|
||||||
|
|
||||||
#### Templates
|
|
||||||
|
|
||||||
- Layout test: Responsive grid layouts
|
|
||||||
- Visual: Desktop/tablet/mobile
|
|
||||||
- Doc: Layout structure, breakpoints
|
|
||||||
|
|
||||||
#### Pages
|
|
||||||
|
|
||||||
- E2E test: Full user flows
|
|
||||||
- Performance: Metrics
|
|
||||||
- Doc: Route, data flow, features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12) Performance Optimization
|
|
||||||
|
|
||||||
### Code Splitting
|
|
||||||
|
|
||||||
- Atoms: Always bundled (small, frequently used)
|
|
||||||
- Molecules: Bundled by page/feature
|
|
||||||
- Organisms: Use `dynamic()` for heavy sections
|
|
||||||
- Templates: Bundled by layout type
|
|
||||||
- Pages: Automatic splitting by Next.js
|
|
||||||
|
|
||||||
### Lazy Loading Example
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
const ReviewModal = dynamic(
|
|
||||||
() => import("@/components/organisms/modals/ReviewModal"),
|
|
||||||
{ loading: () => <Spinner /> },
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Image Optimization
|
|
||||||
|
|
||||||
- Use Next.js `Image` component (atoms/molecules)
|
|
||||||
- Optimize with `priority` for above-fold
|
|
||||||
- Use responsive sizes: `sizes="(max-width: 640px) 100vw, 50vw"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13) Accessibility
|
|
||||||
|
|
||||||
### All Levels
|
|
||||||
|
|
||||||
- Semantic HTML: `<button>`, `<a>`, `<form>`, `<nav>`
|
|
||||||
- ARIA attributes: `aria-label`, `aria-expanded`, `role`
|
|
||||||
- Keyboard navigation: Tab order, focus visible
|
|
||||||
- Color contrast: WCAG AA minimum
|
|
||||||
- Alt text: All images have meaningful `alt`
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<button
|
|
||||||
aria-label="Add to cart"
|
|
||||||
className="focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<CartIcon aria-hidden="true" />
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14) Version Control & Documentation
|
|
||||||
|
|
||||||
### File Format
|
|
||||||
|
|
||||||
```
|
|
||||||
components/
|
|
||||||
├── atoms/
|
|
||||||
│ ├── buttons/
|
|
||||||
│ │ ├── Button.tsx
|
|
||||||
│ │ ├── Button.types.ts
|
|
||||||
│ │ └── Button.md # Component documentation
|
|
||||||
│ └── ...
|
|
||||||
├── molecules/
|
|
||||||
│ ├── cards/
|
|
||||||
│ │ ├── ProductCard.tsx
|
|
||||||
│ │ ├── Card.types.ts
|
|
||||||
│ │ └── ProductCard.md
|
|
||||||
│ └── ...
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documentation Template
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# ProductCard
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Display individual product with image, name, price, and action button.
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
- `product: Product` - Product data
|
|
||||||
- `onAddToCart: (product: Product) => void` - Add to cart handler
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
\`\`\`tsx <ProductCard product={item} onAddToCart={addToCart} /> \`\`\`
|
|
||||||
|
|
||||||
## Variants
|
|
||||||
|
|
||||||
- Image with loading state
|
|
||||||
- With discount badge
|
|
||||||
- With rating stars
|
|
||||||
|
|
||||||
## Responsive
|
|
||||||
|
|
||||||
- Mobile: Single column, full width
|
|
||||||
- Desktop: Grid layout
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Cấu trúc này cung cấp: ✅ **Scalability**: Dễ thêm components mới ✅
|
|
||||||
**Reusability**: Tối đa tái sử dụng ✅ **Maintainability**: Code dễ hiểu, tìm
|
|
||||||
kiếm ✅ **Testability**: Mỗi level có logic riêng ✅ **Performance**: Smart
|
|
||||||
code-splitting
|
|
||||||
+39
-20
@@ -1,9 +1,7 @@
|
|||||||
# Phase 1 Implementation Summary: Atomic Design Foundation
|
# Phase 1 Implementation Summary: Atomic Design Foundation
|
||||||
|
|
||||||
**Status:** ✅ COMPLETE
|
**Status:** ✅ COMPLETE **Date:** 2026-04-03 **Commit:** eca619b **Branch:**
|
||||||
**Date:** 2026-04-03
|
atomic_design
|
||||||
**Commit:** eca619b
|
|
||||||
**Branch:** atomic_design
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,6 +10,7 @@
|
|||||||
### ✨ Created 21 Atom Components
|
### ✨ Created 21 Atom Components
|
||||||
|
|
||||||
#### Buttons (2 components)
|
#### Buttons (2 components)
|
||||||
|
|
||||||
```
|
```
|
||||||
components/atoms/buttons/
|
components/atoms/buttons/
|
||||||
├── Button.tsx (4 variants: primary, secondary, danger, ghost)
|
├── Button.tsx (4 variants: primary, secondary, danger, ghost)
|
||||||
@@ -20,12 +19,14 @@ components/atoms/buttons/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Multiple size options (sm, md, lg)
|
- Multiple size options (sm, md, lg)
|
||||||
- Icon support with positioning (left/right)
|
- Icon support with positioning (left/right)
|
||||||
- Full HTML button attribute support
|
- Full HTML button attribute support
|
||||||
- Active/disabled states with visual feedback
|
- Active/disabled states with visual feedback
|
||||||
|
|
||||||
#### Inputs (3 components)
|
#### Inputs (3 components)
|
||||||
|
|
||||||
```
|
```
|
||||||
components/atoms/inputs/
|
components/atoms/inputs/
|
||||||
├── TextInput.tsx (with label, error, icon support)
|
├── TextInput.tsx (with label, error, icon support)
|
||||||
@@ -35,12 +36,14 @@ components/atoms/inputs/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Error state handling with red border
|
- Error state handling with red border
|
||||||
- Icon integration with callback support
|
- Icon integration with callback support
|
||||||
- Accessible form field structure
|
- Accessible form field structure
|
||||||
- Controlled/uncontrolled patterns
|
- Controlled/uncontrolled patterns
|
||||||
|
|
||||||
#### Typography (3 components)
|
#### Typography (3 components)
|
||||||
|
|
||||||
```
|
```
|
||||||
components/atoms/typography/
|
components/atoms/typography/
|
||||||
├── Heading.tsx (h1-h6 with semantic sizing)
|
├── Heading.tsx (h1-h6 with semantic sizing)
|
||||||
@@ -50,11 +53,13 @@ components/atoms/typography/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Semantic HTML (`<h1>` through `<h6>`)
|
- Semantic HTML (`<h1>` through `<h6>`)
|
||||||
- Consistent font sizing and weights
|
- Consistent font sizing and weights
|
||||||
- CSS variable color application
|
- CSS variable color application
|
||||||
|
|
||||||
#### Badges (2 components)
|
#### Badges (2 components)
|
||||||
|
|
||||||
```
|
```
|
||||||
components/atoms/badges/
|
components/atoms/badges/
|
||||||
├── Badge.tsx (5 variants: primary, secondary, success, danger, warning)
|
├── Badge.tsx (5 variants: primary, secondary, success, danger, warning)
|
||||||
@@ -63,11 +68,13 @@ components/atoms/badges/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Multiple size options (sm, md)
|
- Multiple size options (sm, md)
|
||||||
- Formatted price output (VND/USD)
|
- Formatted price output (VND/USD)
|
||||||
- Color-coded variants
|
- Color-coded variants
|
||||||
|
|
||||||
#### Dividers (1 component)
|
#### Dividers (1 component)
|
||||||
|
|
||||||
```
|
```
|
||||||
components/atoms/dividers/
|
components/atoms/dividers/
|
||||||
├── Divider.tsx (horizontal/vertical separators)
|
├── Divider.tsx (horizontal/vertical separators)
|
||||||
@@ -108,12 +115,14 @@ components/atoms/dividers/
|
|||||||
### 🔧 Updated Existing Components
|
### 🔧 Updated Existing Components
|
||||||
|
|
||||||
#### CartProduct.tsx
|
#### CartProduct.tsx
|
||||||
|
|
||||||
- ✅ Replaced inline button with `<Button>` atom
|
- ✅ Replaced inline button with `<Button>` atom
|
||||||
- ✅ Replaced inline text with `<Text>` atom
|
- ✅ Replaced inline text with `<Text>` atom
|
||||||
- ✅ Replaced inline caption with `<Caption>` atom
|
- ✅ Replaced inline caption with `<Caption>` atom
|
||||||
- **Result:** Cleaner component, more maintainable
|
- **Result:** Cleaner component, more maintainable
|
||||||
|
|
||||||
#### ReviewModal.tsx
|
#### ReviewModal.tsx
|
||||||
|
|
||||||
- ✅ Replaced modal buttons with `<Button>` atoms
|
- ✅ Replaced modal buttons with `<Button>` atoms
|
||||||
- ✅ Replaced heading with `<Heading>` atom
|
- ✅ Replaced heading with `<Heading>` atom
|
||||||
- ✅ Replaced text with `<Text>` atom
|
- ✅ Replaced text with `<Text>` atom
|
||||||
@@ -124,15 +133,15 @@ components/atoms/dividers/
|
|||||||
|
|
||||||
## 📊 Statistics
|
## 📊 Statistics
|
||||||
|
|
||||||
| Metric | Count |
|
| Metric | Count |
|
||||||
|--------|-------|
|
| ------------------------- | --------------------- |
|
||||||
| **New Atoms Created** | 21 |
|
| **New Atoms Created** | 21 |
|
||||||
| **Type Files** | 5 |
|
| **Type Files** | 5 |
|
||||||
| **Total Component Files** | 26 |
|
| **Total Component Files** | 26 |
|
||||||
| **Documentation Files** | 5 |
|
| **Documentation Files** | 5 |
|
||||||
| **New Lines of Code** | ~2,500 (atoms + docs) |
|
| **New Lines of Code** | ~2,500 (atoms + docs) |
|
||||||
| **Build Status** | ✅ Success |
|
| **Build Status** | ✅ Success |
|
||||||
| **TypeScript Errors** | 0 (in atoms) |
|
| **TypeScript Errors** | 0 (in atoms) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -174,6 +183,7 @@ components/atoms/
|
|||||||
## ✅ Quality Assurance
|
## ✅ Quality Assurance
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
✓ Compiled successfully in 3.7s
|
✓ Compiled successfully in 3.7s
|
||||||
✓ npm run build: PASS
|
✓ npm run build: PASS
|
||||||
@@ -182,18 +192,21 @@ components/atoms/
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Type Safety
|
### Type Safety
|
||||||
|
|
||||||
- ✅ Full TypeScript coverage
|
- ✅ Full TypeScript coverage
|
||||||
- ✅ All props typed
|
- ✅ All props typed
|
||||||
- ✅ No `any` types
|
- ✅ No `any` types
|
||||||
- ✅ Interface exports for reuse
|
- ✅ Interface exports for reuse
|
||||||
|
|
||||||
### Accessibility
|
### Accessibility
|
||||||
|
|
||||||
- ✅ Semantic HTML throughout
|
- ✅ Semantic HTML throughout
|
||||||
- ✅ ARIA labels where needed
|
- ✅ ARIA labels where needed
|
||||||
- ✅ Focus visible states
|
- ✅ Focus visible states
|
||||||
- ✅ Keyboard navigation support
|
- ✅ Keyboard navigation support
|
||||||
|
|
||||||
### Consistency
|
### Consistency
|
||||||
|
|
||||||
- ✅ CSS variables for theming
|
- ✅ CSS variables for theming
|
||||||
- ✅ Consistent naming conventions
|
- ✅ Consistent naming conventions
|
||||||
- ✅ Barrel exports for clean imports
|
- ✅ Barrel exports for clean imports
|
||||||
@@ -204,27 +217,30 @@ components/atoms/
|
|||||||
## 📖 Usage Examples
|
## 📖 Usage Examples
|
||||||
|
|
||||||
### Basic Button
|
### Basic Button
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
|
|
||||||
<Button variant="primary" size="sm" icon="fa-cart-plus">
|
<Button variant="primary" size="sm" icon="fa-cart-plus">
|
||||||
Mua
|
Mua
|
||||||
</Button>
|
</Button>;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Form Field
|
### Form Field
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { TextInput, Button } from "@/components/atoms";
|
import { Button, TextInput } from "@/components/atoms";
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
error={emailError}
|
error={emailError}
|
||||||
/>
|
/>;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Heading, Text, Caption } from "@/components/atoms";
|
import { Heading, Text, Caption } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -234,6 +250,7 @@ import { Heading, Text, Caption } from "@/components/atoms";
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Price Display
|
### Price Display
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { PriceBadge } from "@/components/atoms";
|
import { PriceBadge } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -275,6 +292,7 @@ import { PriceBadge } from "@/components/atoms";
|
|||||||
## 🚀 Next Steps (Phase 2)
|
## 🚀 Next Steps (Phase 2)
|
||||||
|
|
||||||
### Molecules to Create
|
### Molecules to Create
|
||||||
|
|
||||||
1. **ProductCard** - Image + Text + Badge + Button
|
1. **ProductCard** - Image + Text + Badge + Button
|
||||||
2. **FormField** - Label + Input + Error + Validation
|
2. **FormField** - Label + Input + Error + Validation
|
||||||
3. **SearchBar** - SearchInput + Button
|
3. **SearchBar** - SearchInput + Button
|
||||||
@@ -288,6 +306,7 @@ import { PriceBadge } from "@/components/atoms";
|
|||||||
## 💡 Design Decisions
|
## 💡 Design Decisions
|
||||||
|
|
||||||
### Why Barrel Exports?
|
### Why Barrel Exports?
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// ❌ Bad: Scattered imports
|
// ❌ Bad: Scattered imports
|
||||||
import Button from "@/components/atoms/buttons/Button";
|
import Button from "@/components/atoms/buttons/Button";
|
||||||
@@ -298,12 +317,14 @@ import { Button, Text } from "@/components/atoms";
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Why TypeScript Types in Separate Files?
|
### Why TypeScript Types in Separate Files?
|
||||||
|
|
||||||
- Clear separation of concerns
|
- Clear separation of concerns
|
||||||
- Easier to maintain interfaces
|
- Easier to maintain interfaces
|
||||||
- Reusable types across modules
|
- Reusable types across modules
|
||||||
- Better for large components
|
- Better for large components
|
||||||
|
|
||||||
### Why CSS Variables?
|
### Why CSS Variables?
|
||||||
|
|
||||||
- Single point for theme changes
|
- Single point for theme changes
|
||||||
- Easy dark mode switching
|
- Easy dark mode switching
|
||||||
- Consistent across all atoms
|
- Consistent across all atoms
|
||||||
@@ -357,7 +378,5 @@ feat: Implement Atomic Design Phase 1 - Atoms Foundation
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Status:** Ready for Phase 2 🚀
|
**Status:** Ready for Phase 2 🚀 **Last Updated:** 2026-04-03 **Contributor:**
|
||||||
**Last Updated:** 2026-04-03
|
Claude Code + Anthropic AI
|
||||||
**Contributor:** Claude Code + Anthropic AI
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
Dự án Frontend cho hệ thống đặt món cà phê, xây dựng bằng Next.js App Router,
|
Dự án Frontend cho hệ thống đặt món cà phê, xây dựng bằng Next.js App Router,
|
||||||
React 19, TypeScript và Tailwind CSS v4.
|
React 19, TypeScript và Tailwind CSS v4.
|
||||||
|
|
||||||
|
_Last Updated: 2026-04-18_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mô Tả Dự Án
|
## Mô Tả Dự Án
|
||||||
@@ -11,7 +13,9 @@ Giao diện người dùng (frontend) cho hệ thống đặt và bán đồ u
|
|||||||
|
|
||||||
### Tính Năng Hiện Tại
|
### Tính Năng Hiện Tại
|
||||||
|
|
||||||
#### 1. **Trang Đăng Nhập & Đăng Ký** (`app/(main)/login`, `app/(main)/register`)
|
#### Tính Năng Khách Hàng (Customer Features)
|
||||||
|
|
||||||
|
##### 1. **Trang Đăng Nhập & Đăng Ký** (`app/(main)/login`, `app/(main)/register`)
|
||||||
|
|
||||||
- Form đăng nhập với validation (username, password)
|
- Form đăng nhập với validation (username, password)
|
||||||
- Form đăng ký với xác thực OTP
|
- Form đăng ký với xác thực OTP
|
||||||
@@ -19,7 +23,7 @@ Giao diện người dùng (frontend) cho hệ thống đặt và bán đồ u
|
|||||||
- Hỗ trợ 3 loại tài khoản: Manager, Staff, Customer
|
- Hỗ trợ 3 loại tài khoản: Manager, Staff, Customer
|
||||||
- Hiển thị thông tin shop trong form
|
- Hiển thị thông tin shop trong form
|
||||||
|
|
||||||
#### 2. **Trang Khám Phá Quán Nước** (`app/(feed)/feed`)
|
##### 2. **Trang Khám Phá Quán Nước** (`app/(feed)/feed`)
|
||||||
|
|
||||||
- Danh sách các quán cà phê
|
- Danh sách các quán cà phê
|
||||||
- Tìm kiếm theo tên quán và địa chỉ
|
- Tìm kiếm theo tên quán và địa chỉ
|
||||||
@@ -27,9 +31,7 @@ Giao diện người dùng (frontend) cho hệ thống đặt và bán đồ u
|
|||||||
- Responsive layout (grid 1/2/3 cột)
|
- Responsive layout (grid 1/2/3 cột)
|
||||||
- Empty state khi không tìm thấy quán
|
- Empty state khi không tìm thấy quán
|
||||||
|
|
||||||
#### 3. **Trang Chính - Duyệt Thực Đơn** (`app/(main)`)
|
##### 3. **Trang Chính - Duyệt Thực Đơn** (`app/(main)`)
|
||||||
|
|
||||||
Dành cho khách hàng:
|
|
||||||
|
|
||||||
- Sidebar collapsible (64px/240px) - danh mục sản phẩm
|
- Sidebar collapsible (64px/240px) - danh mục sản phẩm
|
||||||
- Grid sản phẩm responsive (1-5 cột tuỳ thiết bị)
|
- Grid sản phẩm responsive (1-5 cột tuỳ thiết bị)
|
||||||
@@ -38,47 +40,78 @@ Dành cho khách hàng:
|
|||||||
- Lọc tự động theo trạng thái available
|
- Lọc tự động theo trạng thái available
|
||||||
- Mobile menu: scrollable category tabs (< md)
|
- Mobile menu: scrollable category tabs (< md)
|
||||||
|
|
||||||
#### 4. **Trang Thanh Toán** (`app/(main)/payment`)
|
##### 4. **Trang Thanh Toán** (`app/(main)/payment`)
|
||||||
|
|
||||||
- Bảng danh sách sản phẩm trong giỏ hàng với điều chỉnh số lượng
|
- Bảng danh sách sản phẩm trong giỏ hàng với điều chỉnh số lượng
|
||||||
- Invoice aside sticky với tổng giá và nút thanh toán (Tiền mặt, QR)
|
- Invoice aside sticky với tổng giá và nút thanh toán (Tiền mặt, QR)
|
||||||
- Modal đánh giá 5 sao dành riêng cho khách hàng (ReviewModal)
|
- Modal đánh giá 5 sao dành riêng cho khách hàng (ReviewModal)
|
||||||
|
- **CashPaymentModal:** nhập tiền mặt, tính tiền thối, báo lỗi nếu tiền không đủ
|
||||||
|
- **QRPaymentModal:** tạo mã QR thanh toán (qrcode.react), tự xác nhận sau 5
|
||||||
|
giây đếm ngược
|
||||||
|
- **PaymentSuccessModal:** xác nhận thanh toán thành công, tự động xoá giỏ hàng
|
||||||
|
|
||||||
#### 5. **Hệ Thống Giỏ Hàng** (`lib/cart-context.tsx`)
|
##### 5. **Hệ Thống Giỏ Hàng** (`lib/cart-context.tsx`)
|
||||||
|
|
||||||
- Lưu trữ trạng thái giỏ trong localStorage
|
- Lưu trữ trạng thái giỏ trong localStorage
|
||||||
- Thêm/xóa/tăng/giảm số lượng sản phẩm
|
- Thêm/xóa/tăng/giảm số lượng sản phẩm
|
||||||
- Tính tổng giá và số mặt hàng
|
- Tính tổng giá và số mặt hàng
|
||||||
- Persist dữ liệu giữa các session
|
- Persist dữ liệu giữa các session
|
||||||
|
- `clearCart()` để xoá toàn bộ giỏ sau thanh toán
|
||||||
|
|
||||||
#### 6. **Hệ Thống Xác Thực** (`lib/auth-context.tsx`)
|
##### 6. **Hệ Thống Xác Thực** (`lib/auth-context.tsx`)
|
||||||
|
|
||||||
- Quản lý trạng thái người dùng (login/logout/register)
|
- Quản lý trạng thái người dùng (login/logout/register)
|
||||||
- Lưu thông tin user trong localStorage
|
- Lưu thông tin user trong localStorage
|
||||||
- Mock auth database với 3 loại tài khoản
|
- Mock auth database với 3 loại tài khoản
|
||||||
- Hỗ trợ hoàn tất đăng ký qua OTP
|
- Hỗ trợ hoàn tất đăng ký qua OTP
|
||||||
|
|
||||||
#### 7. **Hệ Thống Danh Mục** (`lib/menu-context.tsx`)
|
##### 7. **Hệ Thống Danh Mục** (`lib/menu-context.tsx`)
|
||||||
|
|
||||||
- Chia sẻ trạng thái category giữa Header mobile và Sidebar
|
- Chia sẻ trạng thái category giữa Header mobile và Sidebar
|
||||||
- Tự động clear search khi thay đổi category
|
- Tự động clear search khi thay đổi category
|
||||||
|
|
||||||
#### 8. **Manager Dashboard** (`app/(manager)/manager`)
|
#### Tính Năng Quản Lý (Manager Features)
|
||||||
|
|
||||||
Dành cho quản lý:
|
##### 8. **Manager Dashboard** (`app/(manager)/manager`) — ~70% hoàn thành
|
||||||
|
|
||||||
- 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
|
||||||
- CRUD sản phẩm, combo, danh mục qua modals
|
Analytics
|
||||||
- Auth guard: tự động redirect non-manager về `/`
|
- Auth guard: tự động redirect non-manager về `/`
|
||||||
|
- **Products tab:** CRUD sản phẩm qua ProductModal, bao gồm:
|
||||||
|
- Upload ảnh sản phẩm với xem trước, nút "Đổi ảnh" và xoá ảnh
|
||||||
|
- Giao diện vùng kéo thả với dashed border
|
||||||
|
- Toggle trạng thái available
|
||||||
|
- Dialog xác nhận xoá
|
||||||
|
- **Combos tab:** CRUD combo qua ComboModal, toggle trạng thái, xác nhận xoá
|
||||||
|
- **Categories tab:** CRUD danh mục qua CategoryModal, xác nhận xoá
|
||||||
|
|
||||||
#### 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
|
||||||
- Lọc theo danh mục
|
- Lọc theo danh mục
|
||||||
|
|
||||||
|
#### Tính Năng Nhân Viên (Staff Features)
|
||||||
|
|
||||||
|
##### 10. **Lịch Làm Việc Nhân Viên** (`app/(staff)/staff/schedule`)
|
||||||
|
|
||||||
|
- Xem lịch theo tuần và theo tháng
|
||||||
|
- Mobile shift view (danh sách ca theo ngày)
|
||||||
|
- Lọc theo phòng ban / bộ phận
|
||||||
|
- Modal tạo ca làm việc mới (ShiftCreateModal)
|
||||||
|
- Modal xem chi tiết ca (ShiftDetailModal)
|
||||||
|
- Auth guard: chỉ staff/manager mới truy cập được
|
||||||
|
|
||||||
|
#### Cải Thiện Tiếp Cận (Accessibility)
|
||||||
|
|
||||||
|
- Thuộc tính `title` trên các nút icon (tooltip cho screen reader)
|
||||||
|
- ARIA roles trên modals: `role="dialog"`, `aria-modal`, `aria-labelledby`
|
||||||
|
- Semantic HTML: nhãn form với `htmlFor` đúng chuẩn
|
||||||
|
- `alt` text đầy đủ cho ảnh sản phẩm
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cách Chạy Dự Án
|
## Cách Chạy Dự Án
|
||||||
@@ -142,9 +175,12 @@ frondend/
|
|||||||
| | +-- layout.tsx # Feed layout
|
| | +-- layout.tsx # Feed layout
|
||||||
| | +-- feed/page.tsx # Trang khám phá quán nước (/feed)
|
| | +-- feed/page.tsx # Trang khám phá quán nước (/feed)
|
||||||
| +-- (manager)/ # Manager route group
|
| +-- (manager)/ # Manager route group
|
||||||
| +-- layout.tsx # Manager layout - auth guard + ManagerProvider
|
| | +-- layout.tsx # Manager layout - auth guard + ManagerProvider
|
||||||
| +-- manager/page.tsx # Manager Dashboard (/manager)
|
| | +-- manager/page.tsx # Manager Dashboard (/manager)
|
||||||
| +-- manager/analytics/page.tsx # Financial Analytics (/manager/analytics)
|
| | +-- manager/analytics/page.tsx # Financial Analytics (/manager/analytics)
|
||||||
|
| +-- (staff)/ # Staff route group
|
||||||
|
| +-- layout.tsx # Staff layout - auth guard
|
||||||
|
| +-- staff/schedule/page.tsx # Lịch làm việc nhân viên (/staff/schedule)
|
||||||
+-- components/ # Atomic Design UI components
|
+-- components/ # Atomic Design UI components
|
||||||
| +-- atoms/ # Nguyên tử: Button, Input, Badge, Text...
|
| +-- atoms/ # Nguyên tử: Button, Input, Badge, Text...
|
||||||
| +-- molecules/ # Phân tử: ProductCard, ShopCard, SearchBar...
|
| +-- molecules/ # Phân tử: ProductCard, ShopCard, SearchBar...
|
||||||
@@ -196,18 +232,19 @@ 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.9.3 | 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) |
|
| qrcode.react | ^4.2.0 | Tạo mã QR thanh toán |
|
||||||
| FontAwesome | 6.7.2 | Icon library (CDN) |
|
| Geist Font | - | Font chữ (Google Fonts via next/font) |
|
||||||
| pnpm | - | Package manager |
|
| FontAwesome | 6.7.2 | Icon library (CDN) |
|
||||||
| ESLint | ^9 | Linting |
|
| pnpm | - | Package manager |
|
||||||
| Prettier | ^3 | Code formatting |
|
| ESLint | ^9 | Linting |
|
||||||
| semantic-release | ^25 | Automated versioning & release |
|
| Prettier | ^3 | Code formatting |
|
||||||
|
| semantic-release | ^25 | Automated versioning & release |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -222,14 +259,15 @@ frondend/
|
|||||||
- **Thanh Toán:** `app/(main)/payment/page.tsx`
|
- **Thanh Toán:** `app/(main)/payment/page.tsx`
|
||||||
- **Manager Dashboard:** `app/(manager)/manager/page.tsx`
|
- **Manager Dashboard:** `app/(manager)/manager/page.tsx`
|
||||||
- **Phân Tích Tài Chính:** `app/(manager)/manager/analytics/page.tsx`
|
- **Phân Tích Tài Chính:** `app/(manager)/manager/analytics/page.tsx`
|
||||||
|
- **Lịch Làm Việc Nhân Viên:** `app/(staff)/staff/schedule/page.tsx`
|
||||||
|
|
||||||
### 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 / OTP |
|
||||||
| -------------- | -------------- | -------------- |
|
| -------------- | ------------- | -------------------------- |
|
||||||
| 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 | bất kỳ 6 chữ số (mock OTP) |
|
||||||
|
|
||||||
### Design & Styling
|
### Design & Styling
|
||||||
|
|
||||||
@@ -243,14 +281,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,5 +1,9 @@
|
|||||||
# Coffee Shop Frontend - TODO
|
# Coffee Shop Frontend - TODO
|
||||||
|
|
||||||
|
_Last Updated: 2026-04-18_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Completed Features & Implementations
|
## Completed Features & Implementations
|
||||||
|
|
||||||
### A. Dead Code Removed
|
### A. Dead Code Removed
|
||||||
@@ -37,17 +41,66 @@
|
|||||||
- [x] npm run build - PASSED (Compiled successfully, TypeScript clean, static
|
- [x] npm run build - PASSED (Compiled successfully, TypeScript clean, static
|
||||||
pages generated)
|
pages generated)
|
||||||
|
|
||||||
|
### F. Cart & Payment (Completed)
|
||||||
|
|
||||||
|
- [x] Cart checkout flow — cart context with addToCart, removeFromCart,
|
||||||
|
setQuantity, clearCart
|
||||||
|
- [x] Payment page implementation (`app/(main)/payment`)
|
||||||
|
- [x] CashPaymentModal: cash input, change calculation, insufficient cash error
|
||||||
|
- [x] QRPaymentModal: QR code generation via qrcode.react, 5-second countdown
|
||||||
|
auto-confirm
|
||||||
|
- [x] PaymentSuccessModal: success confirmation, auto-clears cart
|
||||||
|
|
||||||
|
### G. Manager Dashboard — ~70% Complete (In Progress)
|
||||||
|
|
||||||
|
- [x] Manager Dashboard page (`app/(manager)/manager/page.tsx`) with auth guard
|
||||||
|
- [x] Products tab with CRUD (ProductModal)
|
||||||
|
- [x] Image upload in ProductModal: file input, preview, "Đổi ảnh" button,
|
||||||
|
remove button, dashed border drop zone
|
||||||
|
- [x] Combos tab with CRUD (ComboModal)
|
||||||
|
- [x] Categories tab with CRUD (CategoryModal)
|
||||||
|
- [x] Delete confirmation dialogs for all entities
|
||||||
|
- [x] Status toggle buttons (available/unavailable)
|
||||||
|
- [x] Sidebar navigation with link to Analytics
|
||||||
|
|
||||||
|
### H. Financial Analytics Dashboard (Completed)
|
||||||
|
|
||||||
|
- [x] Analytics page (`app/(manager)/manager/analytics`)
|
||||||
|
- [x] Summary cards: Revenue, Orders, Profit, Average Order Value (with period
|
||||||
|
comparison)
|
||||||
|
- [x] Period selector: Day / Week / Month / Year
|
||||||
|
- [x] SVG charts: LineChart, BarChart, PieChart with hover tooltips
|
||||||
|
- [x] Top 5 products table and sortable detail table
|
||||||
|
- [x] Category filter
|
||||||
|
|
||||||
|
### I. Staff Shift Schedule (Completed)
|
||||||
|
|
||||||
|
- [x] Staff schedule module (`app/(staff)/staff/schedule`)
|
||||||
|
- [x] Weekly calendar view
|
||||||
|
- [x] Monthly calendar view
|
||||||
|
- [x] Mobile shift view (list per day)
|
||||||
|
- [x] ShiftCreateModal: create new shifts
|
||||||
|
- [x] ShiftDetailModal: view shift details
|
||||||
|
- [x] Department/role filtering
|
||||||
|
- [x] Auth guard for staff/manager only
|
||||||
|
|
||||||
|
### J. Accessibility Improvements (Completed)
|
||||||
|
|
||||||
|
- [x] title attributes on icon buttons (tooltips for screen readers)
|
||||||
|
- [x] ARIA roles on modals: role="dialog", aria-modal, aria-labelledby
|
||||||
|
- [x] Semantic HTML: form labels with correct htmlFor
|
||||||
|
- [x] Image alt text for product images
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pending Features (Future Work)
|
## Pending Features (Future Work)
|
||||||
|
|
||||||
### Cart & Ordering
|
### Cart & Ordering
|
||||||
|
|
||||||
- [ ] Implement cart checkout flow (app/(main)/cart or modal)
|
- [ ] Cart sidebar/modal with item list and total (quick-access without going to
|
||||||
- [ ] Cart sidebar/modal with item list and total
|
payment page)
|
||||||
- [ ] Order submission API integration
|
- [ ] Order submission API integration
|
||||||
- [ ] Payment page implementation (app/(main)/payment)
|
- [ ] Order history/tracking page for customers
|
||||||
- [ ] Order history/tracking page
|
|
||||||
- [ ] Toast notifications for cart actions
|
- [ ] Toast notifications for cart actions
|
||||||
|
|
||||||
### Authentication & User Management
|
### Authentication & User Management
|
||||||
@@ -58,22 +111,22 @@
|
|||||||
- [ ] Password reset/recovery flow
|
- [ ] Password reset/recovery flow
|
||||||
- [ ] Session management and token refresh
|
- [ ] Session management and token refresh
|
||||||
|
|
||||||
### Manager Features
|
### Manager Features (Remaining ~30%)
|
||||||
|
|
||||||
- [ ] Manager dashboard page (app/(manager)/page.tsx)
|
- [ ] Order management & tracking for manager
|
||||||
- [ ] Product management (add/edit/delete)
|
- [ ] Inventory management beyond available toggle (stock counts, low-stock
|
||||||
- [ ] Category management
|
alerts)
|
||||||
- [ ] Order management & tracking
|
- [ ] Staff management tab in Manager Dashboard
|
||||||
- [ ] Sales analytics/dashboard
|
- [ ] Shift approval workflow (manager approves staff shift requests)
|
||||||
- [ ] Inventory management
|
|
||||||
|
|
||||||
### Backend Integration
|
### Backend Integration
|
||||||
|
|
||||||
- [ ] Replace MOCK_PRODUCTS with API calls (GET /api/products)
|
- [ ] Replace MOCK_PRODUCTS with API calls (GET /api/products)
|
||||||
- [ ] Replace MOCK_SHOPS with API calls (GET /api/shops)
|
- [ ] Replace MOCK_SHOPS with API calls (GET /api/shops)
|
||||||
- [ ] Replace MOCK_USERS with real authentication (POST /api/auth/login)
|
- [ ] Replace MOCK_USERS with real authentication (POST /api/auth/login)
|
||||||
- [ ] Real product images (replace placeholder.jpg)
|
- [ ] Real product images hosted on server/CDN (replace public/imgs/products/)
|
||||||
- [ ] Image upload for products
|
- [ ] Real analytics data from backend (GET /api/analytics)
|
||||||
|
- [ ] Real shift/schedule data from backend
|
||||||
|
|
||||||
### UX Improvements
|
### UX Improvements
|
||||||
|
|
||||||
@@ -85,11 +138,12 @@
|
|||||||
- [ ] Filter by price range
|
- [ ] Filter by price range
|
||||||
- [ ] Quantity selector in product card
|
- [ ] Quantity selector in product card
|
||||||
- [ ] Related products suggestions
|
- [ ] Related products suggestions
|
||||||
|
- [ ] Push notifications for order status updates
|
||||||
|
|
||||||
### Performance & SEO
|
### Performance & SEO
|
||||||
|
|
||||||
- [ ] Dynamic route generation for products (app/(main)/product/[id]/page.tsx)
|
- [ ] Dynamic route generation for products (`app/(main)/product/[id]/page.tsx`)
|
||||||
- [ ] Dynamic route generation for shops (app/(feed)/shop/[id]/page.tsx)
|
- [ ] Dynamic route generation for shops (`app/(feed)/shop/[id]/page.tsx`)
|
||||||
- [ ] Meta tags and Open Graph for SEO
|
- [ ] Meta tags and Open Graph for SEO
|
||||||
- [ ] Image optimization and lazy loading
|
- [ ] Image optimization and lazy loading
|
||||||
- [ ] Code splitting and dynamic imports
|
- [ ] Code splitting and dynamic imports
|
||||||
@@ -97,7 +151,7 @@
|
|||||||
### Accessibility & Testing
|
### Accessibility & Testing
|
||||||
|
|
||||||
- [ ] Keyboard navigation testing
|
- [ ] Keyboard navigation testing
|
||||||
- [ ] ARIA labels audit
|
- [ ] Full ARIA labels audit (WCAG 2.1 AA)
|
||||||
- [ ] Unit tests for contexts
|
- [ ] Unit tests for contexts (auth, cart, menu, manager)
|
||||||
- [ ] E2E tests for user flows
|
- [ ] E2E tests for user flows (checkout, login, manager CRUD)
|
||||||
- [ ] Accessibility audit (WCAG 2.1 AA)
|
- [ ] Accessibility audit tool (axe, Lighthouse)
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -28,7 +28,7 @@ export default function LoginPage() {
|
|||||||
Đăng nhập vào hệ thống
|
Đăng nhập vào hệ thống
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,15 +56,18 @@ export default function Home() {
|
|||||||
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||||
{/* Title + count */}
|
{/* Title + count */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<h2 className="text-foreground text-xl font-bold">
|
<h1 className="text-foreground text-xl font-bold">
|
||||||
{activeCategoryLabel}
|
{activeCategoryLabel}
|
||||||
</h2>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={setSearchQuery}
|
onChange={(q) => {
|
||||||
|
if (q && activeCategory !== "all") setActiveCategory("all");
|
||||||
|
setSearchQuery(q);
|
||||||
|
}}
|
||||||
onClear={() => setSearchQuery("")}
|
onClear={() => setSearchQuery("")}
|
||||||
placeholder="Tìm kiếm món..."
|
placeholder="Tìm kiếm món..."
|
||||||
className="sm:max-w-xs"
|
className="sm:max-w-xs"
|
||||||
@@ -72,10 +75,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>
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-18
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
import Button from "@/components/atoms/buttons/Button";
|
import Button from "@/components/atoms/buttons/Button";
|
||||||
import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard";
|
import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard";
|
||||||
import ReviewModal from "@/components/organisms/modals/ReviewModal";
|
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { useCart } from "@/lib/cart-context";
|
import { useCart } from "@/lib/cart-context";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const formatPrice = (value: number) =>
|
const formatPrice = (value: number) =>
|
||||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||||
@@ -21,9 +19,6 @@ export default function PaymentPage() {
|
|||||||
} = useCart();
|
} = useCart();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
|
||||||
const isCustomer = user?.role === "customer";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto w-full max-w-screen-2xl px-4 py-6 md:px-6 md:py-8 lg:px-8">
|
<div className="mx-auto w-full max-w-screen-2xl px-4 py-6 md:px-6 md:py-8 lg:px-8">
|
||||||
@@ -45,13 +40,22 @@ export default function PaymentPage() {
|
|||||||
<table className="w-full min-w-190 text-sm">
|
<table className="w-full min-w-190 text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-(--color-border-light)/40 text-left">
|
<tr className="bg-(--color-border-light)/40 text-left">
|
||||||
<th className="px-4 py-3 font-semibold">
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
Tên sản phẩm
|
Tên sản phẩm
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 font-semibold">Giá tiền</th>
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
<th className="px-4 py-3 font-semibold">Mô tả</th>
|
Giá tiền
|
||||||
<th className="px-4 py-3 font-semibold">Số lượng</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right font-semibold">
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
|
Mô tả
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold">
|
||||||
|
Số lượng
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-4 py-3 text-right font-semibold"
|
||||||
|
>
|
||||||
Xóa
|
Xóa
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -75,7 +79,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 +96,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}`}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
@@ -105,6 +109,7 @@ export default function PaymentPage() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
size="md"
|
size="md"
|
||||||
style="payment"
|
style="payment"
|
||||||
|
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
|
||||||
>
|
>
|
||||||
Xóa sản phẩm
|
Xóa sản phẩm
|
||||||
</Button>
|
</Button>
|
||||||
@@ -119,17 +124,13 @@ export default function PaymentPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PaymentSummaryCard
|
<PaymentSummaryCard
|
||||||
|
cartItems={items}
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
isCustomer={isCustomer}
|
role={user?.role ?? "customer"}
|
||||||
backHref="/"
|
backHref="/"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReviewModal
|
|
||||||
isOpen={isReviewOpen}
|
|
||||||
onClose={() => setIsReviewOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Button from "@/components/atoms/buttons/Button";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { SHOP_INFO } from "@/lib/constants";
|
import { SHOP_INFO } from "@/lib/constants";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
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";
|
|
||||||
|
|
||||||
// Static OTP for demo (in production, this would be sent via SMS)
|
// Static OTP for demo (in production, this would be sent via SMS)
|
||||||
const DEMO_OTP = "123456";
|
const DEMO_OTP = "123456";
|
||||||
@@ -161,7 +161,12 @@ export default function RegisterPage() {
|
|||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg">
|
<Button
|
||||||
|
variant="primaryNoBorder"
|
||||||
|
type="submit"
|
||||||
|
style="login"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
Tiếp tục
|
Tiếp tục
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -227,12 +232,22 @@ export default function RegisterPage() {
|
|||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg">
|
<Button
|
||||||
|
variant="primaryNoBorder"
|
||||||
|
type="submit"
|
||||||
|
style="login"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
Hoàn tất đăng ký
|
Hoàn tất đăng ký
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Button variant="bgWhite" onClick={handleBackToPhone} size="lg" style="login">
|
<Button
|
||||||
|
variant="bgWhite"
|
||||||
|
onClick={handleBackToPhone}
|
||||||
|
size="lg"
|
||||||
|
style="login"
|
||||||
|
>
|
||||||
Thay đổi số điện thoại
|
Thay đổi số điện thoại
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
SummaryCard,
|
SummaryCard,
|
||||||
} from "@/components/organisms/analytics";
|
} from "@/components/organisms/analytics";
|
||||||
import type { PieSlice } from "@/components/organisms/analytics";
|
import type { PieSlice } from "@/components/organisms/analytics";
|
||||||
import { calcChange, formatCurrency, formatCurrencyFull } from "@/lib/analytics-utils";
|
import {
|
||||||
|
calcChange,
|
||||||
|
formatCurrency,
|
||||||
|
formatCurrencyFull,
|
||||||
|
} from "@/lib/analytics-utils";
|
||||||
import {
|
import {
|
||||||
MENU_CATEGORIES,
|
MENU_CATEGORIES,
|
||||||
MOCK_PRODUCT_SALES,
|
MOCK_PRODUCT_SALES,
|
||||||
@@ -31,8 +35,15 @@ const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_COLORS = [
|
const CATEGORY_COLORS = [
|
||||||
"#6F4E37", "#C8973A", "#A0785A", "#8B6914", "#D4A96A",
|
"#6F4E37",
|
||||||
"#4A3728", "#F0D9A8", "#A08060", "#3D2B1F",
|
"#C8973A",
|
||||||
|
"#A0785A",
|
||||||
|
"#8B6914",
|
||||||
|
"#D4A96A",
|
||||||
|
"#4A3728",
|
||||||
|
"#F0D9A8",
|
||||||
|
"#A08060",
|
||||||
|
"#3D2B1F",
|
||||||
];
|
];
|
||||||
|
|
||||||
const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = {
|
const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = {
|
||||||
@@ -47,8 +58,8 @@ type ChartType = (typeof CHART_TYPES)[number];
|
|||||||
|
|
||||||
const CHART_META: Record<ChartType, { icon: string; label: string }> = {
|
const CHART_META: Record<ChartType, { icon: string; label: string }> = {
|
||||||
line: { icon: "fa-chart-line", label: "Line" },
|
line: { icon: "fa-chart-line", label: "Line" },
|
||||||
bar: { icon: "fa-chart-bar", label: "Bar" },
|
bar: { icon: "fa-chart-bar", label: "Bar" },
|
||||||
pie: { icon: "fa-chart-pie", label: "Pie" },
|
pie: { icon: "fa-chart-pie", label: "Pie" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Category filter select ───────────────────────────────────────────────────
|
// ─── Category filter select ───────────────────────────────────────────────────
|
||||||
@@ -74,7 +85,9 @@ function CategorySelect({
|
|||||||
>
|
>
|
||||||
<option value="all">Tất cả</option>
|
<option value="all">Tất cả</option>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,27 +106,28 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
// Split into halves for bar comparison
|
// Split into halves for bar comparison
|
||||||
const half = Math.floor(revenueData.length / 2);
|
const half = Math.floor(revenueData.length / 2);
|
||||||
const barCurrent = revenueData.slice(half);
|
const barCurrent = revenueData.slice(half);
|
||||||
const barPrevious = revenueData.slice(0, half).slice(0, barCurrent.length);
|
const barPrevious = revenueData.slice(0, half).slice(0, barCurrent.length);
|
||||||
|
|
||||||
// Filtered product sales
|
// Filtered product sales
|
||||||
const filteredSales = useMemo(
|
const filteredSales = useMemo(
|
||||||
() => categoryFilter === "all"
|
() =>
|
||||||
? MOCK_PRODUCT_SALES
|
categoryFilter === "all"
|
||||||
: MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter),
|
? MOCK_PRODUCT_SALES
|
||||||
|
: MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter),
|
||||||
[categoryFilter],
|
[categoryFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Summary stats
|
// Summary stats
|
||||||
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
|
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
|
||||||
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
|
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
|
||||||
const totalProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
|
const totalProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
|
||||||
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||||
|
|
||||||
// Period-over-period comparisons
|
// Period-over-period comparisons
|
||||||
const curRevenue = barCurrent.reduce((s, d) => s + d.revenue, 0);
|
const curRevenue = barCurrent.reduce((s, d) => s + d.revenue, 0);
|
||||||
const prevRevenue = barPrevious.reduce((s, d) => s + d.revenue, 0);
|
const prevRevenue = barPrevious.reduce((s, d) => s + d.revenue, 0);
|
||||||
const curOrders = barCurrent.reduce((s, d) => s + d.orders, 0);
|
const curOrders = barCurrent.reduce((s, d) => s + d.orders, 0);
|
||||||
const prevOrders = barPrevious.reduce((s, d) => s + d.orders, 0);
|
const prevOrders = barPrevious.reduce((s, d) => s + d.orders, 0);
|
||||||
const revComp = calcChange(curRevenue, prevRevenue);
|
const revComp = calcChange(curRevenue, prevRevenue);
|
||||||
const ordComp = calcChange(curOrders, prevOrders);
|
const ordComp = calcChange(curOrders, prevOrders);
|
||||||
@@ -142,11 +156,13 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
// Totals for summary row
|
// Totals for summary row
|
||||||
const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0);
|
const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0);
|
||||||
const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
|
const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
|
||||||
const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0);
|
const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0);
|
||||||
const avgMargin = filteredSales.length > 0
|
const avgMargin =
|
||||||
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) / filteredSales.length
|
filteredSales.length > 0
|
||||||
: 0;
|
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) /
|
||||||
|
filteredSales.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
@@ -167,7 +183,9 @@ export default function AnalyticsPage() {
|
|||||||
<h1 className="text-foreground text-lg leading-tight font-bold">
|
<h1 className="text-foreground text-lg leading-tight font-bold">
|
||||||
Thống kê & Phân tích tài chính
|
Thống kê & Phân tích tài chính
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-(--color-text-muted)">Financial Analytics Dashboard</p>
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
|
Financial Analytics Dashboard
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,8 +210,12 @@ export default function AnalyticsPage() {
|
|||||||
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
||||||
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
|
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
|
||||||
>
|
>
|
||||||
{(Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]).map(([k, v]) => (
|
{(
|
||||||
<option key={k} value={k}>{v}</option>
|
Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]
|
||||||
|
).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,18 +229,42 @@ export default function AnalyticsPage() {
|
|||||||
Tổng quan
|
Tổng quan
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<SummaryCard icon="fa-solid fa-sack-dollar" title="Tổng doanh thu"
|
<SummaryCard
|
||||||
value={formatCurrency(totalRevenue)} subtitle={PERIOD_LABELS[period]}
|
icon="fa-solid fa-sack-dollar"
|
||||||
change={revComp.change} changePercent={revComp.changePercent} isPositive={revComp.isPositive} />
|
title="Tổng doanh thu"
|
||||||
<SummaryCard icon="fa-solid fa-receipt" title="Số đơn hàng"
|
value={formatCurrency(totalRevenue)}
|
||||||
value={totalOrders.toLocaleString()} subtitle="Tổng đơn trong kỳ"
|
subtitle={PERIOD_LABELS[period]}
|
||||||
change={ordComp.change} changePercent={ordComp.changePercent} isPositive={ordComp.isPositive} />
|
change={revComp.change}
|
||||||
<SummaryCard icon="fa-solid fa-circle-dollar-to-slot" title="Tổng lợi nhuận"
|
changePercent={revComp.changePercent}
|
||||||
value={formatCurrency(totalProfit)} subtitle="Ước tính từ dữ liệu bán hàng"
|
isPositive={revComp.isPositive}
|
||||||
change={proComp.change} changePercent={proComp.changePercent} isPositive={proComp.isPositive} />
|
/>
|
||||||
<SummaryCard icon="fa-solid fa-basket-shopping" title="Giá trị đơn TB"
|
<SummaryCard
|
||||||
value={formatCurrency(avgOrderValue)} subtitle="Doanh thu / số đơn hàng"
|
icon="fa-solid fa-receipt"
|
||||||
change={0} changePercent={0} isPositive={true} />
|
title="Số đơn hàng"
|
||||||
|
value={totalOrders.toLocaleString()}
|
||||||
|
subtitle="Tổng đơn trong kỳ"
|
||||||
|
change={ordComp.change}
|
||||||
|
changePercent={ordComp.changePercent}
|
||||||
|
isPositive={ordComp.isPositive}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon="fa-solid fa-circle-dollar-to-slot"
|
||||||
|
title="Tổng lợi nhuận"
|
||||||
|
value={formatCurrency(totalProfit)}
|
||||||
|
subtitle="Ước tính từ dữ liệu bán hàng"
|
||||||
|
change={proComp.change}
|
||||||
|
changePercent={proComp.changePercent}
|
||||||
|
isPositive={proComp.isPositive}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon="fa-solid fa-basket-shopping"
|
||||||
|
title="Giá trị đơn TB"
|
||||||
|
value={formatCurrency(avgOrderValue)}
|
||||||
|
subtitle="Doanh thu / số đơn hàng"
|
||||||
|
change={0}
|
||||||
|
changePercent={0}
|
||||||
|
isPositive={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -241,7 +287,9 @@ export default function AnalyticsPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<i className={`fa-solid text-xs ${CHART_META[t].icon}`}></i>
|
<i className={`fa-solid text-xs ${CHART_META[t].icon}`}></i>
|
||||||
<span className="hidden sm:inline">{CHART_META[t].label}</span>
|
<span className="hidden sm:inline">
|
||||||
|
{CHART_META[t].label}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -249,19 +297,31 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
{activeChart === "line" && (
|
{activeChart === "line" && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">Doanh thu theo thời gian — {PERIOD_LABELS[period]}</p>
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||||
|
Doanh thu theo thời gian — {PERIOD_LABELS[period]}
|
||||||
|
<span className="ml-2 opacity-70">(tr = triệu đồng VND)</span>
|
||||||
|
</p>
|
||||||
<LineChart data={revenueData} height={220} />
|
<LineChart data={revenueData} height={220} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeChart === "bar" && (
|
{activeChart === "bar" && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">So sánh doanh thu nửa đầu và nửa sau kỳ hiện tại</p>
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||||
<BarChart current={barCurrent} previous={barPrevious} height={220} />
|
So sánh doanh thu nửa đầu và nửa sau kỳ hiện tại
|
||||||
|
<span className="ml-2 opacity-70">(tr = triệu đồng VND)</span>
|
||||||
|
</p>
|
||||||
|
<BarChart
|
||||||
|
current={barCurrent}
|
||||||
|
previous={barPrevious}
|
||||||
|
height={220}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeChart === "pie" && (
|
{activeChart === "pie" && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">Tỷ trọng doanh thu theo danh mục sản phẩm</p>
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||||
|
Tỷ trọng doanh thu theo danh mục sản phẩm
|
||||||
|
</p>
|
||||||
<PieChart data={pieData} />
|
<PieChart data={pieData} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -274,7 +334,10 @@ export default function AnalyticsPage() {
|
|||||||
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
|
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
|
||||||
Top sản phẩm bán chạy
|
Top sản phẩm bán chạy
|
||||||
</h2>
|
</h2>
|
||||||
<CategorySelect value={categoryFilter} onChange={setCategoryFilter} />
|
<CategorySelect
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{top5.map((p, i) => {
|
{top5.map((p, i) => {
|
||||||
@@ -286,15 +349,24 @@ export default function AnalyticsPage() {
|
|||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light) text-xs font-bold text-(--color-primary)">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light) text-xs font-bold text-(--color-primary)">
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground truncate text-sm font-medium">{p.name}</span>
|
<span className="text-foreground truncate text-sm font-medium">
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||||
<span className="text-(--color-text-muted) tabular-nums">{p.unitsSold} ly</span>
|
<span className="text-(--color-text-muted) tabular-nums">
|
||||||
<span className="font-semibold text-(--color-primary) tabular-nums">{formatCurrency(p.revenue)}</span>
|
{p.unitsSold} ly
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-(--color-primary) tabular-nums">
|
||||||
|
{formatCurrency(p.revenue)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background h-2 overflow-hidden rounded-full">
|
<div className="bg-background h-2 overflow-hidden rounded-full">
|
||||||
<div className="h-full rounded-full bg-(--color-primary) transition-all duration-500" style={{ width: `${pct}%` }} />
|
<div
|
||||||
|
className="h-full rounded-full bg-(--color-primary) transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -309,30 +381,51 @@ export default function AnalyticsPage() {
|
|||||||
<i className="fa-solid fa-table text-foreground mr-2"></i>
|
<i className="fa-solid fa-table text-foreground mr-2"></i>
|
||||||
Phân tích chi tiết sản phẩm
|
Phân tích chi tiết sản phẩm
|
||||||
</h2>
|
</h2>
|
||||||
<CategorySelect value={categoryFilter} onChange={setCategoryFilter} label="Lọc danh mục:" />
|
<CategorySelect
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
label="Lọc danh mục:"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||||
Click vào tiêu đề cột để sắp xếp. Hiển thị {filteredSales.length} sản phẩm.
|
Click vào tiêu đề cột để sắp xếp. Hiển thị {filteredSales.length}{" "}
|
||||||
|
sản phẩm.
|
||||||
</p>
|
</p>
|
||||||
<ProductTable data={filteredSales} />
|
<ProductTable data={filteredSales} />
|
||||||
|
|
||||||
{/* Summary row */}
|
{/* Summary row */}
|
||||||
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
|
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">Tổng doanh thu: </span>
|
<span className="text-(--color-text-muted)">
|
||||||
<span className="font-semibold text-(--color-primary)">{formatCurrencyFull(filteredRevenue)}</span>
|
Tổng doanh thu:{" "}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-(--color-primary)">
|
||||||
|
{formatCurrencyFull(filteredRevenue)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">Tổng lợi nhuận: </span>
|
<span className="text-(--color-text-muted)">
|
||||||
<span className="font-semibold text-green-600">{formatCurrencyFull(filteredProfit)}</span>
|
Tổng lợi nhuận:{" "}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{formatCurrencyFull(filteredProfit)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">Tổng sản lượng: </span>
|
<span className="text-(--color-text-muted)">
|
||||||
<span className="text-foreground font-semibold">{filteredUnits.toLocaleString()} ly</span>
|
Tổng sản lượng:{" "}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-semibold">
|
||||||
|
{filteredUnits.toLocaleString()} ly
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-(--color-text-muted)">Biên LN trung bình: </span>
|
<span className="text-(--color-text-muted)">
|
||||||
<span className="font-semibold text-yellow-700">{avgMargin.toFixed(1)}%</span>
|
Biên LN trung bình:{" "}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-yellow-700">
|
||||||
|
{avgMargin.toFixed(1)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function ManagerPage() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
|
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? "bg-(--color-primary) text-white shadow-sm"
|
? "bg-(--color-primary) text-white shadow-sm"
|
||||||
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
|
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
|
||||||
@@ -82,7 +82,7 @@ export default function ManagerPage() {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/manager/analytics"
|
href="/manager/analytics"
|
||||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark) focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||||
<span className="flex-1 text-left">Tài chính</span>
|
<span className="flex-1 text-left">Tài chính</span>
|
||||||
@@ -90,6 +90,16 @@ export default function ManagerPage() {
|
|||||||
Mới
|
Mới
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/staff/schedule"
|
||||||
|
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark) focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
|
||||||
|
<span className="flex-1 text-left">Ca làm</span>
|
||||||
|
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||||
|
Mới
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -115,7 +125,7 @@ export default function ManagerPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50 focus-visible:ring-2 focus-visible:ring-red-400/50 focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-right-from-bracket"></i>
|
<i className="fa-solid fa-right-from-bracket"></i>
|
||||||
Đăng xuất
|
Đăng xuất
|
||||||
@@ -148,7 +158,7 @@ export default function ManagerPage() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? "bg-(--color-primary) text-white"
|
? "bg-(--color-primary) text-white"
|
||||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { StaffLayout } from "@/components/templates/staff-layout";
|
||||||
|
|
||||||
|
export default function RootStaffLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <StaffLayout>{children}</StaffLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MobileShiftView from "@/components/organisms/shift-schedule/MobileShiftView";
|
||||||
|
import MonthlyCalendar from "@/components/organisms/shift-schedule/MonthlyCalendar";
|
||||||
|
import ShiftCreateModal from "@/components/organisms/shift-schedule/ShiftCreateModal";
|
||||||
|
import ShiftDetailModal from "@/components/organisms/shift-schedule/ShiftDetailModal";
|
||||||
|
import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { useShift } from "@/lib/shift-context";
|
||||||
|
import type { ShiftSlot } from "@/lib/types";
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getMonday(d: Date): Date {
|
||||||
|
const date = new Date(d);
|
||||||
|
const day = date.getDay();
|
||||||
|
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
date.setDate(diff);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateShort(d: Date): string {
|
||||||
|
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StaffSchedulePage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const {
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
currentDate,
|
||||||
|
goToNextWeek,
|
||||||
|
goToPrevWeek,
|
||||||
|
goToNextMonth,
|
||||||
|
goToPrevMonth,
|
||||||
|
goToToday,
|
||||||
|
getWeeklyBudget,
|
||||||
|
} = useShift();
|
||||||
|
|
||||||
|
const [selectedShift, setSelectedShift] = useState<ShiftSlot | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createDate, setCreateDate] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const isManager = user?.role === "manager";
|
||||||
|
|
||||||
|
const handleShiftClick = (shift: ShiftSlot) => {
|
||||||
|
setSelectedShift(shift);
|
||||||
|
setDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateShift = (date: string) => {
|
||||||
|
setCreateDate(date);
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateSelect = (date: string) => {
|
||||||
|
// In month view on desktop, clicking a date could open create modal for managers
|
||||||
|
if (isManager) {
|
||||||
|
setCreateDate(date);
|
||||||
|
setCreateOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Week range label
|
||||||
|
const monday = getMonday(currentDate);
|
||||||
|
const sunday = new Date(monday);
|
||||||
|
sunday.setDate(monday.getDate() + 6);
|
||||||
|
const weekLabel = `${formatDateShort(monday)} – ${formatDateShort(sunday)}`;
|
||||||
|
|
||||||
|
const weeklyBudget = getWeeklyBudget();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* ── Sidebar (Desktop) ── */}
|
||||||
|
<aside className="hidden w-64 shrink-0 flex-col border-r border-(--color-border-light) bg-white shadow-sm lg:flex">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-(--color-border-light) px-5 py-5">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-primary)">
|
||||||
|
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground text-sm font-bold">Lịch làm việc</p>
|
||||||
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
|
{isManager ? "Manager" : "Staff"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View toggle */}
|
||||||
|
<nav className="flex-1 space-y-1 p-3">
|
||||||
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
|
Chế độ xem
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("week")}
|
||||||
|
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
|
||||||
|
view === "week"
|
||||||
|
? "bg-(--color-primary) text-white shadow-sm"
|
||||||
|
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-table-columns w-4 text-center"></i>
|
||||||
|
<span className="flex-1 text-left">Theo tuần</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("month")}
|
||||||
|
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
|
||||||
|
view === "month"
|
||||||
|
? "bg-(--color-primary) text-white shadow-sm"
|
||||||
|
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-calendar w-4 text-center"></i>
|
||||||
|
<span className="flex-1 text-left">Theo tháng</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Quick nav */}
|
||||||
|
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||||
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
|
Điều hướng
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToToday}
|
||||||
|
className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-crosshairs w-4 text-center"></i>
|
||||||
|
<span className="flex-1 text-left">Hôm nay</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manager link */}
|
||||||
|
{isManager && (
|
||||||
|
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||||
|
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||||
|
Quản lý
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/manager"
|
||||||
|
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-store w-4 text-center"></i>
|
||||||
|
<span className="flex-1 text-left">Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weekly budget */}
|
||||||
|
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||||
|
<div className="rounded-xl bg-(--color-primary)/5 p-3">
|
||||||
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
|
Ngân sách tuần
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-bold text-(--color-primary)">
|
||||||
|
{weeklyBudget.toLocaleString("vi-VN")}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-(--color-text-muted)">VND</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
<div className="border-t border-(--color-border-light) p-3">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl p-3">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
||||||
|
<i
|
||||||
|
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-foreground truncate text-sm font-semibold">
|
||||||
|
{user?.name ?? "Nhân viên"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
|
{isManager ? "Quản lý" : "Nhân viên"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-2 px-1">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-house"></i>
|
||||||
|
Trang chủ
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-right-from-bracket"></i>
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Main content ── */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-foreground text-base font-bold md:text-lg">
|
||||||
|
Đăng ký ca làm
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
|
{view === "week"
|
||||||
|
? weekLabel
|
||||||
|
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* View toggle (mobile) */}
|
||||||
|
<div className="flex items-center rounded-xl border border-(--color-border-light) lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("week")}
|
||||||
|
className={`cursor-pointer rounded-l-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||||
|
view === "week"
|
||||||
|
? "bg-(--color-primary) text-white"
|
||||||
|
: "bg-transparent text-(--color-text-secondary)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tuần
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("month")}
|
||||||
|
className={`cursor-pointer rounded-r-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||||
|
view === "month"
|
||||||
|
? "bg-(--color-primary) text-white"
|
||||||
|
: "bg-transparent text-(--color-text-secondary)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tháng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation arrows */}
|
||||||
|
<div className="hidden items-center gap-1 md:flex">
|
||||||
|
<button
|
||||||
|
title="Về trước"
|
||||||
|
type="button"
|
||||||
|
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToToday}
|
||||||
|
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Hôm nay
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Tiếp theo"
|
||||||
|
type="button"
|
||||||
|
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create shift button (manager only) */}
|
||||||
|
{isManager && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateDate(undefined);
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus"></i>
|
||||||
|
Tạo ca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile nav */}
|
||||||
|
<div className="flex items-center gap-1 md:hidden">
|
||||||
|
<button
|
||||||
|
title="Về trước"
|
||||||
|
type="button"
|
||||||
|
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Tiếp theo"
|
||||||
|
type="button"
|
||||||
|
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
{/* Desktop views */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
{view === "week" ? (
|
||||||
|
<WeeklySchedule
|
||||||
|
onShiftClick={handleShiftClick}
|
||||||
|
onCreateShift={isManager ? handleCreateShift : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MonthlyCalendar
|
||||||
|
onShiftClick={handleShiftClick}
|
||||||
|
onDateSelect={isManager ? handleDateSelect : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile view */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
{view === "week" ? (
|
||||||
|
<WeeklySchedule
|
||||||
|
onShiftClick={handleShiftClick}
|
||||||
|
onCreateShift={isManager ? handleCreateShift : undefined}
|
||||||
|
mobileCalendarHeader
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MobileShiftView onShiftClick={handleShiftClick} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB for manager */}
|
||||||
|
{isManager && (
|
||||||
|
<button
|
||||||
|
title="Tạo ca"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateDate(undefined);
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
className="fixed right-4 bottom-4 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-lg transition hover:opacity-90 md:hidden"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus text-lg"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ShiftDetailModal
|
||||||
|
shift={selectedShift}
|
||||||
|
isOpen={detailOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailOpen(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ShiftCreateModal
|
||||||
|
isOpen={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
defaultDate={createDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+109
-18
@@ -24,12 +24,16 @@ app/
|
|||||||
│ ├── layout.tsx # Feed layout
|
│ ├── layout.tsx # Feed layout
|
||||||
│ └── feed/
|
│ └── feed/
|
||||||
│ └── page.tsx # Trang khám phá quán (/feed)
|
│ └── page.tsx # Trang khám phá quán (/feed)
|
||||||
└── (manager)/
|
├── (manager)/
|
||||||
├── layout.tsx # Manager layout - auth guard + ManagerProvider
|
│ ├── layout.tsx # Manager layout - auth guard + ManagerProvider
|
||||||
└── manager/
|
│ └── manager/
|
||||||
├── page.tsx # Manager Dashboard - quản lý thực đơn (/manager)
|
│ ├── page.tsx # Manager Dashboard - quản lý thực đơn (/manager)
|
||||||
└── analytics/
|
│ └── analytics/
|
||||||
└── page.tsx # Financial Analytics (/manager/analytics)
|
│ └── page.tsx # Financial Analytics (/manager/analytics)
|
||||||
|
└── (staff)/
|
||||||
|
└── staff/
|
||||||
|
└── schedule/
|
||||||
|
└── page.tsx # Staff Shift Schedule (/staff/schedule)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -241,7 +245,7 @@ registration flow. Two-step process: phone verification → account creation.
|
|||||||
|
|
||||||
**Route:** `/payment` **Type:** Client component **Description:**
|
**Route:** `/payment` **Type:** Client component **Description:**
|
||||||
Payment/checkout page. Shows cart items in a table with quantity controls and a
|
Payment/checkout page. Shows cart items in a table with quantity controls and a
|
||||||
summary aside with payment actions.
|
summary aside (PaymentSummaryCard) with payment actions and modal flows.
|
||||||
|
|
||||||
#### Key Features
|
#### Key Features
|
||||||
|
|
||||||
@@ -251,27 +255,46 @@ summary aside with payment actions.
|
|||||||
- Empty state message when cart is empty
|
- Empty state message when cart is empty
|
||||||
- Horizontal scroll on small screens (min-w-190)
|
- Horizontal scroll on small screens (min-w-190)
|
||||||
|
|
||||||
- **Invoice Aside:**
|
- **PaymentSummaryCard (Invoice Aside):**
|
||||||
- Sticky on desktop (top offset = header height + 1rem)
|
- Sticky on desktop (top offset = header height + 1rem)
|
||||||
- Shows total price
|
- Shows all cart items with subtotals and grand total
|
||||||
- Payment buttons: Tiền mặt, QR Code (UI-only; trigger review modal for
|
- Payment method buttons: Tiền mặt (Cash), QR Code
|
||||||
customers)
|
|
||||||
- **"Đánh giá" button** — only visible when `user.role === "customer"`; opens
|
- **"Đánh giá" button** — only visible when `user.role === "customer"`; opens
|
||||||
ReviewModal
|
ReviewModal
|
||||||
- **"Quay về" button** — links back to `/`; spans full width when review
|
- **"Quay về" button** — links back to `/`; spans full width when review
|
||||||
button is absent
|
button is absent
|
||||||
|
|
||||||
- **Review Modal (ReviewModal):**
|
#### Payment Modal Flow
|
||||||
- Opened when a customer clicks "Đánh giá" OR clicks a payment button (Tiền
|
|
||||||
mặt/QR)
|
- **Cash Payment (CashPaymentModal):**
|
||||||
- Closed via "Quay lại" button, backdrop click, or after submitting and
|
- Opened when user clicks "Tiền mặt"
|
||||||
closing
|
- Input: cash received amount
|
||||||
|
- Shows change due or insufficient cash error inline
|
||||||
|
- Displays invoice item list
|
||||||
|
- Confirm → opens PaymentSuccessModal
|
||||||
|
|
||||||
|
- **QR Payment (QRPaymentModal):**
|
||||||
|
- Opened when user clicks "QR Code"
|
||||||
|
- Generates QR code via `qrcode.react` (`QRCodeSVG` component)
|
||||||
|
- 5-second countdown timer — auto-confirms on expiry
|
||||||
|
- Displays invoice item list
|
||||||
|
- Confirm → opens PaymentSuccessModal
|
||||||
|
|
||||||
|
- **PaymentSuccessModal:**
|
||||||
|
- Shown after confirming Cash or QR payment
|
||||||
|
- Displays success confirmation message
|
||||||
|
- Clears cart via `clearCart()` from CartContext on close/confirm
|
||||||
|
|
||||||
|
- **ReviewModal:**
|
||||||
|
- Opened when a customer clicks "Đánh giá"
|
||||||
|
- 5-star rating + textarea for comment
|
||||||
|
- Closed via "Quay lại" button, backdrop click, or after submitting
|
||||||
- See `components/ReviewModal.tsx` for full documentation
|
- See `components/ReviewModal.tsx` for full documentation
|
||||||
|
|
||||||
#### Context Usage
|
#### Context Usage
|
||||||
|
|
||||||
- **useCart()** — items, totalPrice, increaseQty, decreaseQty, removeFromCart,
|
- **useCart()** — items, totalPrice, increaseQty, decreaseQty, removeFromCart,
|
||||||
setQuantity
|
setQuantity, clearCart
|
||||||
- **useAuth()** — user (to check `user.role === "customer"` for review button
|
- **useAuth()** — user (to check `user.role === "customer"` for review button
|
||||||
visibility)
|
visibility)
|
||||||
|
|
||||||
@@ -279,6 +302,9 @@ summary aside with payment actions.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
isReviewOpen: boolean; // Controls ReviewModal visibility
|
isReviewOpen: boolean; // Controls ReviewModal visibility
|
||||||
|
isCashModalOpen: boolean; // Controls CashPaymentModal visibility
|
||||||
|
isQRModalOpen: boolean; // Controls QRPaymentModal visibility
|
||||||
|
isSuccessModalOpen: boolean; // Controls PaymentSuccessModal visibility
|
||||||
isCustomer: boolean; // Derived from user.role === "customer"
|
isCustomer: boolean; // Derived from user.role === "customer"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -502,13 +528,78 @@ categoryFilter: string; // category id or "all"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## (staff) Route Group
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
**Description:** Route group for staff-specific pages. Uses `StaffLayout` from
|
||||||
|
`components/templates/staff-layout/` which provides the staff navigation shell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Staff Shift Schedule (app/(staff)/staff/schedule/page.tsx)
|
||||||
|
|
||||||
|
**Route:** `/staff/schedule` **Type:** Client component **Description:** Weekly
|
||||||
|
and monthly shift schedule management for staff members.
|
||||||
|
|
||||||
|
#### Key Features
|
||||||
|
|
||||||
|
- **Weekly Schedule View (WeeklySchedule.tsx):**
|
||||||
|
- Grid-based view of shifts for each day of the current week
|
||||||
|
- Shows shift time, role, and department per cell
|
||||||
|
- Click a shift to open ShiftDetailModal
|
||||||
|
|
||||||
|
- **Monthly Calendar View (MonthlyCalendar.tsx):**
|
||||||
|
- Full month calendar grid
|
||||||
|
- Shifts rendered as colored chips per day
|
||||||
|
- Navigate between months
|
||||||
|
|
||||||
|
- **Mobile Shift View (MobileShiftView.tsx):**
|
||||||
|
- Optimized list-based view for small screens
|
||||||
|
- Shows upcoming shifts for the current/selected date
|
||||||
|
|
||||||
|
- **ShiftCreateModal:**
|
||||||
|
- Form to create a new shift: date, start/end time, role, department
|
||||||
|
- Validates time range before submitting
|
||||||
|
|
||||||
|
- **ShiftDetailModal:**
|
||||||
|
- Shows full details of a selected shift
|
||||||
|
- Options to edit or delete the shift
|
||||||
|
|
||||||
|
- **Department Filter:**
|
||||||
|
- Dropdown to filter schedule view by department
|
||||||
|
|
||||||
|
#### Context Usage
|
||||||
|
|
||||||
|
- **useShift()** (lib/shift-context.tsx) — shifts, createShift, updateShift,
|
||||||
|
deleteShift, selectedDate, setSelectedDate
|
||||||
|
|
||||||
|
#### State Management
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
activeView: "weekly" | "monthly"; // Toggle between calendar views
|
||||||
|
isCreateModalOpen: boolean; // Controls ShiftCreateModal
|
||||||
|
selectedShift: Shift | null; // Shift passed to ShiftDetailModal
|
||||||
|
departmentFilter: string; // Currently selected department
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Responsive Behavior
|
||||||
|
|
||||||
|
- **Mobile (<768px):** MobileShiftView displayed instead of grid calendars
|
||||||
|
- **Tablet/Desktop (768px+):** WeeklySchedule or MonthlyCalendar displayed
|
||||||
|
- **Department filter:** Sticky top bar on all screen sizes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
- [x] Payment page implementation
|
- [x] Payment page implementation
|
||||||
- [x] Customer review modal (ReviewModal) with 5-star rating + textarea
|
- [x] Customer review modal (ReviewModal) with 5-star rating + textarea
|
||||||
- [x] Manager dashboard (menu management)
|
- [x] Manager dashboard (menu management)
|
||||||
- [x] Financial Analytics dashboard (charts, profit analysis)
|
- [x] Financial Analytics dashboard (charts, profit analysis)
|
||||||
|
- [x] Staff shift schedule (weekly/monthly views, CRUD, department filter)
|
||||||
|
- [x] Cash and QR payment modals with invoice display
|
||||||
|
- [x] Image upload for products in manager dashboard
|
||||||
- [ ] Order history/tracking page
|
- [ ] Order history/tracking page
|
||||||
- [ ] User profile page
|
- [ ] User profile page
|
||||||
- [ ] Cart checkout flow
|
|
||||||
- [ ] Real backend API integration
|
- [ ] Real backend API integration
|
||||||
|
|||||||
+220
-30
@@ -1,35 +1,98 @@
|
|||||||
# 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
|
||||||
|
|
||||||
```
|
```
|
||||||
components/
|
components/
|
||||||
├── atoms/ # Basic building blocks (Button, Input, Text, Badge...)
|
├── atoms/ # Basic building blocks — 14 atoms (Button, Input, Text, Badge...) — see atoms/ATOMS.md
|
||||||
├── molecules/ # Groups of atoms (ProductCard, SearchBar...)
|
├── molecules/ # Groups of atoms — 5 molecules (ProductCard, ShopCard, ShiftCard, SearchBar, PaymentSummaryCard)
|
||||||
├── organisms/ # Complex UI sections (CategorySidebar, CartFab, ProductGrid, ReviewModal, analytics charts...)
|
├── organisms/ # Complex UI sections — 27 organisms (analytics, cart, forms, manager, modals, navigation, grids, shift-schedule)
|
||||||
└── templates/ # Page layout structures (MainLayout, AuthLayout...)
|
└── templates/ # Page layout structures — 5 templates (MainLayout, AuthLayout, FeedLayout, ManagerLayout, StaffLayout)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ATOMS
|
||||||
|
|
||||||
|
See [`atoms/ATOMS.md`](atoms/ATOMS.md) for full atom documentation (14 atoms).
|
||||||
|
|
||||||
|
**Summary of atom groups:**
|
||||||
|
|
||||||
|
| Folder | Atoms |
|
||||||
|
| ----------- | -------------------------------------------- |
|
||||||
|
| buttons/ | Button, IconButton |
|
||||||
|
| inputs/ | TextInput, SearchInput, Textarea, LoginInput |
|
||||||
|
| typography/ | Heading, Text, Caption |
|
||||||
|
| badges/ | Badge, PriceBadge |
|
||||||
|
| dividers/ | Divider |
|
||||||
|
| errors/ | ErrorMessageLogin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MOLECULES
|
## MOLECULES
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
|
**Atomic level:** Molecule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ShiftCard (`molecules/cards/ShiftCard.tsx`)
|
||||||
|
|
||||||
|
**Description:** Card displaying a single shift slot. Shows start/end time,
|
||||||
|
duration, wage, status badge, and registered staff list. Supports a compact mode
|
||||||
|
for use in calendar grids.
|
||||||
|
|
||||||
|
**Atomic level:** Molecule
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ShiftCardProps {
|
||||||
|
shift: ShiftSlot; // Shift slot data (status, times, staff, etc.)
|
||||||
|
compact?: boolean; // Compact mode for calendar cells (default: false)
|
||||||
|
onClick?: (shift: ShiftSlot) => void; // Click handler
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status variants:** `available` (blue), `registered` (dark blue),
|
||||||
|
`approved_leave` (purple), `absent` (red)
|
||||||
|
|
||||||
|
**Used in:** `WeeklySchedule`, `MobileShiftView`, `MonthlyCalendar`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
|
**Atomic level:** Molecule (wraps SearchInput atom)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PaymentSummaryCard (`molecules/cards/PaymentSummaryCard.tsx`)
|
||||||
|
|
||||||
|
**Description:** Summary card displayed on the payment page. Shows cart items,
|
||||||
|
subtotal, and payment method selection. Triggers cash or QR payment modals.
|
||||||
|
|
||||||
|
**Atomic level:** Molecule
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,49 +100,162 @@ 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`)
|
---
|
||||||
|
|
||||||
**Description:** Modal for customer reviews. Shows 5-star rating and textarea. After submission, displays thank-you message.
|
### LoginForm (`organisms/forms/LoginForm.tsx`)
|
||||||
|
|
||||||
|
**Description:** Login form organism. Uses LoginInput and ErrorMessageLogin
|
||||||
|
atoms. Handles admin/staff/customer login flows via useAuth context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Manager Organisms (`organisms/manager/`)
|
||||||
|
|
||||||
|
Components for the manager dashboard product/category/combo management.
|
||||||
|
|
||||||
|
| Component | File | Description |
|
||||||
|
| ---------------- | ----------------- | ------------------------------------------------------- |
|
||||||
|
| CategoriesTab | CategoriesTab.tsx | Category list with add/edit/delete actions |
|
||||||
|
| CategoryModal | CategoryModal.tsx | Modal form to create/edit a category |
|
||||||
|
| ComboModal | ComboModal.tsx | Modal form to create/edit a combo |
|
||||||
|
| CombosTab | CombosTab.tsx | Combo list with add/edit/delete actions |
|
||||||
|
| DeleteConfirm | DeleteConfirm.tsx | Generic delete confirmation dialog |
|
||||||
|
| ProductModal | ProductModal.tsx | Modal form to create/edit a product (with image upload) |
|
||||||
|
| ProductsTab | ProductsTab.tsx | Product list with add/edit/delete actions |
|
||||||
|
| StatusBadge | StatusBadge.tsx | Badge showing active/inactive status |
|
||||||
|
| Manager.types.ts | Manager.types.ts | Shared TypeScript interfaces for manager components |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modals (`organisms/modals/`)
|
||||||
|
|
||||||
|
| Component | File | Description |
|
||||||
|
| ------------------- | ----------------------- | --------------------------------------------------------- |
|
||||||
|
| ReviewModal | ReviewModal.tsx | 5-star rating and textarea review submission modal |
|
||||||
|
| CashPaymentModal | CashPaymentModal.tsx | Cash payment — input received amount, calculate change |
|
||||||
|
| QRPaymentModal | QRPaymentModal.tsx | QR code payment — generates QR, 5s countdown auto-confirm |
|
||||||
|
| PaymentSuccessModal | PaymentSuccessModal.tsx | Success screen — auto-redirects to home after 1 second |
|
||||||
|
| Modal.types.ts | Modal.types.ts | Shared TypeScript interfaces for all modal components |
|
||||||
|
|
||||||
|
#### CashPaymentModal
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CashPaymentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
items: InvoiceItem[]; // { name, quantity, price }[]
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- ARIA: `role="dialog"`, `aria-modal="true"`,
|
||||||
|
`aria-labelledby="cash-modal-title"`
|
||||||
|
- Shows invoice list, cash received input, change due calculation, insufficient
|
||||||
|
cash error
|
||||||
|
|
||||||
|
#### QRPaymentModal
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface QRPaymentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses `qrcode.react` (`QRCodeSVG`) to render a QR code
|
||||||
|
- 5-second countdown timer — auto-calls `onConfirm` when it reaches 0
|
||||||
|
- ARIA: `role="dialog"`, `aria-modal="true"`, `aria-labelledby="qr-modal-title"`
|
||||||
|
|
||||||
|
#### PaymentSuccessModal
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PaymentSuccessModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Auto-redirects to `/` after 1 second via `useRouter`
|
||||||
|
- ARIA: `role="dialog"`, `aria-modal="true"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Shift Schedule (`organisms/shift-schedule/`)
|
||||||
|
|
||||||
|
Components for the staff shift scheduling feature.
|
||||||
|
|
||||||
|
| Component | File | Description |
|
||||||
|
| ---------------- | -------------------- | ----------------------------------------------------- |
|
||||||
|
| WeeklySchedule | WeeklySchedule.tsx | Weekly calendar grid showing shifts per day |
|
||||||
|
| MonthlyCalendar | MonthlyCalendar.tsx | Monthly calendar view with shift indicators per day |
|
||||||
|
| MobileShiftView | MobileShiftView.tsx | Mobile-optimized list/card view of shifts |
|
||||||
|
| ShiftCreateModal | ShiftCreateModal.tsx | Form modal for registering for or creating a shift |
|
||||||
|
| ShiftDetailModal | ShiftDetailModal.tsx | Detail view of a shift with its registered staff list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## TEMPLATES
|
## TEMPLATES
|
||||||
|
|
||||||
### 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 +263,13 @@ 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.
|
||||||
|
|
||||||
|
### StaffLayout (`templates/staff-layout/`)
|
||||||
|
|
||||||
|
**Description:** Staff layout template — wraps staff-facing pages (shift
|
||||||
|
schedule, etc.). Used by the staff route group.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,16 +277,24 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2026-04-18
|
||||||
|
|||||||
+203
-57
@@ -1,16 +1,18 @@
|
|||||||
# Atoms Components Library
|
# Atoms Components Library
|
||||||
|
|
||||||
**Status:** ✅ Phase 1 Implementation Complete
|
**Status:** ✅ Complete — 14 atoms **Created:** 2026-04-03 **Foundation:**
|
||||||
**Created:** 2026-04-03
|
Atomic Design Pattern
|
||||||
**Foundation:** Atomic Design Pattern
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Overview
|
## 📚 Overview
|
||||||
|
|
||||||
Atoms are the basic building blocks of your UI. They are small, focused, reusable components that have no dependencies on other components (except potentially other atoms).
|
Atoms are the basic building blocks of your UI. They are small, focused,
|
||||||
|
reusable components that have no dependencies on other components (except
|
||||||
|
potentially other atoms).
|
||||||
|
|
||||||
**Key Principles:**
|
**Key Principles:**
|
||||||
|
|
||||||
- ✅ No business logic
|
- ✅ No business logic
|
||||||
- ✅ No context/hooks (useAuth, useCart, etc.)
|
- ✅ No context/hooks (useAuth, useCart, etc.)
|
||||||
- ✅ Pure props-based
|
- ✅ Pure props-based
|
||||||
@@ -20,14 +22,16 @@ Atoms are the basic building blocks of your UI. They are small, focused, reusabl
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Atoms Created
|
## 🎯 Atoms Created (14 total)
|
||||||
|
|
||||||
### Buttons (`buttons/`)
|
### Buttons (`buttons/`)
|
||||||
|
|
||||||
#### **Button.tsx**
|
#### **Button.tsx**
|
||||||
|
|
||||||
Main interactive button component with multiple variants and sizes.
|
Main interactive button component with multiple variants and sizes.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `variant` - "primary" | "secondary" | "danger" | "ghost" (default: "primary")
|
- `variant` - "primary" | "secondary" | "danger" | "ghost" (default: "primary")
|
||||||
- `size` - "sm" | "md" | "lg" (default: "md")
|
- `size` - "sm" | "md" | "lg" (default: "md")
|
||||||
- `icon` - FontAwesome icon class (optional)
|
- `icon` - FontAwesome icon class (optional)
|
||||||
@@ -38,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";
|
||||||
|
|
||||||
@@ -55,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
|
||||||
@@ -63,15 +69,18 @@ import { Button } from "@/components/atoms";
|
|||||||
---
|
---
|
||||||
|
|
||||||
#### **IconButton.tsx**
|
#### **IconButton.tsx**
|
||||||
|
|
||||||
Button designed specifically for icon-only interactions.
|
Button designed specifically for icon-only interactions.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `variant` - "primary" | "secondary" | "danger" | "ghost"
|
- `variant` - "primary" | "secondary" | "danger" | "ghost"
|
||||||
- `size` - "sm" (8x8) | "md" (10x10) | "lg" (12x12)
|
- `size` - "sm" (8x8) | "md" (10x10) | "lg" (12x12)
|
||||||
- `icon` - FontAwesome icon class (required)
|
- `icon` - FontAwesome icon class (required)
|
||||||
- All standard HTML button attributes
|
- All standard HTML button attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { IconButton } from "@/components/atoms";
|
import { IconButton } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -84,9 +93,11 @@ import { IconButton } from "@/components/atoms";
|
|||||||
### Inputs (`inputs/`)
|
### Inputs (`inputs/`)
|
||||||
|
|
||||||
#### **TextInput.tsx**
|
#### **TextInput.tsx**
|
||||||
|
|
||||||
General text input field with optional label, error, and icon.
|
General text input field with optional label, error, and icon.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `label` - string (optional)
|
- `label` - string (optional)
|
||||||
- `error` - string (optional, shows red border and error text)
|
- `error` - string (optional, shows red border and error text)
|
||||||
- `icon` - FontAwesome icon class (optional)
|
- `icon` - FontAwesome icon class (optional)
|
||||||
@@ -94,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";
|
||||||
|
|
||||||
@@ -115,9 +127,11 @@ import { TextInput } from "@/components/atoms";
|
|||||||
---
|
---
|
||||||
|
|
||||||
#### **SearchInput.tsx**
|
#### **SearchInput.tsx**
|
||||||
|
|
||||||
Search input with built-in search icon and clear button.
|
Search input with built-in search icon and clear button.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `value` - string (controlled input)
|
- `value` - string (controlled input)
|
||||||
- `onChange` - callback when text changes
|
- `onChange` - callback when text changes
|
||||||
- `onClear` - callback for clear button (required for button to show)
|
- `onClear` - callback for clear button (required for button to show)
|
||||||
@@ -125,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";
|
||||||
|
|
||||||
@@ -135,20 +150,23 @@ const [query, setQuery] = useState("");
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onClear={() => setQuery("")}
|
onClear={() => setQuery("")}
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
/>
|
/>;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### **Textarea.tsx**
|
#### **Textarea.tsx**
|
||||||
|
|
||||||
Multi-line text input with optional label and error.
|
Multi-line text input with optional label and error.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `label` - string (optional)
|
- `label` - string (optional)
|
||||||
- `error` - string (optional)
|
- `error` - string (optional)
|
||||||
- All standard HTML textarea attributes
|
- All standard HTML textarea attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Textarea } from "@/components/atoms";
|
import { Textarea } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -158,22 +176,77 @@ import { Textarea } from "@/components/atoms";
|
|||||||
rows={4}
|
rows={4}
|
||||||
value={review}
|
value={review}
|
||||||
onChange={(e) => setReview(e.target.value)}
|
onChange={(e) => setReview(e.target.value)}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **LoginInput.tsx**
|
||||||
|
|
||||||
|
Specialized input for the login form. Supports username/phone and password
|
||||||
|
fields. Password fields include a built-in show/hide toggle button. Displays a
|
||||||
|
user icon on desktop viewports.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string; // Field label (rendered as <label>)
|
||||||
|
type: string; // Input type — "text" | "password"
|
||||||
|
name: string; // Input id and name (used for label htmlFor)
|
||||||
|
value: string; // Controlled value
|
||||||
|
errors?: string; // Error string — adds red border when set
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import LoginInput from "@/components/atoms/inputs/LoginInput";
|
||||||
|
|
||||||
|
<LoginInput
|
||||||
|
label="Tên đăng nhập"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
errors={errors.username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoginInput
|
||||||
|
label="Mật khẩu"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
errors={errors.password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Password type shows a show/hide toggle button (ARIA-labelled)
|
||||||
|
- Desktop: renders a `fa-user` icon at left via `lg:block`
|
||||||
|
- Error state: red border via `border-red-400` when `errors` is set
|
||||||
|
- Used in: `LoginForm.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Typography (`typography/`)
|
### Typography (`typography/`)
|
||||||
|
|
||||||
#### **Heading.tsx**
|
#### **Heading.tsx**
|
||||||
|
|
||||||
Semantic heading component with level-based sizing.
|
Semantic heading component with level-based sizing.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `level` - 1 | 2 | 3 | 4 | 5 | 6 (default: 2)
|
- `level` - 1 | 2 | 3 | 4 | 5 | 6 (default: 2)
|
||||||
- `children` - ReactNode
|
- `children` - ReactNode
|
||||||
- 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
|
||||||
@@ -182,6 +255,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";
|
||||||
|
|
||||||
@@ -193,20 +267,24 @@ import { Heading } from "@/components/atoms";
|
|||||||
---
|
---
|
||||||
|
|
||||||
#### **Text.tsx**
|
#### **Text.tsx**
|
||||||
|
|
||||||
Paragraph text with semantic variants.
|
Paragraph text with semantic variants.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `variant` - "body1" | "body2" | "caption" | "label" (default: "body1")
|
- `variant` - "body1" | "body2" | "caption" | "label" (default: "body1")
|
||||||
- `children` - ReactNode
|
- `children` - ReactNode
|
||||||
- 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";
|
||||||
|
|
||||||
@@ -219,13 +297,16 @@ import { Text } from "@/components/atoms";
|
|||||||
---
|
---
|
||||||
|
|
||||||
#### **Caption.tsx**
|
#### **Caption.tsx**
|
||||||
|
|
||||||
Small caption/note text component.
|
Small caption/note text component.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `children` - ReactNode
|
- `children` - ReactNode
|
||||||
- All standard HTML span attributes
|
- All standard HTML span attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Caption } from "@/components/atoms";
|
import { Caption } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -238,15 +319,19 @@ import { Caption } from "@/components/atoms";
|
|||||||
### Badges (`badges/`)
|
### Badges (`badges/`)
|
||||||
|
|
||||||
#### **Badge.tsx**
|
#### **Badge.tsx**
|
||||||
|
|
||||||
Labeled badge component for highlighting information.
|
Labeled badge component for highlighting information.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
- `variant` - "primary" | "secondary" | "success" | "danger" | "warning" (default: "primary")
|
|
||||||
|
- `variant` - "primary" | "secondary" | "success" | "danger" | "warning"
|
||||||
|
(default: "primary")
|
||||||
- `size` - "sm" | "md" (default: "md")
|
- `size` - "sm" | "md" (default: "md")
|
||||||
- `children` - ReactNode
|
- `children` - ReactNode
|
||||||
- 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
|
||||||
@@ -254,6 +339,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";
|
||||||
|
|
||||||
@@ -265,14 +351,17 @@ import { Badge } from "@/components/atoms";
|
|||||||
---
|
---
|
||||||
|
|
||||||
#### **PriceBadge.tsx**
|
#### **PriceBadge.tsx**
|
||||||
|
|
||||||
Specialized badge for displaying formatted prices.
|
Specialized badge for displaying formatted prices.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `price` - number
|
- `price` - number
|
||||||
- `currency` - string (default: "VND")
|
- `currency` - string (default: "VND")
|
||||||
- All standard HTML span attributes
|
- All standard HTML span attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { PriceBadge } from "@/components/atoms";
|
import { PriceBadge } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -281,6 +370,7 @@ import { PriceBadge } from "@/components/atoms";
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
- VND: "50.000 ₫"
|
- VND: "50.000 ₫"
|
||||||
- USD: "$99.99"
|
- USD: "$99.99"
|
||||||
|
|
||||||
@@ -289,13 +379,16 @@ import { PriceBadge } from "@/components/atoms";
|
|||||||
### Dividers (`dividers/`)
|
### Dividers (`dividers/`)
|
||||||
|
|
||||||
#### **Divider.tsx**
|
#### **Divider.tsx**
|
||||||
|
|
||||||
Visual separator element.
|
Visual separator element.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
|
|
||||||
- `orientation` - "horizontal" | "vertical" (default: "horizontal")
|
- `orientation` - "horizontal" | "vertical" (default: "horizontal")
|
||||||
- All standard HTML hr attributes
|
- All standard HTML hr attributes
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Divider } from "@/components/atoms";
|
import { Divider } from "@/components/atoms";
|
||||||
|
|
||||||
@@ -305,6 +398,50 @@ import { Divider } from "@/components/atoms";
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Errors (`errors/`)
|
||||||
|
|
||||||
|
#### **ErrorMessageLogin.tsx**
|
||||||
|
|
||||||
|
Displays error messages in the login form. Two visual styles controlled by the
|
||||||
|
`type` prop: a prominent alert box (`primary`) and a compact inline message
|
||||||
|
(`secondary`). Both include a `fa-circle-exclamation` icon.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ErrorMessageLoginProps {
|
||||||
|
message: string; // Error message text to display
|
||||||
|
type?: string; // "primary" (default) | "secondary"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
- `primary` (default): Rounded alert box — red border, red-50 background,
|
||||||
|
`text-red-600`, `text-sm`. Used for form-level errors (e.g. wrong password).
|
||||||
|
- `secondary`: Inline paragraph — `text-red-500`, `text-xs`. Used for
|
||||||
|
field-level errors below individual inputs.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||||
|
|
||||||
|
{
|
||||||
|
/* Form-level error */
|
||||||
|
}
|
||||||
|
<ErrorMessageLogin message="Tên đăng nhập hoặc mật khẩu không đúng" />;
|
||||||
|
|
||||||
|
{
|
||||||
|
/* Field-level inline error */
|
||||||
|
}
|
||||||
|
<ErrorMessageLogin message="Vui lòng nhập mật khẩu" type="secondary" />;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Used in:** `LoginForm.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🎨 Theming
|
## 🎨 Theming
|
||||||
|
|
||||||
All atoms use CSS variables for consistent theming:
|
All atoms use CSS variables for consistent theming:
|
||||||
@@ -332,34 +469,45 @@ Change one variable in `globals.css` to update all atoms across the app.
|
|||||||
## 🔄 Import Patterns
|
## 🔄 Import Patterns
|
||||||
|
|
||||||
### Individual Imports
|
### Individual Imports
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Button } from "@/components/atoms/buttons";
|
|
||||||
import { TextInput, SearchInput } from "@/components/atoms/inputs";
|
|
||||||
import { Heading, Text, Caption } from "@/components/atoms/typography";
|
|
||||||
import { Badge, PriceBadge } from "@/components/atoms/badges";
|
import { Badge, PriceBadge } from "@/components/atoms/badges";
|
||||||
|
import { Button } from "@/components/atoms/buttons";
|
||||||
import { Divider } from "@/components/atoms/dividers";
|
import { Divider } from "@/components/atoms/dividers";
|
||||||
|
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||||
|
import { LoginInput, SearchInput, TextInput } from "@/components/atoms/inputs";
|
||||||
|
import { Caption, Heading, Text } from "@/components/atoms/typography";
|
||||||
```
|
```
|
||||||
|
|
||||||
### Barrel Exports (Recommended)
|
### Barrel Exports (Recommended)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
TextInput,
|
|
||||||
SearchInput,
|
|
||||||
Textarea,
|
|
||||||
Heading,
|
|
||||||
Text,
|
|
||||||
Caption,
|
|
||||||
Badge,
|
Badge,
|
||||||
PriceBadge,
|
Button,
|
||||||
|
Caption,
|
||||||
Divider,
|
Divider,
|
||||||
|
Heading,
|
||||||
|
IconButton,
|
||||||
|
PriceBadge,
|
||||||
|
SearchInput,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
} from "@/components/atoms";
|
} from "@/components/atoms";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Note: `ErrorMessageLogin` and `LoginInput` are imported directly from their
|
||||||
|
> subfolders as they are auth-specific and not re-exported from the root barrel.
|
||||||
|
|
||||||
### Type Imports
|
### Type Imports
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/atoms";
|
import type {
|
||||||
|
ButtonProps,
|
||||||
|
HeadingProps,
|
||||||
|
TextInputProps,
|
||||||
|
} from "@/components/atoms";
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -367,13 +515,10 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
|||||||
## ✅ Usage Examples
|
## ✅ Usage Examples
|
||||||
|
|
||||||
### Form Field
|
### Form Field
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextInput
|
<TextInput label="Name" placeholder="Enter your name" type="text" />
|
||||||
label="Name"
|
|
||||||
placeholder="Enter your name"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
@@ -385,11 +530,12 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 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>
|
||||||
<Text variant="body2">Product description</Text>
|
<Text variant="body2">Product description</Text>
|
||||||
<div className="flex items-center justify-between mt-4">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<PriceBadge price={50000} />
|
<PriceBadge price={50000} />
|
||||||
<Button icon="fa-cart-plus">Add to Cart</Button>
|
<Button icon="fa-cart-plus">Add to Cart</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,6 +543,7 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Rating Display
|
### Rating Display
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<div>
|
<div>
|
||||||
<Heading level={4}>Reviews</Heading>
|
<Heading level={4}>Reviews</Heading>
|
||||||
@@ -410,6 +557,7 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
|||||||
## 🧪 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>
|
||||||
@@ -420,7 +568,9 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Accessibility
|
### Accessibility
|
||||||
|
|
||||||
All atoms include:
|
All atoms include:
|
||||||
|
|
||||||
- Semantic HTML
|
- Semantic HTML
|
||||||
- ARIA labels where appropriate
|
- ARIA labels where appropriate
|
||||||
- Keyboard navigation support
|
- Keyboard navigation support
|
||||||
@@ -433,49 +583,46 @@ All atoms include:
|
|||||||
|
|
||||||
```
|
```
|
||||||
components/atoms/
|
components/atoms/
|
||||||
├── buttons/
|
|
||||||
│ ├── Button.tsx
|
|
||||||
│ ├── Button.types.ts
|
|
||||||
│ ├── IconButton.tsx
|
|
||||||
│ └── index.ts
|
|
||||||
├── inputs/
|
|
||||||
│ ├── TextInput.tsx
|
|
||||||
│ ├── SearchInput.tsx
|
|
||||||
│ ├── Textarea.tsx
|
|
||||||
│ ├── Input.types.ts
|
|
||||||
│ └── index.ts
|
|
||||||
├── typography/
|
|
||||||
│ ├── Heading.tsx
|
|
||||||
│ ├── Text.tsx
|
|
||||||
│ ├── Caption.tsx
|
|
||||||
│ ├── Typography.types.ts
|
|
||||||
│ └── index.ts
|
|
||||||
├── badges/
|
├── badges/
|
||||||
│ ├── Badge.tsx
|
│ ├── Badge.tsx
|
||||||
│ ├── Badge.types.ts
|
│ ├── Badge.types.ts
|
||||||
│ ├── PriceBadge.tsx
|
│ ├── PriceBadge.tsx
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
|
├── buttons/
|
||||||
|
│ ├── Button.tsx
|
||||||
|
│ ├── Button.types.ts
|
||||||
|
│ ├── IconButton.tsx
|
||||||
|
│ └── index.ts
|
||||||
├── dividers/
|
├── dividers/
|
||||||
│ ├── Divider.tsx
|
│ ├── Divider.tsx
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
|
├── errors/ ← auth error display
|
||||||
|
│ ├── Error.types.ts
|
||||||
|
│ └── ErrorMessageLogin.tsx
|
||||||
|
├── inputs/
|
||||||
|
│ ├── Input.types.ts
|
||||||
|
│ ├── LoginInput.tsx ← auth-specific input
|
||||||
|
│ ├── SearchInput.tsx
|
||||||
|
│ ├── Textarea.tsx
|
||||||
|
│ ├── TextInput.tsx
|
||||||
|
│ └── index.ts
|
||||||
|
├── typography/
|
||||||
|
│ ├── Caption.tsx
|
||||||
|
│ ├── Heading.tsx
|
||||||
|
│ ├── Text.tsx
|
||||||
|
│ ├── Typography.types.ts
|
||||||
|
│ └── index.ts
|
||||||
├── index.ts (barrel export)
|
├── index.ts (barrel export)
|
||||||
└── ATOMS.md (this file)
|
└── ATOMS.md (this file)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Next Steps
|
## 🚀 Status
|
||||||
|
|
||||||
Once atoms are comfortable, the next phase is creating **Molecules** - combinations of atoms:
|
Atoms are stable and in use across molecules and organisms. Molecules are
|
||||||
|
implemented — see `components/COMPONENTS.md` for details. See
|
||||||
- **ProductCard** - Image + Text + Badge + Button
|
`OPTIMIZATION_PLAN.md` for the full roadmap.
|
||||||
- **FormField** - Label + Input + Error Text
|
|
||||||
- **SearchBar** - SearchInput + Button
|
|
||||||
- **RatingInput** - Interactive 5-star rating
|
|
||||||
- **PriceTag** - Price display with formatting
|
|
||||||
- And more...
|
|
||||||
|
|
||||||
See `OPTIMIZATION_PLAN.md` for the full roadmap.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -489,6 +636,5 @@ See `OPTIMIZATION_PLAN.md` for the full roadmap.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Status:** ✅ Complete & Ready for Use
|
**Status:** ✅ Complete & Ready for Use **Last Updated:** 2026-04-18 **Atoms:**
|
||||||
**Last Updated:** 2026-04-03
|
14
|
||||||
**Phase:** 1 of 5
|
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ export default function Button({
|
|||||||
size = "md",
|
size = "md",
|
||||||
icon,
|
icon,
|
||||||
iconPosition = "left",
|
iconPosition = "left",
|
||||||
|
className = "",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const styles = {
|
const styles = {
|
||||||
base: "font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5",
|
base: "font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5",
|
||||||
payment: "inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl transition-colors",
|
payment:
|
||||||
login: "w-full cursor-pointer rounded-xl py-3 font-semibold transition-all duration-150",
|
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl transition-colors",
|
||||||
}
|
login:
|
||||||
|
"w-full cursor-pointer rounded-xl py-3 font-semibold transition-all duration-150",
|
||||||
|
};
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary:
|
primary:
|
||||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||||
@@ -25,8 +28,10 @@ export default function Button({
|
|||||||
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
|
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
|
||||||
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
|
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
|
||||||
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
|
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
|
||||||
primaryNoBorder: "border-none bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-98",
|
primaryNoBorder:
|
||||||
bgWhite: "border-2 border-(--color-primary) bg-white text-(--color-primary) hover:bg-(--color-primary) hover:text-white active:scale-98",
|
"border-none bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-98",
|
||||||
|
bgWhite:
|
||||||
|
"border-2 border-(--color-primary) bg-white text-(--color-primary) hover:bg-(--color-primary) hover:text-white active:scale-98",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
@@ -39,7 +44,7 @@ export default function Button({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${styles[style]} ${variants[variant]} ${sizes[size]}`}
|
className={`${styles[style]} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { ButtonHTMLAttributes } from "react";
|
import { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'style'> {
|
export interface ButtonProps extends Omit<
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
"style"
|
||||||
|
> {
|
||||||
style?: "base" | "payment" | "login";
|
style?: "base" | "payment" | "login";
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost" | "primaryNoBorder" | "bgWhite";
|
variant?:
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "danger"
|
||||||
|
| "ghost"
|
||||||
|
| "primaryNoBorder"
|
||||||
|
| "bgWhite";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
icon?: string; // FontAwesome class like "fa-solid fa-cart-plus"
|
icon?: string; // FontAwesome class like "fa-solid fa-cart-plus"
|
||||||
iconPosition?: "left" | "right";
|
iconPosition?: "left" | "right";
|
||||||
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ButtonProps } from "./Button.types";
|
||||||
|
|
||||||
|
export default function IconButton({
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
style: _style,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const baseStyles =
|
||||||
|
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
|
||||||
|
|
||||||
|
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||||
|
secondary:
|
||||||
|
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
|
||||||
|
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
|
||||||
|
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
|
||||||
|
primaryNoBorder:
|
||||||
|
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||||
|
bgWhite:
|
||||||
|
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: "h-8 w-8 text-sm",
|
||||||
|
md: "h-10 w-10 text-base",
|
||||||
|
lg: "h-12 w-12 text-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@ import { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|||||||
export interface ErrorMessageLoginProps {
|
export interface ErrorMessageLoginProps {
|
||||||
message: string;
|
message: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,5 @@ export default function ErrorMessageLogin({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return type === "primary" ? primaryType() : secondaryType();
|
||||||
type === "primary" ? primaryType() : secondaryType()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
export { default as ErrorMessageLogin } from "./ErrorMessageLogin";
|
export { default as ErrorMessageLogin } from "./ErrorMessageLogin";
|
||||||
export type {
|
export type { ErrorMessageLoginProps } from "./Error.types";
|
||||||
ErrorMessageLoginProps,
|
|
||||||
} from "./Error.types";
|
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export default function LoginInput({
|
|||||||
type={showPassword ? "text" : type}
|
type={showPassword ? "text" : type}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder= {type === "password" ? "Mật khẩu" : "admin / số điện thoại / tên nhân viên"}
|
placeholder={
|
||||||
|
type === "password"
|
||||||
|
? "Mật khẩu"
|
||||||
|
: "admin / số điện thoại / tên nhân viên"
|
||||||
|
}
|
||||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors ? "border-red-400" : "border-(--color-border)"} `}
|
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors ? "border-red-400" : "border-(--color-border)"} `}
|
||||||
/>
|
/>
|
||||||
{isPassword()}
|
{isPassword()}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ export default function SearchInput({
|
|||||||
/>
|
/>
|
||||||
{value && onClear && (
|
{value && onClear && (
|
||||||
<button
|
<button
|
||||||
|
title="Xóa"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||||
|
aria-label="Xóa"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-xmark text-sm"></i>
|
<i className="fa-solid fa-xmark text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default function TextInput({
|
|||||||
/>
|
/>
|
||||||
{icon && (
|
{icon && (
|
||||||
<button
|
<button
|
||||||
|
title="Thao tác biểu tượng"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onIconClick}
|
onClick={onIconClick}
|
||||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Product } from "@/lib/types";
|
import type { CartItem } from "@/lib/cart-context";
|
||||||
|
import type { Product, UserRole } from "@/lib/types";
|
||||||
|
|
||||||
export interface ProductCardProps {
|
export interface ProductCardProps {
|
||||||
image: string;
|
image: string;
|
||||||
@@ -18,8 +19,8 @@ export interface ShopCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentSummaryCardProps {
|
export interface PaymentSummaryCardProps {
|
||||||
|
cartItems: CartItem[];
|
||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
isCustomer: boolean;
|
role: UserRole;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
onPay?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,46 @@
|
|||||||
import Button from "@/components/atoms/buttons/Button";
|
"use client";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { ReviewModal } from "@/components/organisms/modals";
|
import Button from "@/components/atoms/buttons/Button";
|
||||||
|
import {
|
||||||
|
CashPaymentModal,
|
||||||
|
PaymentSuccessModal,
|
||||||
|
QRPaymentModal,
|
||||||
|
} from "@/components/organisms/modals";
|
||||||
|
import type { InvoiceItem } from "@/components/organisms/modals";
|
||||||
|
import { useCart } from "@/lib/cart-context";
|
||||||
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { PaymentSummaryCardProps } from "./Card.types";
|
import type { PaymentSummaryCardProps } from "./Card.types";
|
||||||
|
|
||||||
|
type ModalState = "none" | "cash" | "qr" | "success";
|
||||||
|
|
||||||
const formatPrice = (value: number) =>
|
const formatPrice = (value: number) =>
|
||||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||||
|
|
||||||
export default function PaymentSummaryCard({
|
export default function PaymentSummaryCard({
|
||||||
|
cartItems,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
isCustomer = false,
|
role,
|
||||||
backHref,
|
backHref,
|
||||||
}: PaymentSummaryCardProps) {
|
}: PaymentSummaryCardProps) {
|
||||||
|
const [openModal, setOpenModal] = useState<ModalState>("none");
|
||||||
|
const { clearCart } = useCart();
|
||||||
|
|
||||||
|
const isCustomer = role === "customer";
|
||||||
|
|
||||||
|
const invoiceItems: InvoiceItem[] = cartItems.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: item.price,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handlePaymentConfirm = () => setOpenModal("success");
|
||||||
|
const handleSuccessClose = () => {
|
||||||
|
clearCart();
|
||||||
|
setOpenModal("none");
|
||||||
|
};
|
||||||
|
|
||||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
|
||||||
const handlePayment = () => {
|
|
||||||
// UI-only: open review modal after "payment"
|
|
||||||
if (isCustomer) {
|
|
||||||
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">
|
||||||
@@ -35,42 +54,32 @@ export default function PaymentSummaryCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
<Button
|
{!isCustomer && (
|
||||||
style="payment"
|
<Button
|
||||||
onClick={handlePayment}
|
style="payment"
|
||||||
icon="fa-solid fa-money-bill-wave"
|
onClick={() => setOpenModal("cash")}
|
||||||
size="md"
|
icon="fa-solid fa-money-bill-wave"
|
||||||
variant="primary"
|
size="md"
|
||||||
>
|
variant="primary"
|
||||||
Tiền mặt
|
>
|
||||||
</Button>
|
Tiền mặt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
style="payment"
|
style="payment"
|
||||||
onClick={handlePayment}
|
onClick={() => setOpenModal("qr")}
|
||||||
icon="fa-solid fa-qrcode"
|
icon="fa-solid fa-qrcode"
|
||||||
size="md"
|
size="md"
|
||||||
variant="secondary"
|
variant={isCustomer ? "primary" : "secondary"}
|
||||||
|
className={isCustomer ? "col-span-2" : ""}
|
||||||
>
|
>
|
||||||
QR Code
|
QR Code
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isCustomer && (
|
<Link href={backHref || "/"} className="col-span-2">
|
||||||
<Button
|
<Button
|
||||||
style="payment"
|
style="payment"
|
||||||
onClick={handlePayment}
|
|
||||||
icon="fa-solid fa-star"
|
|
||||||
size="md"
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
Đánh giá
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link href={backHref || "/"} className={isCustomer ? "" : "col-span-2"}>
|
|
||||||
<Button
|
|
||||||
style="payment"
|
|
||||||
onClick={() => setIsReviewOpen(false)}
|
|
||||||
icon="fa-solid fa-arrow-rotate-left"
|
icon="fa-solid fa-arrow-rotate-left"
|
||||||
size="md"
|
size="md"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -82,9 +91,25 @@ export default function PaymentSummaryCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReviewModal
|
<CashPaymentModal
|
||||||
isOpen={isReviewOpen}
|
isOpen={openModal === "cash"}
|
||||||
onClose={() => setIsReviewOpen(false)}
|
onClose={() => setOpenModal("none")}
|
||||||
|
onConfirm={handlePaymentConfirm}
|
||||||
|
items={invoiceItems}
|
||||||
|
totalAmount={totalPrice}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QRPaymentModal
|
||||||
|
isOpen={openModal === "qr"}
|
||||||
|
onClose={() => setOpenModal("none")}
|
||||||
|
onConfirm={handlePaymentConfirm}
|
||||||
|
items={invoiceItems}
|
||||||
|
totalAmount={totalPrice}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaymentSuccessModal
|
||||||
|
isOpen={openModal === "success"}
|
||||||
|
onClose={handleSuccessClose}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ export default function ProductCard({
|
|||||||
<Text variant="body2" className="font-bold">
|
<Text variant="body2" className="font-bold">
|
||||||
{formattedPrice}
|
{formattedPrice}
|
||||||
</Text>
|
</Text>
|
||||||
<Button onClick={onBuy} variant="primary" size="sm" icon="fa-cart-plus">
|
<Button
|
||||||
|
onClick={onBuy}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon="fa-cart-plus"
|
||||||
|
aria-label={`Mua ${productName}`}
|
||||||
|
>
|
||||||
Mua
|
Mua
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ShiftSlot } from "@/lib/types";
|
||||||
|
|
||||||
|
import type { ShiftCardProps } from "./ShiftCard.types";
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<
|
||||||
|
ShiftSlot["status"],
|
||||||
|
{ bg: string; text: string; label: string }
|
||||||
|
> = {
|
||||||
|
available: {
|
||||||
|
bg: "bg-blue-50 border-blue-200",
|
||||||
|
text: "text-blue-700",
|
||||||
|
label: "Còn trống",
|
||||||
|
},
|
||||||
|
registered: {
|
||||||
|
bg: "bg-blue-100 border-blue-400",
|
||||||
|
text: "text-blue-900",
|
||||||
|
label: "Đã đăng ký",
|
||||||
|
},
|
||||||
|
approved_leave: {
|
||||||
|
bg: "bg-purple-50 border-purple-300",
|
||||||
|
text: "text-purple-700",
|
||||||
|
label: "Nghỉ phép",
|
||||||
|
},
|
||||||
|
absent: {
|
||||||
|
bg: "bg-red-50 border-red-300",
|
||||||
|
text: "text-red-700",
|
||||||
|
label: "Vắng mặt",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatWage(wage: number): string {
|
||||||
|
if (wage >= 1000) {
|
||||||
|
return `${(wage / 1000).toFixed(0)}k`;
|
||||||
|
}
|
||||||
|
return wage.toLocaleString("vi-VN");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShiftCard({
|
||||||
|
shift,
|
||||||
|
compact = false,
|
||||||
|
onClick,
|
||||||
|
}: ShiftCardProps) {
|
||||||
|
const style = STATUS_STYLES[shift.status];
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClick?.(shift)}
|
||||||
|
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm ${style.bg} ${style.text}`}
|
||||||
|
>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{shift.startTime} – {shift.endTime}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 opacity-75">
|
||||||
|
{shift.durationHours}h · {formatWage(shift.wage)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClick?.(shift)}
|
||||||
|
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md ${style.bg} ${style.text}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold">
|
||||||
|
{shift.startTime} – {shift.endTime}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs opacity-75">
|
||||||
|
{shift.durationHours}h · {formatWage(shift.wage)} VND
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||||
|
shift.status === "available"
|
||||||
|
? "bg-blue-200 text-blue-800"
|
||||||
|
: shift.status === "registered"
|
||||||
|
? "bg-blue-300 text-blue-900"
|
||||||
|
: shift.status === "approved_leave"
|
||||||
|
? "bg-purple-200 text-purple-800"
|
||||||
|
: "bg-red-200 text-red-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shift.registeredStaff.length > 0 && (
|
||||||
|
<div className="mt-2 border-t border-current/10 pt-2">
|
||||||
|
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
|
||||||
|
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{shift.registeredStaff.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.id}
|
||||||
|
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shift.status === "available" && shift.registeredStaff.length === 0 && (
|
||||||
|
<p className="mt-2 text-[10px] italic opacity-50">
|
||||||
|
{shift.maxStaff} vị trí còn trống
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ShiftSlot } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface ShiftCardProps {
|
||||||
|
shift: ShiftSlot;
|
||||||
|
compact?: boolean;
|
||||||
|
onClick?: (shift: ShiftSlot) => void;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export { default as ProductCard } from "./ProductCard";
|
export { default as ProductCard } from "./ProductCard";
|
||||||
export { default as ShopCard } from "./ShopCard";
|
export { default as ShopCard } from "./ShopCard";
|
||||||
|
export { default as ShiftCard } from "./ShiftCard";
|
||||||
export type { ProductCardProps, ShopCardProps } from "./Card.types";
|
export type { ProductCardProps, ShopCardProps } from "./Card.types";
|
||||||
|
export type { ShiftCardProps } from "./ShiftCard.types";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function SearchBar({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
aria-label="Tìm kiếm món ăn"
|
||||||
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
|
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && (
|
||||||
|
|||||||
@@ -16,15 +16,25 @@ 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;
|
||||||
|
|
||||||
const n = current.length;
|
const n = current.length;
|
||||||
const maxVal = Math.max(...current.map((d) => d.revenue), ...previous.map((d) => d.revenue)) || 1;
|
const maxVal =
|
||||||
|
Math.max(
|
||||||
|
...current.map((d) => d.revenue),
|
||||||
|
...previous.map((d) => d.revenue),
|
||||||
|
) || 1;
|
||||||
const groupW = chartW / n;
|
const groupW = chartW / n;
|
||||||
const barW = groupW * 0.35;
|
const barW = groupW * 0.35;
|
||||||
const gap = groupW * 0.05;
|
const gap = groupW * 0.05;
|
||||||
@@ -47,8 +57,24 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
|||||||
>
|
>
|
||||||
{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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -62,48 +88,121 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
|||||||
const isHovPrev = hovered?.set === "prev" && hovered.idx === i;
|
const isHovPrev = hovered?.set === "prev" && hovered.idx === i;
|
||||||
return (
|
return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<rect x={prevX} y={padT + chartH - prevH} width={barW} height={prevH} rx="3"
|
<rect
|
||||||
|
x={prevX}
|
||||||
|
y={padT + chartH - prevH}
|
||||||
|
width={barW}
|
||||||
|
height={prevH}
|
||||||
|
rx="3"
|
||||||
fill={isHovPrev ? "#A0785A" : "#E2C9A8"}
|
fill={isHovPrev ? "#A0785A" : "#E2C9A8"}
|
||||||
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
||||||
onMouseEnter={() => setHovered({ set: "prev", idx: i })} />
|
onMouseEnter={() => setHovered({ set: "prev", idx: i })}
|
||||||
<rect x={curX} y={padT + chartH - curH} width={barW} height={curH} rx="3"
|
/>
|
||||||
|
<rect
|
||||||
|
x={curX}
|
||||||
|
y={padT + chartH - curH}
|
||||||
|
width={barW}
|
||||||
|
height={curH}
|
||||||
|
rx="3"
|
||||||
fill={isHovCur ? "#4A3728" : "#6F4E37"}
|
fill={isHovCur ? "#4A3728" : "#6F4E37"}
|
||||||
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
||||||
onMouseEnter={() => setHovered({ set: "cur", idx: i })} />
|
onMouseEnter={() => setHovered({ set: "cur", idx: i })}
|
||||||
|
/>
|
||||||
{i % step === 0 && (
|
{i % step === 0 && (
|
||||||
<text x={groupX + groupW / 2} y={H - 8} textAnchor="middle" fontSize="10" fill="#A08060">{d.label}</text>
|
<text
|
||||||
|
x={groupX + groupW / 2}
|
||||||
|
y={H - 8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="10"
|
||||||
|
fill="#A08060"
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</text>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{hovered !== null && (() => {
|
{hovered !== null &&
|
||||||
const d = hovered.set === "cur" ? current[hovered.idx] : previous[hovered.idx];
|
(() => {
|
||||||
if (!d) return null;
|
const d =
|
||||||
const groupX = padL + hovered.idx * groupW;
|
hovered.set === "cur"
|
||||||
const tipW = 130, tipH = 50;
|
? current[hovered.idx]
|
||||||
const tipX = Math.min(Math.max(groupX - tipW / 2, padL), W - padR - tipW);
|
: previous[hovered.idx];
|
||||||
const barH = (d.revenue / maxVal) * chartH;
|
if (!d) return null;
|
||||||
const barTopY = padT + chartH - barH;
|
const groupX = padL + hovered.idx * groupW;
|
||||||
const aboveY = barTopY - tipH - 8;
|
const tipW = 130,
|
||||||
const tipY = Math.min(Math.max(aboveY >= padT ? aboveY : barTopY + 8, padT), padT + chartH - tipH);
|
tipH = 50;
|
||||||
return (
|
const tipX = Math.min(
|
||||||
<g>
|
Math.max(groupX - tipW / 2, padL),
|
||||||
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="6" fill="#3D2B1F" opacity="0.92" />
|
W - padR - tipW,
|
||||||
<text x={tipX + tipW / 2} y={tipY + 15} textAnchor="middle" fontSize="10" fill="#F0D9A8">
|
);
|
||||||
{d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"})
|
const barH = (d.revenue / maxVal) * chartH;
|
||||||
</text>
|
const barTopY = padT + chartH - barH;
|
||||||
<text x={tipX + tipW / 2} y={tipY + 30} textAnchor="middle" fontSize="11" fontWeight="600" fill="#C8973A">{formatCurrency(d.revenue)}</text>
|
const aboveY = barTopY - tipH - 8;
|
||||||
<text x={tipX + tipW / 2} y={tipY + 44} textAnchor="middle" fontSize="10" fill="#A08060">{d.orders} đơn hàng</text>
|
const tipY = Math.min(
|
||||||
</g>
|
Math.max(aboveY >= padT ? aboveY : barTopY + 8, 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 + 15}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="10"
|
||||||
|
fill="#F0D9A8"
|
||||||
|
>
|
||||||
|
{d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"})
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={tipX + tipW / 2}
|
||||||
|
y={tipY + 30}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="11"
|
||||||
|
fontWeight="600"
|
||||||
|
fill="#C8973A"
|
||||||
|
>
|
||||||
|
{formatCurrency(d.revenue)}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={tipX + tipW / 2}
|
||||||
|
y={tipY + 44}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="10"
|
||||||
|
fill="#A08060"
|
||||||
|
>
|
||||||
|
{d.orders} đơn hàng
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
|
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
|
||||||
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">Hiện tại</text>
|
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
|
||||||
<rect x={padL + 65} y={4} width={10} height={10} rx="2" fill="#E2C9A8" />
|
Hiện tại
|
||||||
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">Kỳ trước</text>
|
</text>
|
||||||
|
<rect
|
||||||
|
x={padL + 65}
|
||||||
|
y={4}
|
||||||
|
width={10}
|
||||||
|
height={10}
|
||||||
|
rx="2"
|
||||||
|
fill="#E2C9A8"
|
||||||
|
/>
|
||||||
|
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
|
||||||
|
Kỳ trước
|
||||||
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -78,7 +78,14 @@ export function PieChart({ data }: PieChartProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hovered !== null && (
|
{hovered !== null && (
|
||||||
<text x={CX} y={CY + 5} textAnchor="middle" fontSize="12" fontWeight="bold" fill="#3D2B1F">
|
<text
|
||||||
|
x={CX}
|
||||||
|
y={CY + 5}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="12"
|
||||||
|
fontWeight="bold"
|
||||||
|
fill="#3D2B1F"
|
||||||
|
>
|
||||||
{slices[hovered].percent.toFixed(1)}%
|
{slices[hovered].percent.toFixed(1)}%
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
@@ -93,14 +100,22 @@ export function PieChart({ data }: PieChartProps) {
|
|||||||
onMouseEnter={() => setHovered(s.index)}
|
onMouseEnter={() => setHovered(s.index)}
|
||||||
onMouseLeave={() => setHovered(null)}
|
onMouseLeave={() => setHovered(null)}
|
||||||
>
|
>
|
||||||
<span className="inline-block h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: s.color }} />
|
<span
|
||||||
|
className="inline-block h-3 w-3 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: s.color }}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className="max-w-35 truncate"
|
className="max-w-35 truncate"
|
||||||
style={{ color: hovered === s.index ? "#3D2B1F" : "#6F4E37", fontWeight: hovered === s.index ? 600 : 400 }}
|
style={{
|
||||||
|
color: hovered === s.index ? "#3D2B1F" : "#6F4E37",
|
||||||
|
fontWeight: hovered === s.index ? 600 : 400,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-(--color-text-muted)">{s.percent.toFixed(1)}%</span>
|
<span className="text-xs text-(--color-text-muted)">
|
||||||
|
{s.percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,18 +32,33 @@ export function ProductTable({ data }: ProductTableProps) {
|
|||||||
|
|
||||||
const handleSort = (key: keyof ProductSalesStats) => {
|
const handleSort = (key: keyof ProductSalesStats) => {
|
||||||
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||||
else { setSortKey(key); setSortDir("desc"); }
|
else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir("desc");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortIcon = (col: keyof ProductSalesStats) => (
|
const sortIcon = (col: keyof ProductSalesStats) => (
|
||||||
<i className={`fa-solid ml-1 text-xs ${
|
<i
|
||||||
sortKey === col
|
className={`fa-solid ml-1 text-xs ${
|
||||||
? sortDir === "desc" ? "fa-sort-down text-(--color-primary)" : "fa-sort-up text-(--color-primary)"
|
sortKey === col
|
||||||
: "fa-sort text-(--color-text-muted)"
|
? sortDir === "desc"
|
||||||
}`} />
|
? "fa-sort-down text-(--color-primary)"
|
||||||
|
: "fa-sort-up text-(--color-primary)"
|
||||||
|
: "fa-sort text-(--color-text-muted)"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const SortTh = ({ col, label, className = "" }: { col: keyof ProductSalesStats; label: string; className?: string }) => (
|
const SortTh = ({
|
||||||
|
col,
|
||||||
|
label,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
col: keyof ProductSalesStats;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
<th
|
<th
|
||||||
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
|
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
|
||||||
onClick={() => handleSort(col)}
|
onClick={() => handleSort(col)}
|
||||||
@@ -57,38 +72,67 @@ export function ProductTable({ data }: ProductTableProps) {
|
|||||||
<table className="w-full min-w-175 text-sm">
|
<table className="w-full min-w-175 text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-background border-b border-(--color-border-light)">
|
<tr className="bg-background border-b border-(--color-border-light)">
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">#</th>
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">Sản phẩm</th>
|
#
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">Danh mục</th>
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
|
Sản phẩm
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
|
Danh mục
|
||||||
|
</th>
|
||||||
<SortTh col="unitsSold" label="Số lượng" className="text-right" />
|
<SortTh col="unitsSold" label="Số lượng" className="text-right" />
|
||||||
<SortTh col="revenue" label="Doanh thu" className="text-right" />
|
<SortTh col="revenue" label="Doanh thu" className="text-right" />
|
||||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">Giá nhập</th>
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">Giá bán</th>
|
Giá nhập
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||||
|
Giá bán
|
||||||
|
</th>
|
||||||
<SortTh col="profit" label="Lợi nhuận" className="text-right" />
|
<SortTh col="profit" label="Lợi nhuận" className="text-right" />
|
||||||
<SortTh col="profitMargin" label="Biên LN" className="text-right" />
|
<SortTh col="profitMargin" label="Biên LN" className="text-right" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sorted.map((row, i) => (
|
{sorted.map((row, i) => (
|
||||||
<tr key={row.productId} className="border-b border-(--color-border-light) bg-(--color-bg-card) transition-colors hover:bg-(--color-accent-light)/30">
|
<tr
|
||||||
|
key={row.productId}
|
||||||
|
className="border-b border-(--color-border-light) bg-(--color-bg-card) transition-colors hover:bg-(--color-accent-light)/30"
|
||||||
|
>
|
||||||
<td className="px-4 py-3 text-(--color-text-muted)">{i + 1}</td>
|
<td className="px-4 py-3 text-(--color-text-muted)">{i + 1}</td>
|
||||||
<td className="text-foreground px-4 py-3 font-medium">{row.name}</td>
|
<td className="text-foreground px-4 py-3 font-medium">
|
||||||
|
{row.name}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)">
|
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)">
|
||||||
{categoryName(row.category)}
|
{categoryName(row.category)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-foreground px-4 py-3 text-right tabular-nums">{row.unitsSold.toLocaleString()}</td>
|
<td className="text-foreground px-4 py-3 text-right tabular-nums">
|
||||||
<td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">{formatCurrencyFull(row.revenue)}</td>
|
{row.unitsSold.toLocaleString()}
|
||||||
<td className="px-4 py-3 text-right text-(--color-text-muted) tabular-nums">{formatCurrencyFull(row.costPrice)}</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-(--color-text-secondary) tabular-nums">{formatCurrencyFull(row.sellingPrice)}</td>
|
<td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">
|
||||||
<td className="px-4 py-3 text-right font-medium text-green-600 tabular-nums">{formatCurrencyFull(row.profit)}</td>
|
{formatCurrencyFull(row.revenue)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-(--color-text-muted) tabular-nums">
|
||||||
|
{formatCurrencyFull(row.costPrice)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-(--color-text-secondary) tabular-nums">
|
||||||
|
{formatCurrencyFull(row.sellingPrice)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium text-green-600 tabular-nums">
|
||||||
|
{formatCurrencyFull(row.profit)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
<span
|
||||||
row.profitMargin >= 70 ? "bg-green-100 text-green-700"
|
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||||
: row.profitMargin >= 60 ? "bg-yellow-100 text-yellow-700"
|
row.profitMargin >= 70
|
||||||
: "bg-red-100 text-red-600"
|
? "bg-green-100 text-green-700"
|
||||||
}`}>
|
: row.profitMargin >= 60
|
||||||
|
? "bg-yellow-100 text-yellow-700"
|
||||||
|
: "bg-red-100 text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{row.profitMargin.toFixed(1)}%
|
{row.profitMargin.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export function SummaryCard({
|
|||||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-(--color-accent-light) text-lg text-(--color-primary)">
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-(--color-accent-light) text-lg text-(--color-primary)">
|
||||||
<i className={icon}></i>
|
<i className={icon}></i>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-(--color-text-muted)">{title}</span>
|
<span className="text-sm font-medium text-(--color-text-muted)">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
|
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -102,7 +102,12 @@ export default function LoginForm() {
|
|||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
{/* Login Button */}
|
{/* Login Button */}
|
||||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg">
|
<Button
|
||||||
|
variant="primaryNoBorder"
|
||||||
|
type="submit"
|
||||||
|
style="login"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
Đăng nhập
|
Đăng nhập
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,19 @@ export type {
|
|||||||
DeleteConfirmProps,
|
DeleteConfirmProps,
|
||||||
StatusBadgeProps,
|
StatusBadgeProps,
|
||||||
} from "./manager";
|
} from "./manager";
|
||||||
|
|
||||||
|
// Shift Schedule
|
||||||
|
export {
|
||||||
|
WeeklySchedule,
|
||||||
|
MonthlyCalendar,
|
||||||
|
MobileShiftView,
|
||||||
|
ShiftDetailModal,
|
||||||
|
ShiftCreateModal,
|
||||||
|
} from "./shift-schedule";
|
||||||
|
export type {
|
||||||
|
WeeklyScheduleProps,
|
||||||
|
MonthlyCalendarProps,
|
||||||
|
MobileShiftViewProps,
|
||||||
|
ShiftDetailModalProps,
|
||||||
|
ShiftCreateModalProps,
|
||||||
|
} from "./shift-schedule";
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Product } from "@/lib/types";
|
import type { Product } from "@/lib/types";
|
||||||
import { useState } from "react";
|
import Image from "next/image";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import type { ProductModalProps } from "./Manager.types";
|
import type { ProductModalProps } from "./Manager.types";
|
||||||
|
|
||||||
@@ -12,6 +13,10 @@ export default function ProductModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ProductModalProps) {
|
}: ProductModalProps) {
|
||||||
const isEdit = product !== null;
|
const isEdit = product !== null;
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [priceError, setPriceError] = useState("");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [form, setForm] = useState<Omit<Product, "id">>({
|
const [form, setForm] = useState<Omit<Product, "id">>({
|
||||||
name: product?.name ?? "",
|
name: product?.name ?? "",
|
||||||
category: product?.category ?? categories[0]?.id ?? "",
|
category: product?.category ?? categories[0]?.id ?? "",
|
||||||
@@ -21,8 +26,28 @@ export default function ProductModal({
|
|||||||
available: product?.available ?? true,
|
available: product?.available ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0] ?? null;
|
||||||
|
setImageFile(file);
|
||||||
|
if (file) {
|
||||||
|
setImagePreview(URL.createObjectURL(file));
|
||||||
|
} else {
|
||||||
|
setImagePreview(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = () => {
|
||||||
|
setImageFile(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (form.price <= 0) {
|
||||||
|
setPriceError("Giá phải lớn hơn 0đ");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isEdit && product) {
|
if (isEdit && product) {
|
||||||
onSave({ ...form, id: product.id });
|
onSave({ ...form, id: product.id });
|
||||||
} else {
|
} else {
|
||||||
@@ -93,15 +118,19 @@ export default function ProductModal({
|
|||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={1}
|
||||||
step={1000}
|
step={1000}
|
||||||
value={form.price}
|
value={form.price}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setForm({ ...form, price: Number(e.target.value) })
|
setPriceError("");
|
||||||
}
|
setForm({ ...form, price: Number(e.target.value) });
|
||||||
className={inputCls}
|
}}
|
||||||
|
className={`${inputCls} ${priceError ? "border-red-400 focus:border-red-400 focus:ring-red-400/20" : ""}`}
|
||||||
placeholder="25000"
|
placeholder="25000"
|
||||||
/>
|
/>
|
||||||
|
{priceError && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{priceError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,6 +149,65 @@ export default function ProductModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="mb-1 block text-sm font-medium text-(--color-text-secondary)"
|
||||||
|
htmlFor="product-image-upload"
|
||||||
|
>
|
||||||
|
Ảnh sản phẩm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id="product-image-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="sr-only"
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
{imagePreview ? (
|
||||||
|
<div className="relative h-40 overflow-hidden rounded-xl border border-(--color-border)">
|
||||||
|
<Image
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Xem trước ảnh sản phẩm"
|
||||||
|
fill
|
||||||
|
unoptimized
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
title="Xóa ảnh"
|
||||||
|
className="absolute top-2 right-2 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-none bg-black/50 text-xs text-white transition hover:bg-black/70"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="absolute right-2 bottom-2 cursor-pointer rounded-lg border-none bg-black/50 px-3 py-1 text-xs text-white transition hover:bg-black/70"
|
||||||
|
>
|
||||||
|
Đổi ảnh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-(--color-border) bg-transparent px-4 py-6 text-(--color-text-muted) transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-cloud-arrow-up text-2xl"></i>
|
||||||
|
<span className="text-sm font-medium">Nhấn để chọn ảnh</span>
|
||||||
|
<span className="text-xs">PNG, JPG, WEBP (tối đa 5MB)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{imageFile && (
|
||||||
|
<p className="mt-1 truncate text-xs text-(--color-text-muted)">
|
||||||
|
{imageFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-foreground text-sm font-medium">Trạng thái</p>
|
<p className="text-foreground text-sm font-medium">Trạng thái</p>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function ProductsTab() {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Tìm kiếm món..."
|
placeholder="Tìm kiếm món..."
|
||||||
|
aria-label="Tìm kiếm món"
|
||||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white py-2 pr-9 pl-9 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white py-2 pr-9 pl-9 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
@@ -78,6 +79,7 @@ export default function ProductsTab() {
|
|||||||
onChange={(e) => setFilterCategory(e.target.value)}
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
||||||
title="Lọc theo danh mục"
|
title="Lọc theo danh mục"
|
||||||
|
aria-label="Lọc theo danh mục"
|
||||||
>
|
>
|
||||||
<option value="all">Tất cả danh mục</option>
|
<option value="all">Tất cả danh mục</option>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
@@ -96,6 +98,7 @@ export default function ProductsTab() {
|
|||||||
}
|
}
|
||||||
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
||||||
title="Lọc theo trạng thái"
|
title="Lọc theo trạng thái"
|
||||||
|
aria-label="Lọc theo trạng thái"
|
||||||
>
|
>
|
||||||
<option value="all">Tất cả trạng thái</option>
|
<option value="all">Tất cả trạng thái</option>
|
||||||
<option value="available">Còn hàng</option>
|
<option value="available">Còn hàng</option>
|
||||||
@@ -152,12 +155,18 @@ 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>
|
||||||
{p.description && (
|
{p.description && (
|
||||||
<p className="mt-0.5 max-w-xs truncate text-xs text-(--color-text-muted)">
|
<p
|
||||||
|
title={p.description}
|
||||||
|
className="mt-0.5 max-w-xs truncate text-xs text-(--color-text-muted)"
|
||||||
|
>
|
||||||
{p.description}
|
{p.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -188,6 +197,7 @@ export default function ProductsTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setModalProduct(p)}
|
onClick={() => setModalProduct(p)}
|
||||||
title="Chỉnh sửa"
|
title="Chỉnh sửa"
|
||||||
|
aria-label={`Chỉnh sửa: ${p.name}`}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-pen text-xs"></i>
|
<i className="fa-solid fa-pen text-xs"></i>
|
||||||
@@ -195,6 +205,7 @@ export default function ProductsTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTarget(p)}
|
onClick={() => setDeleteTarget(p)}
|
||||||
title="Xóa"
|
title="Xóa"
|
||||||
|
aria-label={`Xóa: ${p.name}`}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-trash text-xs"></i>
|
<i className="fa-solid fa-trash text-xs"></i>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
|
|||||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
available
|
available
|
||||||
? "bg-emerald-100 text-emerald-700"
|
? "bg-emerald-100 text-emerald-700"
|
||||||
: "bg-amber-100 text-amber-700"
|
: "bg-amber-100 text-amber-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Button from "@/components/atoms/buttons/Button";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { CashPaymentModalProps } from "./Modal.types";
|
||||||
|
|
||||||
|
const formatPrice = (value: number) =>
|
||||||
|
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||||
|
|
||||||
|
export default function CashPaymentModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
items,
|
||||||
|
totalAmount,
|
||||||
|
}: CashPaymentModalProps) {
|
||||||
|
const [cashReceived, setCashReceived] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) setCashReceived("");
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const parsedCash = cashReceived === "" ? 0 : Number(cashReceived);
|
||||||
|
const isInsufficient = cashReceived !== "" && parsedCash < totalAmount;
|
||||||
|
const changeAmount = parsedCash - totalAmount;
|
||||||
|
const canConfirm = cashReceived !== "" && !isInsufficient && parsedCash >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="cash-modal-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative w-full max-w-lg rounded-2xl border border-(--color-border-light) bg-white p-6 shadow-xl">
|
||||||
|
<h2
|
||||||
|
id="cash-modal-title"
|
||||||
|
className="text-foreground mb-4 text-xl font-bold"
|
||||||
|
>
|
||||||
|
Thanh toán tiền mặt
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Invoice list */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold tracking-wide text-(--color-text-secondary) uppercase">
|
||||||
|
Hóa đơn
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between border-b border-(--color-border-light) px-4 py-2.5 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-foreground font-medium">{item.name}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-(--color-text-muted)">
|
||||||
|
x{item.quantity}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-(--color-primary)">
|
||||||
|
{formatPrice(item.price * item.quantity)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between bg-(--color-border-light)/30 px-4 py-3">
|
||||||
|
<span className="text-foreground font-bold">Tổng cộng</span>
|
||||||
|
<span className="text-lg font-bold text-(--color-primary)">
|
||||||
|
{formatPrice(totalAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cash input section */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<label
|
||||||
|
htmlFor="cash-input"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)"
|
||||||
|
>
|
||||||
|
Số tiền nhận
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cash-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step={1000}
|
||||||
|
value={cashReceived}
|
||||||
|
onChange={(e) => setCashReceived(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="w-full rounded-lg border border-(--color-border) bg-transparent px-3 py-2 text-right text-lg font-semibold focus:ring-2 focus:ring-(--color-primary) focus:outline-none"
|
||||||
|
/>
|
||||||
|
{isInsufficient && (
|
||||||
|
<p className="mt-1.5 text-sm text-red-500">
|
||||||
|
<i className="fa-solid fa-triangle-exclamation mr-1"></i>
|
||||||
|
Số tiền không đủ
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{cashReceived !== "" && !isInsufficient && (
|
||||||
|
<div className="mt-2 flex items-center justify-between rounded-lg bg-(--color-border-light)/40 px-3 py-2">
|
||||||
|
<span className="text-sm text-(--color-text-secondary)">
|
||||||
|
Tiền thối
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-(--color-primary)">
|
||||||
|
{formatPrice(changeAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
icon="fa-solid fa-arrow-left"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
icon="fa-solid fa-check"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
>
|
||||||
|
Xác nhận
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,3 +12,30 @@ export interface ConfirmModalProps {
|
|||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvoiceItem {
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashPaymentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRPaymentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentSuccessModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface PaymentSuccessModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentSuccessModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: PaymentSuccessModalProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.push("/");
|
||||||
|
onClose();
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen, router, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-sm rounded-2xl border border-(--color-border-light) bg-white p-8 shadow-xl">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<i className="fa-solid fa-circle-check text-5xl text-(--color-primary)"></i>
|
||||||
|
<h2 className="text-foreground text-xl font-bold">
|
||||||
|
Thanh toán thành công!
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
|
Cảm ơn quý khách. Đang chuyển về trang chủ...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { QRPaymentModalProps } from "./Modal.types";
|
||||||
|
|
||||||
|
const formatPrice = (value: number) =>
|
||||||
|
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||||
|
|
||||||
|
const COUNTDOWN_SECONDS = 5;
|
||||||
|
|
||||||
|
export default function QRPaymentModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
items,
|
||||||
|
totalAmount,
|
||||||
|
}: QRPaymentModalProps) {
|
||||||
|
const [qrValue, setQrValue] = useState("");
|
||||||
|
const [timeLeft, setTimeLeft] = useState(COUNTDOWN_SECONDS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
setQrValue(`payment:${totalAmount}:${Date.now()}`);
|
||||||
|
setTimeLeft(COUNTDOWN_SECONDS);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTimeLeft((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isOpen, totalAmount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeLeft === 0 && isOpen) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}, [timeLeft, isOpen, onConfirm]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="qr-modal-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full max-w-lg rounded-2xl border border-(--color-border-light) bg-white p-6 shadow-xl">
|
||||||
|
<h2
|
||||||
|
id="qr-modal-title"
|
||||||
|
className="text-foreground mb-4 text-xl font-bold"
|
||||||
|
>
|
||||||
|
Thanh toán QR Code
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Invoice list */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold tracking-wide text-(--color-text-secondary) uppercase">
|
||||||
|
Hóa đơn
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between border-b border-(--color-border-light) px-4 py-2.5 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-foreground font-medium">{item.name}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-(--color-text-muted)">
|
||||||
|
x{item.quantity}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-(--color-primary)">
|
||||||
|
{formatPrice(item.price * item.quantity)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between bg-(--color-border-light)/30 px-4 py-3">
|
||||||
|
<span className="text-foreground font-bold">Tổng cộng</span>
|
||||||
|
<span className="text-lg font-bold text-(--color-primary)">
|
||||||
|
{formatPrice(totalAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR code section */}
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-(--color-border-light) px-4 py-5">
|
||||||
|
<p className="text-sm font-medium text-(--color-text-secondary)">
|
||||||
|
Quét mã QR để thanh toán
|
||||||
|
</p>
|
||||||
|
{qrValue && <QRCodeSVG value={qrValue} size={180} />}
|
||||||
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
|
Tự động xác nhận sau{" "}
|
||||||
|
<span className="font-bold text-(--color-primary)">{timeLeft}</span>{" "}
|
||||||
|
giây...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Heading, Text, Textarea } from "@/components/atoms";
|
import { Button, Heading, Text, Textarea } from "@/components/atoms";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { ReviewModalProps } from "./Modal.types";
|
import type { ReviewModalProps } from "./Modal.types";
|
||||||
|
|
||||||
@@ -11,6 +11,12 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
const [review, setReview] = useState("");
|
const [review, setReview] = useState("");
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!submitted) return;
|
||||||
|
const timer = setTimeout(handleClose, 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [submitted]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
@@ -54,9 +60,6 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
<Text variant="body2" className="mt-2">
|
<Text variant="body2" className="mt-2">
|
||||||
Chúng tôi trân trọng đánh giá của bạn!
|
Chúng tôi trân trọng đánh giá của bạn!
|
||||||
</Text>
|
</Text>
|
||||||
<Button onClick={handleClose} variant="primary" className="mt-4">
|
|
||||||
Đóng
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Review form */
|
/* Review form */
|
||||||
@@ -98,7 +101,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
|||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "fa-solid fa-star text-yellow-400"
|
? "fa-solid fa-star text-yellow-400"
|
||||||
: "fa-regular fa-star text-(--color-border)"
|
: "fa-regular fa-star text-gray-300"
|
||||||
}
|
}
|
||||||
></i>
|
></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
export { default as ReviewModal } from "./ReviewModal";
|
export { default as ReviewModal } from "./ReviewModal";
|
||||||
|
export { default as PaymentSuccessModal } from "./PaymentSuccessModal";
|
||||||
|
export { default as CashPaymentModal } from "./CashPaymentModal";
|
||||||
|
export { default as QRPaymentModal } from "./QRPaymentModal";
|
||||||
export type { ReviewModalProps, ConfirmModalProps } from "./Modal.types";
|
export type { ReviewModalProps, ConfirmModalProps } from "./Modal.types";
|
||||||
|
export type { CashPaymentModalProps, InvoiceItem } from "./Modal.types";
|
||||||
|
export type { QRPaymentModalProps } from "./Modal.types";
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default function CategorySidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||||
|
aria-label={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ShiftCard from "@/components/molecules/cards/ShiftCard";
|
||||||
|
import { DEPARTMENTS } from "@/lib/constants";
|
||||||
|
import { useShift } from "@/lib/shift-context";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
|
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||||
|
|
||||||
|
function formatDateISO(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(d: Date): boolean {
|
||||||
|
const today = new Date(2026, 3, 10);
|
||||||
|
return (
|
||||||
|
d.getDate() === today.getDate() &&
|
||||||
|
d.getMonth() === today.getMonth() &&
|
||||||
|
d.getFullYear() === today.getFullYear()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"Tháng 1",
|
||||||
|
"Tháng 2",
|
||||||
|
"Tháng 3",
|
||||||
|
"Tháng 4",
|
||||||
|
"Tháng 5",
|
||||||
|
"Tháng 6",
|
||||||
|
"Tháng 7",
|
||||||
|
"Tháng 8",
|
||||||
|
"Tháng 9",
|
||||||
|
"Tháng 10",
|
||||||
|
"Tháng 11",
|
||||||
|
"Tháng 12",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MobileShiftView({
|
||||||
|
onShiftClick,
|
||||||
|
}: MobileShiftViewProps) {
|
||||||
|
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>(
|
||||||
|
formatDateISO(new Date(2026, 3, 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const calendarDays = useMemo(() => {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
let startOffset = firstDay.getDay() - 1;
|
||||||
|
if (startOffset < 0) startOffset = 6;
|
||||||
|
|
||||||
|
const days: (Date | null)[] = [];
|
||||||
|
for (let i = 0; i < startOffset; i++) days.push(null);
|
||||||
|
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||||
|
days.push(new Date(year, month, d));
|
||||||
|
}
|
||||||
|
while (days.length % 7 !== 0) days.push(null);
|
||||||
|
return days;
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
const getDotColors = (date: Date): string[] => {
|
||||||
|
const dateStr = formatDateISO(date);
|
||||||
|
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||||
|
const dots: string[] = [];
|
||||||
|
if (dayShifts.some((s) => s.status === "available"))
|
||||||
|
dots.push("bg-amber-400");
|
||||||
|
if (dayShifts.some((s) => s.status === "registered"))
|
||||||
|
dots.push("bg-green-500");
|
||||||
|
if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||||
|
dots.push("bg-purple-400");
|
||||||
|
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
|
||||||
|
return dots;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedShifts = useMemo(() => {
|
||||||
|
return shifts.filter((s) => s.date === selectedDate);
|
||||||
|
}, [shifts, selectedDate]);
|
||||||
|
|
||||||
|
const selectedDateObj = new Date(selectedDate + "T00:00:00");
|
||||||
|
const dayOfWeek = DAY_HEADERS[(selectedDateObj.getDay() + 6) % 7];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Compact month calendar */}
|
||||||
|
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
title="Trở lại tháng trước"
|
||||||
|
type="button"
|
||||||
|
onClick={goToPrevMonth}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<h3 className="text-foreground text-sm font-bold">
|
||||||
|
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
title="Trở lại tháng sau"
|
||||||
|
type="button"
|
||||||
|
onClick={goToNextMonth}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="mb-1 grid grid-cols-7">
|
||||||
|
{DAY_HEADERS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{calendarDays.map((date, i) => {
|
||||||
|
if (!date) {
|
||||||
|
return <div key={`empty-${i}`} className="p-1" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = formatDateISO(date);
|
||||||
|
const today = isToday(date);
|
||||||
|
const selected = dateStr === selectedDate;
|
||||||
|
const dots = getDotColors(date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={i}
|
||||||
|
onClick={() => setSelectedDate(dateStr)}
|
||||||
|
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
|
||||||
|
selected ? "rounded-lg bg-(--color-primary)/10" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs ${
|
||||||
|
today
|
||||||
|
? "bg-(--color-primary) font-bold text-white"
|
||||||
|
: selected
|
||||||
|
? "text-foreground font-bold"
|
||||||
|
: "text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
<div className="mt-0.5 flex gap-0.5">
|
||||||
|
{dots.slice(0, 3).map((color, j) => (
|
||||||
|
<span key={j} className={`h-1 w-1 rounded-full ${color}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||||
|
<span className="text-[10px] text-(--color-text-muted)">
|
||||||
|
Còn trống
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||||
|
<span className="text-[10px] text-(--color-text-muted)">Đã ĐK</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||||
|
<span className="text-[10px] text-(--color-text-muted)">
|
||||||
|
Nghỉ phép
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||||
|
<span className="text-[10px] text-(--color-text-muted)">Vắng</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected day shifts */}
|
||||||
|
<div>
|
||||||
|
<h3 className="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>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{selectedShifts.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
||||||
|
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
||||||
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
|
Không có ca làm trong ngày này
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{DEPARTMENTS.map((dept) => {
|
||||||
|
const deptShifts = selectedShifts.filter(
|
||||||
|
(s) => s.department === dept.id,
|
||||||
|
);
|
||||||
|
if (deptShifts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={dept.id}>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
{dept.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deptShifts.map((shift) => (
|
||||||
|
<ShiftCard
|
||||||
|
key={shift.id}
|
||||||
|
shift={shift}
|
||||||
|
onClick={onShiftClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useShift } from "@/lib/shift-context";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
|
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||||
|
|
||||||
|
function formatDateISO(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(d: Date): boolean {
|
||||||
|
const today = new Date(2026, 3, 10);
|
||||||
|
return (
|
||||||
|
d.getDate() === today.getDate() &&
|
||||||
|
d.getMonth() === today.getMonth() &&
|
||||||
|
d.getFullYear() === today.getFullYear()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonthlyCalendar({
|
||||||
|
onShiftClick,
|
||||||
|
onDateSelect,
|
||||||
|
}: MonthlyCalendarProps) {
|
||||||
|
const { currentDate, shifts } = useShift();
|
||||||
|
|
||||||
|
const calendarDays = useMemo(() => {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
// Monday = 0, Sunday = 6
|
||||||
|
let startOffset = firstDay.getDay() - 1;
|
||||||
|
if (startOffset < 0) startOffset = 6;
|
||||||
|
|
||||||
|
const days: (Date | null)[] = [];
|
||||||
|
|
||||||
|
// Fill leading empty cells
|
||||||
|
for (let i = 0; i < startOffset; i++) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill actual days
|
||||||
|
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||||
|
days.push(new Date(year, month, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill trailing empty cells to complete the grid
|
||||||
|
while (days.length % 7 !== 0) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
const getShiftSummary = (date: Date) => {
|
||||||
|
const dateStr = formatDateISO(date);
|
||||||
|
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||||
|
const available = dayShifts.filter((s) => s.status === "available").length;
|
||||||
|
const registered = dayShifts.filter(
|
||||||
|
(s) => s.status === "registered",
|
||||||
|
).length;
|
||||||
|
const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
|
||||||
|
const absent = dayShifts.filter((s) => s.status === "absent").length;
|
||||||
|
return { total: dayShifts.length, available, registered, leave, absent };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="grid grid-cols-7 border-b border-(--color-border-light) bg-gray-50">
|
||||||
|
{DAY_HEADERS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="px-2 py-2.5 text-center text-xs font-semibold text-(--color-text-muted) uppercase"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{calendarDays.map((date, i) => {
|
||||||
|
if (!date) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${i}`}
|
||||||
|
className="min-h-25 border-r border-b border-(--color-border-light) bg-gray-50/30"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = getShiftSummary(date);
|
||||||
|
const today = isToday(date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={i}
|
||||||
|
onClick={() => onDateSelect?.(formatDateISO(date))}
|
||||||
|
className={`min-h-25 cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${
|
||||||
|
today ? "bg-(--color-primary)/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
|
||||||
|
today
|
||||||
|
? "bg-(--color-primary) font-bold text-white"
|
||||||
|
: "text-foreground font-medium"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{summary.total > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{summary.available > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
|
||||||
|
<span className="text-[10px] text-blue-600">
|
||||||
|
{summary.available} trống
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary.registered > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-700"></span>
|
||||||
|
<span className="text-[10px] text-blue-800">
|
||||||
|
{summary.registered} đã ĐK
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary.leave > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||||
|
<span className="text-[10px] text-purple-600">
|
||||||
|
{summary.leave} nghỉ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary.absent > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||||
|
<span className="text-[10px] text-red-600">
|
||||||
|
{summary.absent} vắng
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DEPARTMENTS } from "@/lib/constants";
|
||||||
|
import { useShift } from "@/lib/shift-context";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
|
export default function ShiftCreateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
defaultDate,
|
||||||
|
}: ShiftCreateModalProps) {
|
||||||
|
const { createShift } = useShift();
|
||||||
|
|
||||||
|
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
||||||
|
const [startTime, setStartTime] = useState("08:00");
|
||||||
|
const [endTime, setEndTime] = useState("12:00");
|
||||||
|
const [department, setDepartment] = useState("bar");
|
||||||
|
const [maxStaff, setMaxStaff] = useState(2);
|
||||||
|
const [wage, setWage] = useState(120000);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setDate(defaultDate ?? "2026-04-10");
|
||||||
|
}
|
||||||
|
}, [defaultDate, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!date || !startTime || !endTime) {
|
||||||
|
setError("Vui lòng điền đầy đủ thông tin.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sh, sm] = startTime.split(":").map(Number);
|
||||||
|
const [eh, em] = endTime.split(":").map(Number);
|
||||||
|
const startMinutes = sh * 60 + sm;
|
||||||
|
const endMinutes = eh * 60 + em;
|
||||||
|
|
||||||
|
if (endMinutes <= startMinutes) {
|
||||||
|
setError("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationHours = (endMinutes - startMinutes) / 60;
|
||||||
|
|
||||||
|
createShift({
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
durationHours,
|
||||||
|
wage,
|
||||||
|
department,
|
||||||
|
maxStaff,
|
||||||
|
registeredStaff: [],
|
||||||
|
status: "available",
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-foreground text-base font-bold">
|
||||||
|
Tạo ca làm mới
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
|
Thêm khung giờ ca làm cho nhân viên
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
title="Close"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
|
||||||
|
{/* Date */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
Ngày
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
title="Date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="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>
|
||||||
|
|
||||||
|
{/* Time range */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
Giờ bắt đầu
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
title="Start Time"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
className="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>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
Giờ kết thúc
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
title="End Time"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
className="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>
|
||||||
|
|
||||||
|
{/* Department */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
Bộ phận
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
title="Department"
|
||||||
|
value={department}
|
||||||
|
onChange={(e) => setDepartment(e.target.value)}
|
||||||
|
className="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}>
|
||||||
|
{dept.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max staff & Wage */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
Số nhân viên tối đa
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
title="Max Staff"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={maxStaff}
|
||||||
|
onChange={(e) => setMaxStaff(Number(e.target.value))}
|
||||||
|
className="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>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
Lương ca (VND)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
title="Wage"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={10000}
|
||||||
|
value={wage}
|
||||||
|
onChange={(e) => setWage(Number(e.target.value))}
|
||||||
|
className="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>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<i className="fa-solid fa-circle-exclamation mr-2"></i>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus mr-2"></i>
|
||||||
|
Tạo ca làm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { DEPARTMENTS } from "@/lib/constants";
|
||||||
|
import { useShift } from "@/lib/shift-context";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
|
export default function ShiftDetailModal({
|
||||||
|
shift,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: ShiftDetailModalProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { registerShift, unregisterShift, deleteShift } = useShift();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isOpen || !shift) return null;
|
||||||
|
|
||||||
|
const dept = DEPARTMENTS.find((d) => d.id === shift.department);
|
||||||
|
const isManager = user?.role === "manager";
|
||||||
|
const isRegistered = user
|
||||||
|
? shift.registeredStaff.some((s) => s.id === user.id)
|
||||||
|
: false;
|
||||||
|
const isFull = shift.registeredStaff.length >= shift.maxStaff;
|
||||||
|
|
||||||
|
const handleRegister = () => {
|
||||||
|
if (!user) return;
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
const result = registerShift(shift.id, user.id, user.name);
|
||||||
|
if (result.success) {
|
||||||
|
setSuccess("Đăng ký ca thành công!");
|
||||||
|
setTimeout(onClose, 1200);
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? "Có lỗi xảy ra.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnregister = () => {
|
||||||
|
if (!user) return;
|
||||||
|
setError(null);
|
||||||
|
unregisterShift(shift.id, user.id);
|
||||||
|
setSuccess("Đã hủy đăng ký ca.");
|
||||||
|
setTimeout(onClose, 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManagerUnregister = (staffId: number) => {
|
||||||
|
unregisterShift(shift.id, staffId);
|
||||||
|
setSuccess("Đã xóa nhân viên khỏi ca.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteShift(shift.id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = {
|
||||||
|
available: "Còn trống",
|
||||||
|
registered: "Đã đăng ký",
|
||||||
|
approved_leave: "Nghỉ phép",
|
||||||
|
absent: "Vắng mặt",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = {
|
||||||
|
available: "bg-blue-100 text-blue-700",
|
||||||
|
registered: "bg-blue-200 text-blue-900",
|
||||||
|
approved_leave: "bg-purple-100 text-purple-700",
|
||||||
|
absent: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-foreground text-base font-bold">
|
||||||
|
Chi tiết ca làm
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
title="Close"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="space-y-4 px-5 py-4">
|
||||||
|
{/* Status badge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
|
||||||
|
>
|
||||||
|
{statusLabel[shift.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shift info grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
|
Ngày
|
||||||
|
</p>
|
||||||
|
<p className="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="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="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="mt-1 text-sm font-bold text-(--color-primary)">
|
||||||
|
{shift.wage.toLocaleString("vi-VN")} VND
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registered staff */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
Nhân viên đã đăng ký ({shift.registeredStaff.length}/
|
||||||
|
{shift.maxStaff})
|
||||||
|
</p>
|
||||||
|
{shift.registeredStaff.length === 0 ? (
|
||||||
|
<p className="text-xs text-(--color-text-muted) italic">
|
||||||
|
Chưa có ai đăng ký
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{shift.registeredStaff.map((staff) => (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-(--color-primary)/10">
|
||||||
|
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
|
||||||
|
</div>
|
||||||
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
{staff.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isManager && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleManagerUnregister(staff.id)}
|
||||||
|
className="cursor-pointer rounded-lg border-none bg-transparent px-2 py-1 text-xs text-red-500 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-user-minus mr-1"></i>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<i className="fa-solid fa-circle-exclamation mr-2"></i>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-xl bg-green-50 px-4 py-3 text-sm text-green-700">
|
||||||
|
<i className="fa-solid fa-circle-check mr-2"></i>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
|
||||||
|
{!isRegistered &&
|
||||||
|
!isFull &&
|
||||||
|
shift.status !== "approved_leave" &&
|
||||||
|
shift.status !== "absent" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegister}
|
||||||
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||||
|
Đăng ký ca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isRegistered && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnregister}
|
||||||
|
className="flex-1 cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-calendar-minus mr-2"></i>
|
||||||
|
Hủy đăng ký
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isManager && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-trash mr-2"></i>
|
||||||
|
Xóa ca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ShiftSlot } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface WeeklyScheduleProps {
|
||||||
|
onShiftClick: (shift: ShiftSlot) => void;
|
||||||
|
onCreateShift?: (date: string) => void;
|
||||||
|
mobileCalendarHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyCalendarProps {
|
||||||
|
onShiftClick: (shift: ShiftSlot) => void;
|
||||||
|
onDateSelect?: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileShiftViewProps {
|
||||||
|
onShiftClick: (shift: ShiftSlot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftDetailModalProps {
|
||||||
|
shift: ShiftSlot | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftCreateModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
defaultDate?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ShiftCard from "@/components/molecules/cards/ShiftCard";
|
||||||
|
import { DEPARTMENTS } from "@/lib/constants";
|
||||||
|
import { useShift } from "@/lib/shift-context";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
|
const MONTH_NAMES_EN = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||||
|
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
|
||||||
|
function formatDateShort(d: Date): string {
|
||||||
|
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateISO(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(d: Date): boolean {
|
||||||
|
const today = new Date(2026, 3, 10);
|
||||||
|
return (
|
||||||
|
d.getDate() === today.getDate() &&
|
||||||
|
d.getMonth() === today.getMonth() &&
|
||||||
|
d.getFullYear() === today.getFullYear()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeeklySchedule({
|
||||||
|
onShiftClick,
|
||||||
|
onCreateShift,
|
||||||
|
mobileCalendarHeader = false,
|
||||||
|
}: WeeklyScheduleProps) {
|
||||||
|
const {
|
||||||
|
currentDate,
|
||||||
|
getWeekDates,
|
||||||
|
getShiftsForDate,
|
||||||
|
goToNextWeek,
|
||||||
|
goToPrevWeek,
|
||||||
|
} = useShift();
|
||||||
|
const weekDates = getWeekDates();
|
||||||
|
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>(
|
||||||
|
formatDateISO(weekDates[0] ?? currentDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusDotsByDate = useMemo(() => {
|
||||||
|
const map: Record<string, string[]> = {};
|
||||||
|
weekDates.forEach((date) => {
|
||||||
|
const dateStr = formatDateISO(date);
|
||||||
|
const dayShifts = getShiftsForDate(dateStr);
|
||||||
|
const dots: string[] = [];
|
||||||
|
if (dayShifts.some((s) => s.status === "available"))
|
||||||
|
dots.push("bg-sky-300");
|
||||||
|
if (dayShifts.some((s) => s.status === "registered"))
|
||||||
|
dots.push("bg-blue-600");
|
||||||
|
if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||||
|
dots.push("bg-purple-400");
|
||||||
|
if (dayShifts.some((s) => s.status === "absent"))
|
||||||
|
dots.push("bg-rose-400");
|
||||||
|
map[dateStr] = dots.slice(0, 3);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [weekDates, getShiftsForDate]);
|
||||||
|
|
||||||
|
const selectedDateObj = useMemo(() => {
|
||||||
|
const inWeek = weekDates.find((d) => formatDateISO(d) === selectedDate);
|
||||||
|
return inWeek ?? weekDates[0] ?? currentDate;
|
||||||
|
}, [selectedDate, weekDates, currentDate]);
|
||||||
|
|
||||||
|
const selectedDateStr = formatDateISO(selectedDateObj);
|
||||||
|
|
||||||
|
const renderMobileDayView = mobileCalendarHeader && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
title="Tuần trước"
|
||||||
|
type="button"
|
||||||
|
onClick={goToPrevWeek}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<h3 className="text-base font-bold text-(--color-primary-dark)">
|
||||||
|
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
title="Tuần sau"
|
||||||
|
type="button"
|
||||||
|
onClick={goToNextWeek}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{weekDates.map((date, i) => {
|
||||||
|
const dateStr = formatDateISO(date);
|
||||||
|
const active = dateStr === selectedDateStr;
|
||||||
|
const dots = statusDotsByDate[dateStr] ?? [];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedDate(dateStr)}
|
||||||
|
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
|
||||||
|
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
|
||||||
|
>
|
||||||
|
{DAY_LABELS_EN[i]}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
|
||||||
|
active || isToday(date)
|
||||||
|
? "bg-indigo-500 text-white"
|
||||||
|
: i >= 5
|
||||||
|
? "text-pink-500"
|
||||||
|
: "text-sky-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
<div className="flex min-h-2 items-center gap-0.5">
|
||||||
|
{dots.map((dot, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${dot}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{DEPARTMENTS.map((dept) => {
|
||||||
|
const deptShifts = getShiftsForDate(selectedDateStr).filter(
|
||||||
|
(s) => s.department === dept.id,
|
||||||
|
);
|
||||||
|
if (deptShifts.length === 0 && !onCreateShift) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dept.id}
|
||||||
|
className="rounded-xl border border-(--color-border-light) bg-white p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
{dept.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deptShifts.map((shift) => (
|
||||||
|
<ShiftCard
|
||||||
|
key={shift.id}
|
||||||
|
shift={shift}
|
||||||
|
onClick={onShiftClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{onCreateShift && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreateShift(selectedDateStr)}
|
||||||
|
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus mr-1"></i>
|
||||||
|
Thêm ca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mobileCalendarHeader) {
|
||||||
|
return renderMobileDayView ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-225 border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-28 border-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-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)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block uppercase">{DAY_LABELS[i]}</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] font-normal">
|
||||||
|
{formatDateShort(date)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DEPARTMENTS.map((dept) => (
|
||||||
|
<tr key={dept.id}>
|
||||||
|
<td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
|
{dept.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{weekDates.map((date, i) => {
|
||||||
|
const dateStr = formatDateISO(date);
|
||||||
|
const shifts = getShiftsForDate(dateStr).filter(
|
||||||
|
(s) => s.department === dept.id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={i}
|
||||||
|
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
|
||||||
|
isToday(date) ? "bg-(--color-primary)/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-20 flex-col gap-1">
|
||||||
|
{shifts.map((shift) => (
|
||||||
|
<ShiftCard
|
||||||
|
key={shift.id}
|
||||||
|
shift={shift}
|
||||||
|
compact
|
||||||
|
onClick={onShiftClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{onCreateShift && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreateShift(dateStr)}
|
||||||
|
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus mr-1"></i>
|
||||||
|
Thêm ca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { default as WeeklySchedule } from "./WeeklySchedule";
|
||||||
|
export { default as MonthlyCalendar } from "./MonthlyCalendar";
|
||||||
|
export { default as MobileShiftView } from "./MobileShiftView";
|
||||||
|
export { default as ShiftDetailModal } from "./ShiftDetailModal";
|
||||||
|
export { default as ShiftCreateModal } from "./ShiftCreateModal";
|
||||||
|
export type {
|
||||||
|
WeeklyScheduleProps,
|
||||||
|
MonthlyCalendarProps,
|
||||||
|
MobileShiftViewProps,
|
||||||
|
ShiftDetailModalProps,
|
||||||
|
ShiftCreateModalProps,
|
||||||
|
} from "./ShiftSchedule.types";
|
||||||
@@ -13,3 +13,7 @@ export type { FeedLayoutProps } from "./feed-layout";
|
|||||||
// Manager Layout
|
// Manager Layout
|
||||||
export { ManagerLayout } from "./manager-layout";
|
export { ManagerLayout } from "./manager-layout";
|
||||||
export type { ManagerLayoutProps } from "./manager-layout";
|
export type { ManagerLayoutProps } from "./manager-layout";
|
||||||
|
|
||||||
|
// Staff Layout
|
||||||
|
export { StaffLayout } from "./staff-layout";
|
||||||
|
export type { StaffLayoutProps } from "./staff-layout";
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { ShiftProvider } from "@/lib/shift-context";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import type { StaffLayoutProps } from "./StaffLayout.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff layout template — wraps content with auth guard and ShiftProvider.
|
||||||
|
* Allows both staff and manager roles; redirects customers away.
|
||||||
|
*/
|
||||||
|
export default function StaffLayout({ children }: StaffLayoutProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user !== null && user.role === "customer") {
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
}, [user, router]);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
|
||||||
|
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
|
||||||
|
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm font-medium text-(--color-primary) hover:underline"
|
||||||
|
>
|
||||||
|
Đăng nhập nếu chưa có tài khoản
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === "customer") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShiftProvider>
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">{children}</div>
|
||||||
|
</ShiftProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface StaffLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as StaffLayout } from "./StaffLayout";
|
||||||
|
export type { StaffLayoutProps } from "./StaffLayout.types";
|
||||||
@@ -16,7 +16,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: frontend-container
|
- name: frontend-container
|
||||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.0.10
|
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.2
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+224
-276
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> Tài liệu chi tiết về layout components (Header, Footer) và responsive behavior
|
> Tài liệu chi tiết về layout components (Header, Footer) và responsive behavior
|
||||||
|
|
||||||
|
> **Last Updated:** 2026-04-18
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -21,180 +23,197 @@ layouts/
|
|||||||
|
|
||||||
## Header (layouts/header.tsx)
|
## Header (layouts/header.tsx)
|
||||||
|
|
||||||
**File:** layouts/header.tsx **Type:** Client component **Description:** Sticky
|
**File:** layouts/header.tsx **Type:** Client component (`"use client"`)
|
||||||
top navigation bar with brand info and authentication status display.
|
**Description:** Sticky top navigation bar with brand info and authentication
|
||||||
|
status display. Uses `useAuth()` context for real auth state and `useRouter()`
|
||||||
|
for navigation.
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ [Logo] [Shop Name] [Tagline] │ [Auth Status] │
|
│ [Logo] [Shop Name] [Tagline] │ [Auth Button(s)] │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
height: 72px (var(--spacing-header-height))
|
height: var(--spacing-header-height) (72px)
|
||||||
```
|
```
|
||||||
|
|
||||||
**2-Column Layout:**
|
**2-Column Layout:**
|
||||||
|
|
||||||
- **Left:** Shop branding (logo + name + tagline)
|
- **Left:** Shop branding (logo + name + tagline) — wrapped in `<Link href="/">`
|
||||||
- **Right:** Auth status indicator
|
- **Right:** Auth status indicator (varies by user role)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
#### Logo & Branding
|
#### Logo & Branding
|
||||||
|
|
||||||
- Shop logo (40x40px)
|
- Shop logo rendered with `next/image` (fill, 40x40px / 44x44px on md+)
|
||||||
- Shop name (bold text)
|
- Shop name (bold, color transitions on hover)
|
||||||
- Tagline (hidden on mobile < md, visible md+)
|
- Tagline (hidden on mobile < md, visible md+)
|
||||||
|
- Entire left section is a link to `/`
|
||||||
|
|
||||||
**Responsive:** | Breakpoint | Logo | Name | Tagline |
|
**Responsive:**
|
||||||
|-----------|------|------|---------| | < sm (640px) | Yes | Yes | Hidden | |
|
|
||||||
sm-md | Yes | Yes | Hidden | | md+ (768px) | Yes | Yes | Visible |
|
| Breakpoint | Logo | Name | Tagline |
|
||||||
|
| ------------ | --------- | ---- | ------- |
|
||||||
|
| < sm (640px) | h-10/w-10 | Yes | Hidden |
|
||||||
|
| sm-md | h-10/w-10 | Yes | Hidden |
|
||||||
|
| md+ (768px) | h-11/w-11 | Yes | Visible |
|
||||||
|
|
||||||
#### Authentication Status
|
#### Authentication Status
|
||||||
|
|
||||||
The header cycles through 3 auth states (for UI demo):
|
The header reads from `useAuth()` and reflects the actual logged-in user role.
|
||||||
|
|
||||||
**State 1: Guest (Not logged in)**
|
**State 1: Guest (user = null)**
|
||||||
|
|
||||||
```
|
```
|
||||||
[brown button] "Dang nhap"
|
[brown button] "Đăng nhập" ← icon always shown, label hidden on xs
|
||||||
```
|
```
|
||||||
|
|
||||||
- Guest state (user = null)
|
- `title="Đăng nhập"` on button
|
||||||
- Brown primary button
|
- Clicking navigates to `/login`
|
||||||
- Click: toggles to next state
|
- Icon: `fa-solid fa-right-to-bracket`
|
||||||
|
|
||||||
**State 2: Manager**
|
**State 2: Manager (user.role === "manager")**
|
||||||
|
|
||||||
```
|
```
|
||||||
[caramel/gold badge] "👔 Quản lý"
|
[Dashboard link] [Logout icon button]
|
||||||
```
|
```
|
||||||
|
|
||||||
- Shows manager role
|
- Dashboard link → `/manager`
|
||||||
- Gold/caramel colored badge
|
- Icon: `fa-solid fa-user-tie`
|
||||||
- User tie icon
|
- Label "Dashboard" hidden on xs, visible sm+
|
||||||
- Name: Nguyễn Văn An
|
- Logout button: `title="Đăng xuất"`, `aria-label="Đăng xuất"`
|
||||||
|
- Logout icon: `fa-solid fa-right-from-bracket`
|
||||||
|
- Hover: red tint on logout button
|
||||||
|
|
||||||
**State 3: Staff**
|
**State 3: Staff (user.role === "staff")**
|
||||||
|
|
||||||
```
|
```
|
||||||
[border button] "👤 [Name]"
|
[Ca làm link] [Avatar + Name button (logout on click)]
|
||||||
```
|
```
|
||||||
|
|
||||||
- Shows staff role with avatar placeholder
|
- Ca làm link → `/staff/schedule`
|
||||||
- Bordered button style
|
- Icon: `fa-solid fa-calendar-check`
|
||||||
- Name: Trần Thị Bình
|
- Label "Ca làm" hidden on xs, visible sm+
|
||||||
- Click: toggles back to Guest state
|
- Staff button: `title="Nhấn để đăng xuất"`, `aria-label="Đăng xuất"`
|
||||||
|
- Avatar: colored circle with `fa-solid fa-user`
|
||||||
|
- Name hidden on xs, visible sm+
|
||||||
|
|
||||||
|
**State 4: Customer (user.role === "customer")**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Customer icon + "Khách hàng" button (logout on click)]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `title="Khách hàng - {user.phone} - Nhấn để đăng xuất"`
|
||||||
|
- Avatar circle with `fa-solid fa-user`
|
||||||
|
- Label "Khách hàng" hidden on xs, visible sm+
|
||||||
|
|
||||||
### Responsive Behavior
|
### Responsive Behavior
|
||||||
|
|
||||||
| Breakpoint | Layout |
|
| Breakpoint | Layout |
|
||||||
| ------------- | ------------------------------- |
|
| ------------- | -------------------------------------- |
|
||||||
| < sm | Stacked vertically |
|
| < sm (640px) | Button labels hidden, icons only |
|
||||||
| sm-md (640px) | Side-by-side, reduced spacing |
|
| sm-md (640px) | Button labels visible, reduced spacing |
|
||||||
| md+ (768px) | Full width, comfortable spacing |
|
| md+ (768px) | Full layout including tagline |
|
||||||
|
|
||||||
**Mobile (<640px):**
|
**Mobile (<640px):**
|
||||||
|
|
||||||
- Logo smaller
|
- Logo 40x40px
|
||||||
- Name only (no tagline)
|
- Shop name only (no tagline)
|
||||||
- Auth button text hidden, icon shown
|
- Auth button shows icon only (label hidden via `hidden sm:inline`)
|
||||||
- Touch-friendly padding
|
- Touch-friendly padding
|
||||||
|
|
||||||
**Desktop (≥640px):**
|
**Desktop (≥768px):**
|
||||||
|
|
||||||
- Normal logo size
|
- Logo 44x44px
|
||||||
- Full layout with tagline
|
- Full layout with tagline
|
||||||
- Button with text and icon
|
- Buttons show icon + text label
|
||||||
- Comfortable spacing
|
- Comfortable spacing
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- Logo image: `alt="Logo {SHOP_INFO.name}"`
|
||||||
|
- Guest button: `title="Đăng nhập"`
|
||||||
|
- Manager logout button: `title="Đăng xuất"`, `aria-label="Đăng xuất"`
|
||||||
|
- Staff logout button: `title="Nhấn để đăng xuất"`, `aria-label="Đăng xuất"`
|
||||||
|
- Customer button: `title="Khách hàng - {phone} - Nhấn để đăng xuất"`
|
||||||
|
- Icons are presentational (no aria-hidden needed — buttons have
|
||||||
|
title/aria-label)
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
| Element | Classes |
|
| Element | Classes |
|
||||||
| -------------------- | --------------------------------------------------------- |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Header wrapper | `sticky top-0 z-50 bg-white shadow-md` |
|
| Header wrapper | `sticky top-0 z-50 h-(--spacing-header-height) w-full border-b bg-(--color-bg-header) shadow-[...]` |
|
||||||
| Container | `max-w-7xl mx-auto px-4 md:px-6 h-72px flex items-center` |
|
| Container | `mx-auto flex h-full max-w-screen-2xl items-center justify-between gap-6 px-6 md:px-8 lg:px-12` |
|
||||||
| Left section (brand) | `flex items-center gap-3 flex-1` |
|
| Left section (brand) | `group flex shrink-0 items-center gap-4 no-underline` (Link) |
|
||||||
| Right section (auth) | `flex items-center gap-3 shrink-0` |
|
| Logo wrapper | `relative h-10 w-10 shrink-0 md:h-11 md:w-11` |
|
||||||
| Logo image | `w-10 h-10 md:w-12 md:h-12` |
|
| Brand text | `text-base font-bold text-(--color-primary-dark) md:text-lg` |
|
||||||
| Brand text | `font-bold text-sm md:text-base` |
|
| Tagline | `hidden text-xs text-(--color-text-muted) md:block` |
|
||||||
| Tagline | `hidden md:block text-xs text-muted-foreground` |
|
| Right section (auth) | `flex shrink-0 items-center gap-3` |
|
||||||
| Auth button | `px-4 py-2 rounded-lg text-sm font-medium` |
|
| Guest button | `flex items-center gap-2.5 rounded-xl bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white` |
|
||||||
|
| Manager link | `flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light)` |
|
||||||
|
| Logout button | `flex items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 hover:border-red-300 hover:bg-red-50 hover:text-red-500` |
|
||||||
|
| Staff button | `flex items-center gap-2.5 rounded-xl border border-(--color-border) bg-background` |
|
||||||
|
| Customer button | `flex items-center gap-2.5 rounded-xl bg-(--color-primary-light) px-4 py-2 text-white` |
|
||||||
|
|
||||||
### CSS Variables Used
|
### CSS Variables Used
|
||||||
|
|
||||||
```css
|
```css
|
||||||
--spacing-header-height: 72px;
|
--spacing-header-height /* 72px — header height */
|
||||||
--color-primary: brown;
|
--color-bg-header /* header background */
|
||||||
--color-primary-dark: darker brown;
|
--color-border /* border color */
|
||||||
--color-text-primary: text color;
|
--color-shadow-sm /* subtle shadow */
|
||||||
--color-text-muted: gray text;
|
--color-primary /* brand primary (brown) */
|
||||||
--color-shadow-sm: subtle shadow;
|
--color-primary-dark /* darker brown */
|
||||||
|
--color-primary-light /* lighter brown */
|
||||||
|
--color-accent /* accent/gold color */
|
||||||
|
--color-accent-light /* light accent background */
|
||||||
|
--color-text-muted /* gray/muted text */
|
||||||
|
--color-text-secondary /* secondary text color */
|
||||||
|
--color-border-light /* light border */
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
- **next/image** - Logo image rendering
|
- **next/image** — Logo image rendering
|
||||||
- **next/link** - Navigation links
|
- **next/link** — Brand link, dashboard link, schedule link
|
||||||
- **lib/constants:** SHOP_INFO, MOCK_USERS
|
- **next/navigation** — `useRouter` for programmatic navigation
|
||||||
- **lib/types:** User
|
- **lib/auth-context:** `useAuth` — user state, logout
|
||||||
- **FontAwesome icons:** fa-sign-in-alt, fa-sign-out-alt, fa-user-tie,
|
- **lib/constants:** `SHOP_INFO`
|
||||||
fa-user-circle
|
|
||||||
|
|
||||||
### Key Code Snippets
|
|
||||||
|
|
||||||
**Auth Demo State Machine:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
|
|
||||||
const handleAuthClick = () => {
|
|
||||||
if (!user) {
|
|
||||||
setUser(MOCK_USERS.manager);
|
|
||||||
} else if (user.role === "manager") {
|
|
||||||
setUser(MOCK_USERS.staff);
|
|
||||||
} else {
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responsive Brand Text:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-bold text-sm md:text-base">{SHOP_INFO.name}</span>
|
|
||||||
<span className="hidden md:block text-xs text-muted-foreground">
|
|
||||||
{SHOP_INFO.tagline}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Footer (layouts/footer.tsx)
|
## Footer (layouts/footer.tsx)
|
||||||
|
|
||||||
**File:** layouts/footer.tsx **Type:** Client component (uses constants)
|
**File:** layouts/footer.tsx **Type:** Server component (no `"use client"`)
|
||||||
**Description:** Site footer with shop information, social links, and WiFi
|
**Description:** Site footer with shop information, social links, and WiFi
|
||||||
details.
|
details. Copyright year is dynamic via `new Date().getFullYear()`.
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
│ [Brand Info] │ [Social + WiFi] │
|
│ [Brand Info] │ [Social + WiFi] │
|
||||||
├────────────────────────────────────────────┤
|
├────────────────────────────────────────────────────────────────┤
|
||||||
│ © 2024 Coffee Shop. Made with ❤️ in VN │
|
│ © {year} Coffee Shop. All rights reserved. | Powered by ... │
|
||||||
└────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**12-Column Grid Layout:**
|
**12-Column Grid Layout:**
|
||||||
|
|
||||||
| Breakpoint | Brand Info | Social + WiFi |
|
| Breakpoint | Brand Info | Right column (Social + WiFi) |
|
||||||
| ------------ | ----------- | ------------- |
|
| ------------ | ----------- | -------------------------------- |
|
||||||
| Mobile | col-span-12 | col-span-12 |
|
| Mobile | col-span-12 | col-span-12 |
|
||||||
| md (768px) | col-span-6 | col-span-6 |
|
| md (768px) | col-span-6 | col-span-6 |
|
||||||
| lg+ (1024px) | col-span-6 | col-span-6 |
|
| lg+ (1024px) | col-span-8 | col-span-4 |
|
||||||
|
| xl+ | col-span-6 | col-span-6 (with 2-col sub-grid) |
|
||||||
|
|
||||||
|
The right column (Social + WiFi) uses an internal sub-grid:
|
||||||
|
|
||||||
|
- Mobile/lg: stacked (1 column)
|
||||||
|
- xl+: side-by-side (2 columns)
|
||||||
|
|
||||||
### Sections
|
### Sections
|
||||||
|
|
||||||
@@ -202,179 +221,114 @@ details.
|
|||||||
|
|
||||||
**Content:**
|
**Content:**
|
||||||
|
|
||||||
- Shop logo (40x40px)
|
- Shop logo (40x40px, `next/image`)
|
||||||
- Shop name (bold)
|
- Shop name (bold, accent color)
|
||||||
- Tagline (gray text)
|
- Tagline (gray, 75% opacity)
|
||||||
- Address with location icon
|
- Address with `fa-solid fa-location-dot`
|
||||||
- Phone with phone icon
|
- Phone (clickable `tel:` link) with `fa-solid fa-phone`
|
||||||
- Email with envelope icon
|
- Email (clickable `mailto:` link) with `fa-solid fa-envelope`
|
||||||
- Open hours with clock icon
|
- Open hours with `fa-solid fa-clock`
|
||||||
|
|
||||||
**Responsive:**
|
#### 2. Social Links
|
||||||
|
|
||||||
```
|
**Links (heading: "Kết nối"):**
|
||||||
Mobile:
|
|
||||||
└─ Logo
|
|
||||||
└─ Name & Tagline (stacked)
|
|
||||||
└─ Address
|
|
||||||
└─ Phone
|
|
||||||
└─ Email
|
|
||||||
└─ Open Hours
|
|
||||||
|
|
||||||
Desktop:
|
- Facebook → `SOCIAL_LINKS.facebook` (branded blue icon, opens `_blank`)
|
||||||
└─ Logo + Name/Tagline (flexbox, side-by-side)
|
- TikTok → `SOCIAL_LINKS.tiktok` (black icon, opens `_blank`)
|
||||||
└─ Address, Phone, Email, Hours (stacked below)
|
- Website → `SOCIAL_LINKS.website` (primary color icon, uses `next/link`)
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Social Links (Top Right)
|
|
||||||
|
|
||||||
**Links:**
|
|
||||||
|
|
||||||
- Facebook icon → SHOP_INFO.facebook
|
|
||||||
- TikTok icon → SHOP_INFO.tiktok
|
|
||||||
- Website icon → SHOP_INFO.website
|
|
||||||
|
|
||||||
**Styling:**
|
**Styling:**
|
||||||
|
|
||||||
- Icons in circle backgrounds
|
- Each item: icon badge (8x8 rounded-lg) + label text
|
||||||
- Hover effect (color change)
|
- Hover: accent color + full opacity
|
||||||
- Flex row layout
|
|
||||||
- Centered alignment
|
|
||||||
|
|
||||||
#### 3. WiFi Card (Bottom Right)
|
#### 3. WiFi Card
|
||||||
|
|
||||||
**Content:**
|
**Content (heading: "WiFi Miễn Phí"):**
|
||||||
|
|
||||||
- "📶 WiFi miễn phí" label
|
- WiFi icon (`fa-solid fa-wifi`)
|
||||||
- Network name (monospace)
|
- Network name (monospace, accent-bordered)
|
||||||
- Password (monospace, can be hidden/shown)
|
- Password (monospace, accent-bordered, always visible — no toggle)
|
||||||
|
|
||||||
|
**Note:** Current implementation shows password in plain text (no hide/show
|
||||||
|
toggle). This differs from older documentation.
|
||||||
|
|
||||||
**Styling:**
|
**Styling:**
|
||||||
|
|
||||||
- Light gray background
|
- Semi-transparent dark panel (`bg-(--color-primary-dark) bg-opacity-30`)
|
||||||
- Rounded borders
|
- Rounded xl border
|
||||||
|
- Stacked label + value rows (no overflow risk)
|
||||||
- Monospace font for credentials
|
- Monospace font for credentials
|
||||||
- Eye icon toggle for password visibility
|
|
||||||
|
|
||||||
**Example:**
|
#### 4. Bottom Bar
|
||||||
|
|
||||||
```
|
|
||||||
📶 WiFi miễn phí
|
|
||||||
┌────────────────────────┐
|
|
||||||
│ Network: CoffeeShop │
|
|
||||||
│ Password: •••••••• │
|
|
||||||
│ (eye icon) │
|
|
||||||
└────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Copyright Bar (Bottom)
|
|
||||||
|
|
||||||
**Content:**
|
**Content:**
|
||||||
|
|
||||||
- Copyright text: "© 2024 Coffee Shop"
|
- Left: `© {year} {SHOP_INFO.name}. All rights reserved.`
|
||||||
- "Made with ❤️ in Vietnam"
|
- Right: `Được vận hành ❤ bằng Drinkool`
|
||||||
- Centered
|
- Responsive: stacked on mobile, side-by-side on sm+
|
||||||
|
|
||||||
**Styling:**
|
|
||||||
|
|
||||||
- Small text
|
|
||||||
- Gray color
|
|
||||||
- Subtle separator line above
|
|
||||||
|
|
||||||
### Responsive Behavior
|
### Responsive Behavior
|
||||||
|
|
||||||
**Mobile (<768px):**
|
**Mobile (<768px):**
|
||||||
|
|
||||||
- Single column layout
|
- Single column layout
|
||||||
- Stacked sections
|
- Stacked sections (brand, then social+WiFi)
|
||||||
- Full width
|
- Full width
|
||||||
- Padding around edges
|
|
||||||
|
|
||||||
**Tablet (768px):**
|
**Tablet (768px):**
|
||||||
|
|
||||||
- 2-column layout
|
- 2-column: brand (6 cols) | social+WiFi (6 cols)
|
||||||
- Brand info left, Social + WiFi right
|
- Social and WiFi stacked within right column
|
||||||
- Equal width columns
|
|
||||||
|
|
||||||
**Desktop (≥768px):**
|
**Desktop (≥1024px):**
|
||||||
|
|
||||||
- 2-column layout with more spacing
|
- Brand 8 cols, right 4 cols
|
||||||
- Brand info slightly smaller
|
- Social and WiFi stacked in right col
|
||||||
- Comfortable padding
|
|
||||||
|
**XL (≥1280px):**
|
||||||
|
|
||||||
|
- Brand 6 cols, right 6 cols
|
||||||
|
- Social and WiFi side-by-side in right col sub-grid
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
| Element | Classes |
|
| Element | Classes |
|
||||||
| -------------- | --------------------------------------------------------- |
|
| ----------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Footer wrapper | `bg-gray-100 border-t` |
|
| Footer wrapper | `w-full overflow-x-hidden bg-(--color-bg-footer) text-(--color-text-on-dark)` |
|
||||||
| Container | `max-w-7xl mx-auto px-4 py-8` |
|
| Container | `mx-auto max-w-screen-2xl px-4 py-10 md:px-6 lg:px-8` |
|
||||||
| Grid | `grid grid-cols-12 gap-8` |
|
| Grid | `grid grid-cols-12 gap-x-8 gap-y-8` |
|
||||||
| Brand section | `col-span-12 md:col-span-6` |
|
| Brand section | `col-span-12 md:col-span-6 lg:col-span-8 xl:col-span-6` |
|
||||||
| Social section | `col-span-12 md:col-span-6` |
|
| Right section | `col-span-12 grid grid-cols-1 gap-6 md:col-span-6 lg:col-span-4 xl:col-span-6 xl:grid-cols-2` |
|
||||||
| Logo | `w-10 h-10` |
|
| Logo | `relative h-10 w-10 shrink-0` |
|
||||||
| Info text | `text-sm text-gray-600` |
|
| Shop name | `text-lg font-bold text-(--color-accent)` |
|
||||||
| Social icons | `w-10 h-10 rounded-full flex items-center justify-center` |
|
| Tagline | `text-sm leading-relaxed opacity-75` |
|
||||||
| WiFi card | `bg-white p-4 rounded-lg border` |
|
| Contact list | `flex flex-col gap-2 text-sm opacity-80` |
|
||||||
| Copyright bar | `border-t mt-8 pt-6 text-center text-xs text-gray-500` |
|
| Social icon badge | `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg` |
|
||||||
|
| WiFi card | `rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4` |
|
||||||
|
| WiFi value | `rounded border border-(--color-accent) px-2 py-1 font-mono font-bold text-(--color-accent)` |
|
||||||
|
| Bottom bar | `border-t border-white border-opacity-10` |
|
||||||
|
| Copyright row | `mx-auto flex max-w-screen-2xl flex-col sm:flex-row items-center justify-between gap-2 px-4 py-4 text-xs opacity-50` |
|
||||||
|
|
||||||
### CSS Variables Used
|
### CSS Variables Used
|
||||||
|
|
||||||
```css
|
```css
|
||||||
--color-primary: brand color;
|
--color-bg-footer /* dark footer background */
|
||||||
--color-text-secondary: secondary text color;
|
--color-text-on-dark /* light text for dark backgrounds */
|
||||||
--color-text-muted: muted/gray text color;
|
--color-accent /* accent/gold color */
|
||||||
--color-accent: accent color;
|
--color-primary /* brand primary (brown) */
|
||||||
--color-border: border color;
|
--color-primary-dark /* darker brown */
|
||||||
--color-bg-*: background colors;
|
--color-primary-light /* lighter brown */
|
||||||
--color-shadow-sm: subtle shadow;
|
--color-border /* border color */
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
- **next/image** - Shop logo
|
- **next/image** — Shop logo
|
||||||
- **next/link** - Social links
|
- **next/link** — Website social link
|
||||||
- **lib/constants:** SHOP_INFO, SOCIAL_LINKS
|
- **lib/constants:** `SHOP_INFO`, `SOCIAL_LINKS`
|
||||||
- **FontAwesome icons:** location-dot, phone, envelope, clock, facebook, tiktok,
|
- **FontAwesome icons:** `location-dot`, `phone`, `envelope`, `clock`,
|
||||||
globe, wifi, eye, eye-slash
|
`fa-brands fa-facebook-f`, `fa-brands fa-tiktok`, `globe`, `wifi`, `heart`
|
||||||
|
|
||||||
### Key Code Snippets
|
|
||||||
|
|
||||||
**Brand Info Section:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Image src={SHOP_INFO.logo} alt={SHOP_INFO.name} width={40} height={40} />
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold">{SHOP_INFO.name}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{SHOP_INFO.tagline}</p>
|
|
||||||
<p className="text-sm mt-2">{SHOP_INFO.address}</p>
|
|
||||||
{/* etc */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Social Links:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Link href={SOCIAL_LINKS.facebook} target="_blank" rel="noopener">
|
|
||||||
<i className="fab fa-facebook" />
|
|
||||||
</Link>
|
|
||||||
{/* etc */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**WiFi Display:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<div className="bg-white p-4 rounded-lg">
|
|
||||||
<p className="font-semibold text-sm">📶 WiFi miễn phí</p>
|
|
||||||
<p className="font-mono text-xs mt-2">{SHOP_INFO.wifi.name}</p>
|
|
||||||
<p className="font-mono text-xs">
|
|
||||||
{showPassword ? SHOP_INFO.wifi.password : "••••••••"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -398,7 +352,7 @@ export default function RootLayout({ children }) {
|
|||||||
|
|
||||||
**Result:**
|
**Result:**
|
||||||
|
|
||||||
- Header: Always at top (sticky)
|
- Header: Always at top (sticky, z-50)
|
||||||
- Content: Takes full width between header/footer
|
- Content: Takes full width between header/footer
|
||||||
- Footer: Always at bottom
|
- Footer: Always at bottom
|
||||||
|
|
||||||
@@ -409,30 +363,22 @@ export default function RootLayout({ children }) {
|
|||||||
### Header Responsive Pattern
|
### Header Responsive Pattern
|
||||||
|
|
||||||
```css
|
```css
|
||||||
/* Mobile first */
|
/* Mobile first — icon-only auth buttons */
|
||||||
.header {
|
.header-auth-label {
|
||||||
flex-direction: column;
|
display: none; /* hidden on xs */
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet+ */
|
/* sm+ — show button labels */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.header {
|
.header-auth-label {
|
||||||
flex-direction: row;
|
display: inline; /* hidden sm:inline */
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop+ */
|
/* md+ — show tagline */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.header {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagline {
|
.tagline {
|
||||||
display: block; /* Show tagline on desktop */
|
display: block; /* hidden md:block */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -440,17 +386,14 @@ export default function RootLayout({ children }) {
|
|||||||
### Footer Responsive Pattern
|
### Footer Responsive Pattern
|
||||||
|
|
||||||
```css
|
```css
|
||||||
/* Mobile: stacked */
|
/* Mobile: single column */
|
||||||
.footer-grid {
|
.footer-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(12, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet: 2 columns */
|
/* md: brand 6 cols, right 6 cols */
|
||||||
@media (min-width: 768px) {
|
/* lg: brand 8 cols, right 4 cols */
|
||||||
.footer-grid {
|
/* xl: brand 6 cols, right 6 cols — right col uses xl:grid-cols-2 */
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -483,33 +426,38 @@ CSS variables are set up for dark mode support. To enable:
|
|||||||
|
|
||||||
### Header
|
### Header
|
||||||
|
|
||||||
- Logo has alt text
|
- Logo image has descriptive `alt` text: `"Logo {SHOP_INFO.name}"`
|
||||||
- Auth button has aria-label
|
- Guest button: `title="Đăng nhập"`
|
||||||
- Icons have semantic meaning
|
- Manager logout button: `title="Đăng xuất"` + `aria-label="Đăng xuất"`
|
||||||
- Good contrast ratios
|
- Staff logout button: `title="Nhấn để đăng xuất"` + `aria-label="Đăng xuất"`
|
||||||
|
- Customer button: `title="Khách hàng - {phone} - Nhấn để đăng xuất"`
|
||||||
|
- Interactive elements have hover and active states (visual feedback)
|
||||||
|
- Touch targets are sized appropriately (px-4/5 + py-2/2.5)
|
||||||
|
|
||||||
### Footer
|
### Footer
|
||||||
|
|
||||||
- Headings use semantic tags
|
- Phone and email are clickable `<a>` links (`tel:`, `mailto:`)
|
||||||
- Links have descriptive text
|
- Social links open in `_blank` with `rel="noopener noreferrer"`
|
||||||
- Icons are decorative (aria-hidden)
|
- Section headings use `<h3>` semantic tags
|
||||||
- Monospace font for technical info (WiFi credentials)
|
- Icons are decorative within labelled list items
|
||||||
|
- WiFi credentials in monospace font for readability
|
||||||
|
|
||||||
### General
|
### General
|
||||||
|
|
||||||
- Touch targets ≥ 48px on mobile
|
- Touch targets ≥ 44px (py-2.5 + px-4/5 buttons)
|
||||||
- Sufficient color contrast
|
- `active:scale-95` on interactive buttons for tactile feedback
|
||||||
- Semantic HTML structure
|
- Semantic HTML structure (`<header>`, `<footer>`, `<nav>` implicit via links)
|
||||||
- ARIA labels where needed
|
- ARIA labels on icon-only buttons (logout buttons)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Keep layouts simple:** Avoid complex nested layouts
|
1. **Keep layouts simple:** Avoid complex nested layouts
|
||||||
2. **Responsive first:** Use mobile-first CSS approach
|
2. **Responsive first:** Use mobile-first CSS approach (`hidden sm:inline`)
|
||||||
3. **Reuse components:** Use Header/Footer across all pages
|
3. **Reuse components:** Use Header/Footer across all pages
|
||||||
4. **Props over hardcoding:** Use lib/constants for data
|
4. **Props over hardcoding:** Use lib/constants for data
|
||||||
5. **Type safety:** Use TypeScript for component props
|
5. **Type safety:** Use TypeScript for component props
|
||||||
6. **Performance:** Optimize images with next/image
|
6. **Performance:** Optimize images with next/image (`fill`, `sizes`,
|
||||||
7. **Accessibility:** Add ARIA labels and semantic HTML
|
`priority`)
|
||||||
|
7. **Accessibility:** Add title/aria-label on icon-only buttons
|
||||||
|
|||||||
+23
-12
@@ -91,24 +91,35 @@ export default function Header() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleAuthClick}
|
onClick={handleAuthClick}
|
||||||
title="Đăng xuất"
|
title="Đăng xuất"
|
||||||
|
aria-label="Đăng xuất"
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
|
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-right-from-bracket text-base"></i>
|
<i className="fa-solid fa-right-from-bracket text-base"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : user.role === "staff" ? (
|
) : user.role === "staff" ? (
|
||||||
/* Staff: avatar + name */
|
/* Staff: Register shift + avatar + name */
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleAuthClick}
|
<Link
|
||||||
title="Nhấn để đăng xuất"
|
href="/staff/schedule"
|
||||||
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
|
className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white"
|
||||||
>
|
>
|
||||||
{/* Avatar circle */}
|
<i className="fa-solid fa-calendar-check text-base"></i>
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-primary-light) text-xs text-white">
|
<span className="hidden sm:inline">Ca làm</span>
|
||||||
<i className="fa-solid fa-user"></i>
|
</Link>
|
||||||
</div>
|
<button
|
||||||
<span className="hidden sm:inline">{user.name}</span>
|
onClick={handleAuthClick}
|
||||||
</button>
|
title="Nhấn để đăng xuất"
|
||||||
|
aria-label="Đăng xuất"
|
||||||
|
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
|
||||||
|
>
|
||||||
|
{/* Avatar circle */}
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--color-primary-light) text-xs text-white">
|
||||||
|
<i className="fa-solid fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:inline">{user.name}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Customer: phone icon + label */
|
/* Customer: phone icon + label */
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface CartContextValue {
|
|||||||
decreaseQty: (id: number) => void;
|
decreaseQty: (id: number) => void;
|
||||||
removeFromCart: (id: number) => void;
|
removeFromCart: (id: number) => void;
|
||||||
setQuantity: (id: number, quantity: number) => void;
|
setQuantity: (id: number, quantity: number) => void;
|
||||||
|
clearCart: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "coffee-shop-cart";
|
const STORAGE_KEY = "coffee-shop-cart";
|
||||||
@@ -91,6 +92,8 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearCart = () => setItems([]);
|
||||||
|
|
||||||
const setQuantity = (id: number, quantity: number) => {
|
const setQuantity = (id: number, quantity: number) => {
|
||||||
const safeQty = Number.isFinite(quantity)
|
const safeQty = Number.isFinite(quantity)
|
||||||
? Math.max(0, Math.floor(quantity))
|
? Math.max(0, Math.floor(quantity))
|
||||||
@@ -127,6 +130,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
decreaseQty,
|
decreaseQty,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
setQuantity,
|
setQuantity,
|
||||||
|
clearCart,
|
||||||
}),
|
}),
|
||||||
[items, totalItems, totalPrice],
|
[items, totalItems, totalPrice],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
Combo,
|
Combo,
|
||||||
|
Department,
|
||||||
MenuCategory,
|
MenuCategory,
|
||||||
Product,
|
Product,
|
||||||
ProductSalesStats,
|
ProductSalesStats,
|
||||||
RevenueDataPoint,
|
RevenueDataPoint,
|
||||||
|
ShiftSlot,
|
||||||
Shop,
|
Shop,
|
||||||
ShopInfo,
|
ShopInfo,
|
||||||
SocialLinks,
|
SocialLinks,
|
||||||
@@ -608,3 +610,102 @@ export const MOCK_USERS: Record<string, User> = {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== SHIFT / SCHEDULE DATA =====
|
||||||
|
|
||||||
|
export const DEPARTMENTS: Department[] = [
|
||||||
|
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" },
|
||||||
|
{ id: "kitchen", name: "Kitchen", icon: "fa-solid fa-kitchen-set" },
|
||||||
|
{ id: "cashier", name: "Cashier", icon: "fa-solid fa-cash-register" },
|
||||||
|
{ id: "janitor", name: "Janitor", icon: "fa-solid fa-broom" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate mock shift slots for the weeks around today (April 2026).
|
||||||
|
* Covers Mon 6 Apr – Sun 26 Apr 2026.
|
||||||
|
*/
|
||||||
|
function generateMockShifts(): ShiftSlot[] {
|
||||||
|
const shifts: ShiftSlot[] = [];
|
||||||
|
const departments = ["bar", "kitchen", "cashier", "janitor"];
|
||||||
|
const timeSlots = [
|
||||||
|
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 },
|
||||||
|
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 },
|
||||||
|
{ start: "15:00", end: "19:00", hours: 4, wage: 120000 },
|
||||||
|
{ start: "19:00", end: "22:00", hours: 3, wage: 100000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const staffPool = [
|
||||||
|
{ id: 2, name: "Nguyễn Văn An" },
|
||||||
|
{ id: 3, name: "Trần Thị Bình" },
|
||||||
|
{ id: 4, name: "Lê Văn Cường" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate shifts from April 6 to April 26, 2026
|
||||||
|
let shiftCounter = 0;
|
||||||
|
for (let day = 6; day <= 26; day++) {
|
||||||
|
const dateStr = `2026-04-${day.toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
|
for (const dept of departments) {
|
||||||
|
// Not every department has shifts every day
|
||||||
|
if (dept === "janitor" && day % 3 !== 0) continue;
|
||||||
|
|
||||||
|
for (const slot of timeSlots) {
|
||||||
|
// Skip some slots randomly for variety
|
||||||
|
if (dept === "kitchen" && slot.start === "19:00") continue;
|
||||||
|
if (dept === "cashier" && slot.start === "07:00" && day % 2 === 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
shiftCounter++;
|
||||||
|
const shiftId = `shift_${shiftCounter.toString().padStart(3, "0")}`;
|
||||||
|
|
||||||
|
// Determine registration status
|
||||||
|
const registered: { id: number; name: string }[] = [];
|
||||||
|
let status: ShiftSlot["status"] = "available";
|
||||||
|
|
||||||
|
// Past shifts (before April 10) are mostly registered
|
||||||
|
if (day < 10) {
|
||||||
|
const staffIdx = shiftCounter % staffPool.length;
|
||||||
|
registered.push(staffPool[staffIdx]);
|
||||||
|
if (shiftCounter % 7 === 0) {
|
||||||
|
registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
|
||||||
|
}
|
||||||
|
status = "registered";
|
||||||
|
// Some past shifts have leave/absent
|
||||||
|
if (shiftCounter % 11 === 0) status = "approved_leave";
|
||||||
|
if (shiftCounter % 13 === 0) status = "absent";
|
||||||
|
}
|
||||||
|
// Current week (April 6-12): mix of registered and available
|
||||||
|
else if (day >= 10 && day <= 12) {
|
||||||
|
if (shiftCounter % 3 === 0) {
|
||||||
|
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||||
|
status = "registered";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Future shifts: mostly available, some registered
|
||||||
|
else {
|
||||||
|
if (shiftCounter % 5 === 0) {
|
||||||
|
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||||
|
status = "registered";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shifts.push({
|
||||||
|
id: shiftId,
|
||||||
|
date: dateStr,
|
||||||
|
startTime: slot.start,
|
||||||
|
endTime: slot.end,
|
||||||
|
durationHours: slot.hours,
|
||||||
|
wage: slot.wage,
|
||||||
|
department: dept,
|
||||||
|
maxStaff: dept === "bar" ? 3 : 2,
|
||||||
|
registeredStaff: registered,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shifts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { MOCK_SHIFT_SLOTS } from "./constants";
|
||||||
|
import type { ShiftSlot, ShiftStatus } from "./types";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ScheduleView = "week" | "month";
|
||||||
|
|
||||||
|
interface ShiftContextType {
|
||||||
|
// Data
|
||||||
|
shifts: ShiftSlot[];
|
||||||
|
view: ScheduleView;
|
||||||
|
setView: (view: ScheduleView) => void;
|
||||||
|
|
||||||
|
// Current date navigation
|
||||||
|
currentDate: Date;
|
||||||
|
goToNextWeek: () => void;
|
||||||
|
goToPrevWeek: () => void;
|
||||||
|
goToNextMonth: () => void;
|
||||||
|
goToPrevMonth: () => void;
|
||||||
|
goToToday: () => void;
|
||||||
|
|
||||||
|
// Shift actions
|
||||||
|
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;
|
||||||
|
deleteShift: (shiftId: string) => void;
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
getShiftsForDate: (date: string) => ShiftSlot[];
|
||||||
|
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
||||||
|
getWeekDates: () => Date[];
|
||||||
|
hasConflict: (
|
||||||
|
date: string,
|
||||||
|
startTime: string,
|
||||||
|
endTime: string,
|
||||||
|
staffId: number,
|
||||||
|
excludeShiftId?: string,
|
||||||
|
) => boolean;
|
||||||
|
getWeeklyBudget: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ShiftContext = createContext<ShiftContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getMonday(d: Date): Date {
|
||||||
|
const date = new Date(d);
|
||||||
|
const day = date.getDay();
|
||||||
|
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
date.setDate(diff);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToMinutes(time: string): number {
|
||||||
|
const [h, m] = time.split(":").map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timesOverlap(
|
||||||
|
start1: string,
|
||||||
|
end1: string,
|
||||||
|
start2: string,
|
||||||
|
end2: string,
|
||||||
|
): boolean {
|
||||||
|
const s1 = timeToMinutes(start1);
|
||||||
|
const e1 = timeToMinutes(end1);
|
||||||
|
const s2 = timeToMinutes(start2);
|
||||||
|
const e2 = timeToMinutes(end2);
|
||||||
|
return s1 < e2 && s2 < e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [shifts, setShifts] = useState<ShiftSlot[]>(MOCK_SHIFT_SLOTS);
|
||||||
|
const [view, setView] = useState<ScheduleView>("week");
|
||||||
|
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
|
||||||
|
|
||||||
|
// ── Navigation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const goToNextWeek = useCallback(() => {
|
||||||
|
setCurrentDate((prev) => {
|
||||||
|
const next = new Date(prev);
|
||||||
|
next.setDate(next.getDate() + 7);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToPrevWeek = useCallback(() => {
|
||||||
|
setCurrentDate((prev) => {
|
||||||
|
const next = new Date(prev);
|
||||||
|
next.setDate(next.getDate() - 7);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToNextMonth = useCallback(() => {
|
||||||
|
setCurrentDate((prev) => {
|
||||||
|
const next = new Date(prev);
|
||||||
|
next.setMonth(next.getMonth() + 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToPrevMonth = useCallback(() => {
|
||||||
|
setCurrentDate((prev) => {
|
||||||
|
const next = new Date(prev);
|
||||||
|
next.setMonth(next.getMonth() - 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToToday = useCallback(() => {
|
||||||
|
setCurrentDate(new Date(2026, 3, 10));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Query helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getWeekDates = useCallback((): Date[] => {
|
||||||
|
const monday = getMonday(currentDate);
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setDate(monday.getDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
const getShiftsForDate = useCallback(
|
||||||
|
(date: string): ShiftSlot[] => {
|
||||||
|
return shifts.filter((s) => s.date === date);
|
||||||
|
},
|
||||||
|
[shifts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getShiftsForWeek = useCallback(
|
||||||
|
(weekStart: Date): ShiftSlot[] => {
|
||||||
|
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(weekStart);
|
||||||
|
d.setDate(weekStart.getDate() + i);
|
||||||
|
return formatDate(d);
|
||||||
|
});
|
||||||
|
return shifts.filter((s) => dates.includes(s.date));
|
||||||
|
},
|
||||||
|
[shifts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasConflict = useCallback(
|
||||||
|
(
|
||||||
|
date: string,
|
||||||
|
startTime: string,
|
||||||
|
endTime: string,
|
||||||
|
staffId: number,
|
||||||
|
excludeShiftId?: string,
|
||||||
|
): boolean => {
|
||||||
|
return shifts.some(
|
||||||
|
(s) =>
|
||||||
|
s.date === date &&
|
||||||
|
s.id !== excludeShiftId &&
|
||||||
|
s.registeredStaff.some((rs) => rs.id === staffId) &&
|
||||||
|
timesOverlap(startTime, endTime, s.startTime, s.endTime),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[shifts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getWeeklyBudget = useCallback((): number => {
|
||||||
|
const weekDates = getWeekDates().map(formatDate);
|
||||||
|
return shifts
|
||||||
|
.filter((s) => weekDates.includes(s.date) && s.registeredStaff.length > 0)
|
||||||
|
.reduce((sum, s) => sum + s.wage * s.registeredStaff.length, 0);
|
||||||
|
}, [shifts, getWeekDates]);
|
||||||
|
|
||||||
|
// ── Shift actions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const registerShift = useCallback(
|
||||||
|
(
|
||||||
|
shiftId: string,
|
||||||
|
staffId: number,
|
||||||
|
staffName: string,
|
||||||
|
): { success: boolean; error?: string } => {
|
||||||
|
const shift = shifts.find((s) => s.id === shiftId);
|
||||||
|
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
|
||||||
|
|
||||||
|
if (shift.registeredStaff.length >= shift.maxStaff) {
|
||||||
|
return { success: false, error: "Ca làm đã đủ người." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
|
||||||
|
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
|
||||||
|
}
|
||||||
|
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setShifts((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === shiftId
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
registeredStaff: [
|
||||||
|
...s.registeredStaff,
|
||||||
|
{ id: staffId, name: staffName },
|
||||||
|
],
|
||||||
|
status: "registered" as ShiftStatus,
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
[shifts, hasConflict],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unregisterShift = useCallback((shiftId: string, staffId: number) => {
|
||||||
|
setShifts((prev) =>
|
||||||
|
prev.map((s) => {
|
||||||
|
if (s.id !== shiftId) return s;
|
||||||
|
const updated = s.registeredStaff.filter((rs) => rs.id !== staffId);
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
registeredStaff: updated,
|
||||||
|
status:
|
||||||
|
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => {
|
||||||
|
const newShift: ShiftSlot = {
|
||||||
|
...shift,
|
||||||
|
id: `shift_${Date.now()}`,
|
||||||
|
};
|
||||||
|
setShifts((prev) => [...prev, newShift]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateShift = useCallback((shift: ShiftSlot) => {
|
||||||
|
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteShift = useCallback((shiftId: string) => {
|
||||||
|
setShifts((prev) => prev.filter((s) => s.id !== shiftId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShiftContext.Provider
|
||||||
|
value={{
|
||||||
|
shifts,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
currentDate,
|
||||||
|
goToNextWeek,
|
||||||
|
goToPrevWeek,
|
||||||
|
goToNextMonth,
|
||||||
|
goToPrevMonth,
|
||||||
|
goToToday,
|
||||||
|
registerShift,
|
||||||
|
unregisterShift,
|
||||||
|
createShift,
|
||||||
|
updateShift,
|
||||||
|
deleteShift,
|
||||||
|
getShiftsForDate,
|
||||||
|
getShiftsForWeek,
|
||||||
|
getWeekDates,
|
||||||
|
hasConflict,
|
||||||
|
getWeeklyBudget,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ShiftContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useShift() {
|
||||||
|
const context = useContext(ShiftContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useShift must be used within a ShiftProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -114,3 +114,34 @@ export interface Combo {
|
|||||||
items: ComboItem[]; // list of products + quantities in this combo
|
items: ComboItem[]; // list of products + quantities in this combo
|
||||||
available: boolean;
|
available: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== SHIFT / SCHEDULE TYPES =====
|
||||||
|
export type ShiftStatus =
|
||||||
|
| "available"
|
||||||
|
| "registered"
|
||||||
|
| "approved_leave"
|
||||||
|
| "absent";
|
||||||
|
|
||||||
|
export interface RegisteredStaff {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftSlot {
|
||||||
|
id: string;
|
||||||
|
date: string; // ISO date string "YYYY-MM-DD"
|
||||||
|
startTime: string; // "HH:mm"
|
||||||
|
endTime: string; // "HH:mm"
|
||||||
|
durationHours: number;
|
||||||
|
wage: number; // VND per shift
|
||||||
|
department: string;
|
||||||
|
maxStaff: number;
|
||||||
|
registeredStaff: RegisteredStaff[];
|
||||||
|
status: ShiftStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string; // FontAwesome class
|
||||||
|
}
|
||||||
|
|||||||
Generated
+30
-11
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.0.10",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.0.10",
|
"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.39",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
|
"nextjs": "^0.0.3",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind": "^4.0.0",
|
"tailwind": "^4.0.0",
|
||||||
@@ -21,13 +23,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||||
"@semantic-release/exec": "^7.1.0",
|
"@semantic-release/exec": "^7.1.0",
|
||||||
"@semantic-release/github": "^12.0.6",
|
|
||||||
"@semantic-release/npm": "^13.1.5",
|
"@semantic-release/npm": "^13.1.5",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@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",
|
||||||
@@ -2498,9 +2499,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.37",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -8835,6 +8836,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nextjs": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nextjs/-/nextjs-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-mYbDUo4/sRAZ8TqK63PCpYnFiLg7BICG/ot9+guOrUKd4/Fo71ZmEQ41IZbH6nqbQvG7SXTBuofJXAIWfNho0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nocache": {
|
"node_modules/nocache": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz",
|
||||||
@@ -11613,9 +11623,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.8.1",
|
"version": "3.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -11837,6 +11847,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.5.2",
|
"version": "6.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
|
||||||
|
|||||||
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.0.10",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -12,9 +12,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@types/node": "^20.19.37",
|
"@types/node": "^20.19.39",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
|
"nextjs": "^0.0.3",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind": "^4.0.0",
|
"tailwind": "^4.0.0",
|
||||||
@@ -29,7 +31,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
+241
-253
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user