Compare commits

..

33 Commits

Author SHA1 Message Date
Thanh Quy - wolf dae381d182 Enhance Atoms and Layout Documentation
- Updated ATOMS.md to include new LoginInput and ErrorMessageLogin components with detailed usage examples and props descriptions.
- Adjusted the status and created date in ATOMS.md.
- Refined the layout and styling descriptions in LAYOUTS.md for Header and Footer components, including responsive behavior and accessibility improvements.
- Added new sections for WiFi card and social links in the Footer documentation.
- Improved code formatting and consistency across various components in the codebase.
2026-04-18 11:13:53 +07:00
Thanh Quy - wolf 4892099eaa fix: Address UI/UX accessibility and usability issues from test report
- Add aria-label to search input, category/status dropdowns in ProductsTab
- Add aria-label with item name to Edit/Delete icon buttons
- Add title tooltip to truncated product descriptions
- Add price validation in ProductModal (min=1, reject 0 with inline error)
- Add focus-visible ring to all sidebar nav buttons and links
- Improve StatusBadge contrast: text-amber-700 to text-amber-800 (WCAG AA)
- Add unit annotation (tr = trieu dong VND) to revenue chart subtitles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 10:54:52 +07:00
Thanh Quy - wolf 9772a8f639 feat: Add image upload functionality to ProductModal; implement image preview and removal features 2026-04-17 20:14:49 +07:00
Thanh Quy - wolf d88b067c5c feat: Implement cash and QR payment modals; enhance payment summary card functionality
refactor: Update PaymentSummaryCard to handle new payment methods and improve state management
fix: Add clearCart function to cart context for better cart management
chore: Update package dependencies and improve documentation
2026-04-17 20:09:16 +07:00
Thanh Quy - wolf e85fa90879 fix: Add title attribute to icon button in TextInput for better accessibility 2026-04-17 18:41:31 +07:00
Thanh Quy - wolf 2875d39f2c Refactor README and component documentation for clarity and consistency; update layout and formatting across various components; enhance analytics charts and payment summary card functionality; improve login form imports and structure; optimize product card button implementation; ensure consistent styling and spacing in manager products tab. 2026-04-17 18:31:33 +07:00
Thanh Quy- wolf c99323ff9e Fix review modal 2026-04-17 10:53:29 +07:00
Thanh Quy - wolf cb639704dd fix: Add className prop to Button component for better styling flexibility
Release package / release (pull_request) Successful in 1m25s
2026-04-16 00:21:13 +07:00
Thanh Quy - wolf 6d7dc0b43f feat: Enhance accessibility and improve UI components
- Updated header and navigation components with aria-labels for better accessibility.
- Modified SearchBar and SearchInput components to include aria-labels and titles for clearer user interaction.
- Changed category sidebar button to include aria-label for improved screen reader support.
- Adjusted payment page table headers to include scope attributes for better semantic structure.
- Enhanced search functionality in Home component to reset category when search query is updated.
- Added accessibility features to buttons in ProductCard and other components for improved usability.
- Introduced a new skill for frontend atomic design methodology with Tailwind CSS.
- Created new skills for prompt optimization, UI/UX testing, and website creation automation.
2026-04-16 00:16:31 +07:00
Thanh Quy - wolf 80ff0af3d8 Remove outdated documentation for Register Shift feature 2026-04-15 23:50:10 +07:00
Thanh Quy - wolf 93552cb9d1 Enhance formatting and readability of the Register Shift feature documentation by adjusting line breaks and indentation for better clarity. Update descriptions for roles, functionalities, and UI elements to ensure consistency and improve user understanding. 2026-04-15 23:47:16 +07:00
Thanh Quy - wolf 460e8a4ac1 fix: Resolve merge conflicts and clean up imports in multiple components
Release package / release (pull_request) Failing after 14s
2026-04-13 20:41:24 +07:00
Thanh Quy - wolf e326e11bb4 Fix conflits
Release package / release (pull_request) Failing after 12s
2026-04-13 20:33:01 +07:00
Thanh Quy - wolf 7e4a21d412 chore: Remove obsolete Blackbox configuration files
Release package / release (pull_request) Failing after 2m51s
2026-04-13 20:17:25 +07:00
Thanh Quy - wolf 127cb32b16 acp IconButton 2026-04-10 22:45:08 +07:00
Thanh Quy - wolf 4ddce518ac feat: Implement mobile dropdown navigation and enhance weekly schedule component 2026-04-10 22:37:52 +07:00
Thanh Quy - wolf 95d9435694 feat: Update styling and improve accessibility in shift schedule components 2026-04-10 21:52:52 +07:00
Thanh Quy - wolf 9d9a219ac8 Add Route to staff schedule in manager dashboard 2026-04-10 21:43:15 +07:00
Thanh Quy - wolf 32d40b1dbe Create staff page 2026-04-10 21:38:30 +07:00
Thanh Quy- wolf ace60e424b feat: Enhance Button component with className prop and update PaymentSummaryCard usage
- Added className prop to Button for custom styling
- Updated PaymentSummaryCard to utilize the new Button implementation
- Added nextjs dependency to package.json and package-lock.json
2026-04-09 16:13:34 +07:00
Thanh Quy - wolf 4e643303c3 Merge branch 'atomic_design' into develop
Release package / release (pull_request) Successful in 8m51s
2026-04-06 13:09:47 +07:00
Thanh Quy - wolf 949badaeeb Delete IconButton 2026-04-06 13:08:49 +07:00
Thanh Quy - wolf 7547260e90 update README.md
Release package / release (pull_request) Failing after 8m34s
2026-04-05 16:56:34 +07:00
Thanh Quy - wolf c68ea1f26f Atomic for login page and register page 2026-04-05 16:16:48 +07:00
Thanh Quy - wolf 240155d1f6 Đóng gói code ở payment page 2026-04-04 02:02:23 +07:00
Thanh Quy - wolf ff133edfda Tái cấu trúc manager page 2026-04-03 20:24:36 +07:00
Thanh Quy - wolf 50473d799d Tái cấu trúc code 2026-04-03 20:09:05 +07:00
Thanh Quy - wolf 4f3d807c95 docs: Add Phase 1 implementation summary 2026-04-03 19:41:25 +07:00
Thanh Quy - wolf eca619b79a feat: Implement Atomic Design Phase 1 - Atoms Foundation
 Created complete atoms library (21 components):
- Buttons: Button, IconButton with variants (primary, secondary, danger, ghost)
- Inputs: TextInput, SearchInput, Textarea with validation support
- Typography: Heading (h1-h6), Text (variants), Caption
- Badges: Badge (5 variants), PriceBadge (formatted pricing)
- Dividers: Horizontal/vertical separators

🎯 Updated existing components to use atoms:
- CartProduct: Now uses Button, Text, Caption atoms
- ReviewModal: Now uses Button, Textarea, Heading, Text atoms

📚 Added comprehensive documentation:
- components/atoms/ATOMS.md: Complete atoms reference guide
- Usage examples, theming, import patterns, accessibility

🏗️ Architecture improvements:
- Foundation for molecules/organisms
- Type-safe components with full TypeScript support
- Consistent theming via CSS variables
- Barrel exports for clean imports

 Verified:
- npm run build: Success (✓ Compiled successfully)
- npm run format: All files formatted
- npm run lint: No new errors in atoms

Next: Phase 2 - Create molecules (ProductCard, FormField, SearchBar, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:40:07 +07:00
Thanh Quy - wolf d3a6d1164c Merge branch 'main' into develop
Release package / release (pull_request) Successful in 20m59s
2026-04-03 15:13:39 +07:00
Thanh Quy - wolf e211ee5835 Feature Analytics 2026-04-03 15:09:03 +07:00
Thanh Quy - wolf 17d82f7239 docs: comprehensive AGENTS guide for AI development
Update AGENTS.md with detailed project architecture, code conventions, and quick reference guide to help AI understand the codebase structure and development workflow more efficiently.

Key additions:
- Complete folder structure with descriptions (app/, components/, layouts/, lib/)
- File organization and naming conventions
- Code style (TypeScript, Prettier, ESLint, Tailwind)
- Responsive design guidelines
- Markdown documentation update rules
- Type safety and data flow patterns
- Quick reference guide for common tasks
- CSS variables reference
- Useful npm commands
- Common code patterns
- Project status (completed, in progress, planned features)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 09:23:49 +07:00
Thanh Quy - wolf e0e64ded9a Manage Product and List Product 2026-04-01 15:06:37 +07:00
106 changed files with 6952 additions and 5567 deletions
+1 -4
View File
@@ -14,10 +14,7 @@
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/\")",
"Bash(npm run build)",
"Bash(cat \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
"Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
"Bash(Get-ChildItem -Path \"C:\\\\VS Code\\\\CongNghePhanMem\\\\Final\\\\backend\" -Recurse -Include \"*.graphqls\", \"*.graphql\")",
"Bash(Select-Object FullName)",
"Skill(prompt-optimizer)"
"Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")"
]
}
}
+209
View File
@@ -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.
@@ -1,139 +0,0 @@
---
name: feature-workflow-tracer
description:
Trace and explain the end-to-end workflow of any feature or functionality in a
codebase. Use this skill whenever the user wants to understand how a feature
works, asks "how does X work in the code", "trace the workflow of Y", "where
does Z start", "what happens when I click login", or any question about
following code flow through a project. Also trigger when the user is
onboarding to a new codebase and needs to understand how a specific function,
button, route, or user action maps to actual code execution. Trigger even for
vague questions like "how is auth handled here?" or "explain the checkout
flow".
---
# Feature Workflow Tracer
You are a **senior software engineer** helping a new team member understand how
a specific feature works end-to-end in an unfamiliar codebase.
## Your Task
When the user provides a **feature name** (e.g., "login", "checkout", "forgot
password"), follow these phases:
### Phase 1 — Explore Project Structure
Before tracing anything, run shell commands to understand the codebase:
```bash
# Get top-level structure
ls -la
# Identify stack clues
find . -maxdepth 2 -name "package.json" -o -name "composer.json" -o -name "requirements.txt" | head -10
# Find route files
find . -type f \( -name "*.route.*" -o -name "routes.js" -o -name "web.php" -o -name "api.php" -o -name "router.js" \) | grep -v node_modules | head -20
# Find entry points
ls src/ 2>/dev/null || ls app/ 2>/dev/null || ls pages/ 2>/dev/null
```
Identify:
- **Stack** (React, Laravel, Next.js, Vue, Express, etc.)
- **Architecture pattern** (MVC, feature-based, domain-driven, etc.)
- **Entry point** for the requested feature (route file, page component, or
controller)
### Phase 2 — Trace the Workflow
Starting from the **UI layer**, follow the execution path step by step:
```
UI (button/form/link)
→ Event handler / Route handler
→ Service / Controller
→ Repository / Model / API call
→ Response / Side effect
```
For each step, find the **exact file and line number** using:
```bash
# Search for function/component by name
grep -rn "functionName\|ComponentName" src/ --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" --include="*.php"
# Find where a route is defined
grep -rn "'/login'\|\"login\"\|login.route\|LoginController" . --include="*.php" --include="*.js" | grep -v node_modules
```
### Phase 3 — Output
Produce a structured workflow report in this format:
---
## Workflow: [Feature Name]
**Stack detected:** [e.g., React + Laravel REST API]
**Entry point:** [e.g., `resources/js/pages/Login.jsx:12`]
### Step-by-step Flow
**1. [Action description]**`path/to/file.ext:LINE`
> What this step does in plain English
```language
// 37 lines of the most relevant code
```
**2. [Next step]**`path/to/file.ext:LINE`
> ...
_(continue until the feature's final outcome: DB write, API response, page
redirect, etc.)_
### Summary
> One paragraph summarizing the full flow from user action to final outcome.
---
## Tracing Rules
| Rule | Detail |
| --------------- | ----------------------------------------------------------------------------------------- |
| **Max depth** | 5 layers (UI → handler → service → repo → DB). Stop before third-party library internals. |
| **Always cite** | Every step must have `file:line`. Never say "somewhere in the codebase." |
| **Skip** | Config files, `.env`, migrations, boilerplate. Focus on application logic only. |
| **Branches** | If a step has success/error paths, show both as `1a` and `1b`. |
| **Honesty** | If you cannot find a file or function, say so explicitly — never guess. |
## Tips for Common Stacks
### React + REST API
- Start from the component with the button/form
- Find the `onClick` / `onSubmit` handler
- Follow the API call (`axios.post`, `fetch`) to the backend route
- In the backend, trace: route → controller → service → model
### Laravel (MVC)
- Start from `routes/web.php` or `routes/api.php`
- Follow: route → Controller method → Service (if any) → Model / DB query
### Next.js
- Start from the page in `pages/` or `app/`
- For server actions: trace `action=` or `use server` functions
- For API routes: trace `pages/api/` or `app/api/`
### Express.js
- Start from route definition in `routes/` or `app.js`
- Follow: router → middleware → controller → DB call
-103
View File
@@ -1,103 +0,0 @@
---
name: learning-assistant
description: >
A personal learning companion for students and self-learners. Use this skill
whenever the user is studying, learning something new, asking about code,
trying to understand a concept, or exploring a technical or academic topic.
Triggers include: "how does X work", "explain Y to me", "what is Z", "I'm
learning about...", "can you help me understand...", "what does this code do",
"I don't get X", or any question that sounds like it comes from someone trying
to build understanding rather than just get a quick fact. Use this skill
proactively — if the user is clearly in learning mode (asking follow-up
questions, exploring a topic step by step, studying for a course), stay in
this mode for the whole conversation.
---
# Learning Assistant
Your job is to help the user learn effectively. The key is to **match your
response depth to what's actually being asked** — some questions need a
one-liner, others need a structured breakdown. Read the question carefully and
pick the right mode.
---
## Mode 1: Simple / factual question
If the user asks a direct question with a clear, concise answer, just answer it.
Don't pad it out.
**Examples of simple questions:**
- "What does `===` mean in JavaScript?"
- "What's the difference between TCP and UDP?"
- "What does the `final` keyword do in Java?"
**How to respond:** One to three sentences. Be accurate and direct. If there's a
one-line example that makes it instantly clear, include it. Don't add sections
or menus.
---
## Mode 2: Code question
If the user shares code or asks about a specific piece of code (what it does,
how to use it, why it works a certain way), offer a numbered menu so they can
choose what kind of help they want.
**Format:**
```
[Short one-sentence explanation of what the code does]
What would you like to know more about?
1. How it works step by step
2. Example usage
3. Common variations / alternatives
4. Common mistakes or edge cases
5. Something else — just ask
```
Keep the initial explanation brief. The menu is there so the user drives the
depth, not you.
---
## Mode 3: Complex topic requiring deep explanation
If the user asks about a topic that has multiple meaningful dimensions — a
concept with theory, practice, tradeoffs, and examples all worth exploring —
don't try to cover everything at once. Surface the structure first and let them
pick.
**Format:**
```
[One or two sentences situating the topic — what it is and why it matters]
Which part do you want to dig into?
1. Core idea / how it works
2. Example / walkthrough
3. When to use it (and when not to)
4. Common misconceptions
5. [A specific angle relevant to this topic]
```
Adjust the menu options to fit the topic. Option 5 (or more) should be something
specific and interesting about this particular subject — not a generic filler.
---
## General principles
- **Be concise by default.** If you can say it clearly in two sentences, don't
write six.
- **Don't front-load.** Get to the point before adding caveats or context.
- **When you give examples, make them concrete.** Toy examples are fine; vague
pseudocode is not.
- **Let the user lead the depth.** Your job is to open doors, not walk through
all of them at once.
- **If a follow-up question changes the depth needed, switch modes.** The modes
are a guide, not a rigid script.
- **Use plain language.** Avoid unnecessary jargon. If a technical term is
necessary, define it briefly the first time.
+8 -67
View File
@@ -1,11 +1,11 @@
---
name: prompt-optimizer
description:
"Help users rewrite and improve AI/LLM prompts by adding specificity, context,
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."
suggestions interactively so users can choose which improvements to apply.
---
# Prompt Optimizer
@@ -73,7 +73,9 @@ When you use this skill, you'll see:
- **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.
some, or none)
Then you get a rewritten prompt combining all your choices.
### Example Interaction
@@ -83,8 +85,9 @@ When you use this skill, you'll see:
- **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"
- **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
@@ -107,65 +110,3 @@ A prompt becomes "good" when:
- 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 ✓
---
## Anti-Pattern Detection
Before suggesting improvements, scan the original prompt for these common
mistakes and report them with a warning:
| Anti-Pattern | Description | Example |
| ---------------------- | ----------------------------------------------------- | ------------------------------------------- |
| Too generic | No clear subject, action, or goal | "Tell me about AI" |
| Ambiguous pronouns | "it", "that", "this" with no clear referent | "Fix it so it works better" |
| Internal contradiction | Two requirements that cancel each other out | "Be concise but cover everything in detail" |
| Missing context | Requests an action without explaining why or for whom | "Rewrite this paragraph" |
**Output format:** Before showing improvement suggestions, print a "Detected
Issues" block listing any anti-patterns found. If none are found, skip this
block silently. Example:
```
⚠️ Detected Issues:
- Too generic: No output format specified
- Missing context: No audience or goal provided
```
---
## Prompt Classification
Automatically classify the input prompt into one of these types, then tailor
your improvement suggestions accordingly:
| Type | Trigger Keywords | Extra Suggestions to Offer |
| ---------- | --------------------------------------------- | -------------------------------------------------------------------------- |
| `code` | write, fix, debug, refactor, implement | Programming language & version, runtime environment, input/output examples |
| `content` | write, blog, email, post, describe, summarize | Target audience, tone (formal/casual), word count, platform |
| `analysis` | analyze, compare, evaluate, review, assess | Criteria for evaluation, output format (table, prose), depth of detail |
| `qa` | explain, what is, how does, why, define | Knowledge level of audience, analogies allowed?, length of answer |
| `task` | do, create, set up, build, generate, automate | Step-by-step vs one-shot, tools/permissions available, success criteria |
Show the detected type at the top of your response:
`📌 Prompt type detected: [type]`
---
## Chain-of-Thought Mode
Offer "Chain-of-Thought Enhancement" as an optional improvement when showing
suggestions. When the user selects it, add reasoning instructions to the end of
the optimized prompt based on type:
| Prompt Type | Chain-of-Thought Addition |
| --------------- | -------------------------------------------------------------------------- |
| General | `"Think step by step before answering."` |
| Analysis | `"Explain your reasoning for each point before giving a conclusion."` |
| Code | `"Outline your approach and data structures before writing any code."` |
| Decision-making | `"List pros and cons, then state your recommendation with justification."` |
**Why it works:** Chain-of-Thought forces the AI to externalize its reasoning
process. This reduces hallucination (the AI catches its own errors
mid-reasoning), makes outputs easier to verify, and produces more structured
answers. Studies show CoT improves accuracy on complex tasks by 2040%.
-9
View File
@@ -1,9 +0,0 @@
{
"features": {
"ghcr.io/muhmdraouf/devcontainers-features/alpine-apk:0": {
"version": "0.0.1",
"resolved": "ghcr.io/muhmdraouf/devcontainers-features/alpine-apk@sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044",
"integrity": "sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044"
}
}
}
+3 -3
View File
@@ -13,12 +13,12 @@
},
"customizations": {
"vscode": {
"extensions": ["esbenp.prettier-vscode", "anthropic.claude-code"]
"extensions": ["esbenp.prettier-vscode"]
}
},
}
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000]
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "uname -a",
+8 -25
View File
@@ -22,16 +22,9 @@ jobs:
id-token: write
runs-on: ubuntu-latest
steps:
- name: Install dependencies with Proxy
env:
http_proxy: "http://host.docker.internal:3142"
https_proxy: "http://host.docker.internal:3142"
run: |
# Kiểm tra proxy có hoạt động không
echo "Acquire::http::Proxy \"$http_proxy\";" > /etc/apt/apt.conf.d/80proxy
apt-get update
apt-get install -y --no-install-recommends --no-install-suggests \
docker-cli zip docker-buildx
- uses: gerlero/apt-install@v1
with:
packages: docker.io zip
- name: Setup SSH Key
run: |
@@ -48,21 +41,9 @@ jobs:
- uses: pnpm/action-setup@v3
with:
version: 10.9.0
version: latest
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
cache: true
- name: Update dependencies
run: pnpm update
@@ -95,6 +76,7 @@ jobs:
password: ${{ secrets.ACCESS_TOKEN }}
- name: Prepare Docker Metadata
if: gitea.ref == 'refs/heads/main'
id: meta
run: |
# Lowercase Repository
@@ -107,10 +89,11 @@ jobs:
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
- name: Build and Push Docker Image
if: gitea.ref == 'refs/heads/main'
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.ref_name == 'main' }}
push: true
tags: |
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
+71 -9
View File
@@ -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ý,
thanh toán)
- **`(feed)/`** - Feed/discovery page (khám phá quán)
- **`(manager)/`** - Manager dashboard (quản lý sản phẩm, đơn hàng) - **[ĐANG
PHÁT TRIỂN]**
- **`(manager)/`** - Manager dashboard (quản lý sản phẩm, phân tích tài
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
### `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
- **`product-grid/`** - ProductGrid, ProductFilters
- **`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
- **`hero-section/`** - HeroSection
- **`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)
- **`feed-layout/`** - FeedLayout
- **`manager-layout/`** - ManagerLayout
- **`staff-layout/`** - StaffLayout (navigation shell for staff pages)
- **`checkout-layout/`** - CheckoutLayout
- **`auth-layout/`** - AuthLayout
@@ -161,8 +171,12 @@ Chứa logic dùng chung, context, constants, types:
- **`auth-context.tsx`** - AuthProvider + useAuth() hook (login, logout,
register)
- **`cart-context.tsx`** - CartProvider + useCart() hook (add, remove, quantity
operations)
operations, clearCart)
- **`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
### `public/` - Static Assets
@@ -233,11 +247,41 @@ components/
│ ├── product-grid/
│ │ ├── ProductGrid.tsx
│ │ └── 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
├── templates/
│ ├── main-layout/
│ │ ├── MainLayout.tsx
│ │ └── MainLayout.types.ts
│ ├── manager-layout/
│ │ └── ManagerLayout.tsx
│ ├── staff-layout/
│ │ └── StaffLayout.tsx
│ └── index.ts
└── ATOMIC_DESIGN.md
```
@@ -314,9 +358,15 @@ components/
- **Mock data first** - Use `lib/constants.ts` for development, replace with API
later
- **Contexts for state sharing:**
- `AuthContext` (lib/auth-context.tsx) - User state + auth operations
- `CartContext` (lib/cart-context.tsx) - Shopping cart + operations
- `MenuContext` (lib/menu-context.tsx) - Active category state
- `AuthContext` (lib/auth-context.tsx) - User state + auth operations
`useAuth()`
- `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
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
> Last Updated: 2026-04-18
### ✅ Completed Features
- User authentication (login, register, logout)
- Product grid display with category filtering
- 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)
- Responsive design (mobile, tablet, desktop)
- Header + Footer with navigation
- 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
- Manager dashboard (~70% complete) - `app/(manager)/` route group
- Remaining: order management, inventory tracking, real API integration
- Atomic Design refactoring (components reorganization)
- Manager dashboard (quản lý sản phẩm) - `app/(manager)/` route group
### 📋 Planned Features
+58 -22
View File
@@ -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,
React 19, TypeScript và Tailwind CSS v4.
_Last Updated: 2026-04-18_
---
## 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
#### 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 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
- 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ê
- 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)
- Empty state khi không tìm thấy quán
#### 3. **Trang Chính - Duyệt Thực Đơn** (`app/(main)`)
Dành cho khách hàng:
##### 3. **Trang Chính - Duyệt Thực Đơn** (`app/(main)`)
- 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ị)
@@ -38,41 +40,52 @@ Dành cho khách hàng:
- Lọc tự động theo trạng thái available
- 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
- 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)
- **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
- 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
- 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)
- Lưu thông tin user trong localStorage
- Mock auth database với 3 loại tài khoản
- 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
- 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
- CRUD sản phẩm, combo, danh mục qua modals
- 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)
@@ -81,6 +94,24 @@ Dành cho quản lý:
- 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
#### 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
@@ -144,9 +175,12 @@ frondend/
| | +-- layout.tsx # Feed layout
| | +-- feed/page.tsx # Trang khám phá quán nước (/feed)
| +-- (manager)/ # Manager route group
| +-- layout.tsx # Manager layout - auth guard + ManagerProvider
| +-- manager/page.tsx # Manager Dashboard (/manager)
| +-- manager/analytics/page.tsx # Financial Analytics (/manager/analytics)
| | +-- layout.tsx # Manager layout - auth guard + ManagerProvider
| | +-- manager/page.tsx # Manager Dashboard (/manager)
| | +-- 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
| +-- atoms/ # Nguyên tử: Button, Input, Badge, Text...
| +-- molecules/ # Phân tử: ProductCard, ShopCard, SearchBar...
@@ -202,8 +236,9 @@ frondend/
| ---------------- | --------- | ------------------------------------- |
| Next.js | 16.1.7 | React Framework (App Router) |
| React | 19.2.3 | Thư viện UI |
| TypeScript | ^5 | Kiểu dữ liệu tĩnh |
| TypeScript | ^5.9.3 | Kiểu dữ liệu tĩnh |
| Tailwind CSS | ^4.2.2 | Utility-first CSS framework |
| qrcode.react | ^4.2.0 | Tạo mã QR thanh toán |
| Geist Font | - | Font chữ (Google Fonts via next/font) |
| FontAwesome | 6.7.2 | Icon library (CDN) |
| pnpm | - | Package manager |
@@ -224,14 +259,15 @@ frondend/
- **Thanh Toán:** `app/(main)/payment/page.tsx`
- **Manager Dashboard:** `app/(manager)/manager/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
| Loại tài khoản | Tên đăng nhập | Mật khẩu |
| -------------- | ------------- | ------------- |
| Manager | admin | admin |
| Staff | Nguyễn Văn An | Nguyễn Văn An |
| Customer | 0987654321 | user1 |
| Loại tài khoản | Tên đăng nhập | Mật khẩu / OTP |
| -------------- | ------------- | -------------------------- |
| Manager | admin | admin |
| Staff | Nguyễn Văn An | Nguyễn Văn An |
| Customer | 0987654321 | bất kỳ 6 chữ số (mock OTP) |
### Design & Styling
+156 -11
View File
@@ -1,12 +1,157 @@
# TODO - Add image upload field for manager add/update menu item
# Coffee Shop Frontend - TODO
- [x] Update GraphQL queries/mutations in `lib/manager-context.tsx` to include
`imageUrl`
- [x] Update `components/organisms/manager/ProductModal.tsx`
- [x] Add file input for image selection
- [x] Add upload button to call `POST /api/file`
- [x] Store returned URL string into `form.imageUrl`
- [x] Show upload/loading/error states and image preview
- [x] Update `components/organisms/manager/ProductsTab.tsx` to show image
thumbnail in table
- [x] Mark TODO progress after each step completed
_Last Updated: 2026-04-18_
---
## Completed Features & Implementations
### A. Dead Code Removed
- [x] lib/constants.ts - Removed unused NAV_LINKS export
- [x] lib/types.ts - Removed unused NavLink interface
- [x] components/Navbar.tsx - Removed trivial handleClick wrapper; inlined
onCategoryChange call
- [x] components/Navbar.tsx - Removed unused Link import
### B. Bugs / Inaccuracies Fixed
- [x] layouts/header.tsx - Fixed JSDoc: 3-column -> 2-column layout (no center
section exists)
- [x] app/page.tsx - Added available !== false filter to product list
- [x] app/page.tsx - Fixed setState-in-effect lint error: moved initial sidebar
state to lazy useState initializer
- [x] next.config.ts - Added explanatory JSDoc comment
### C. Documentation Updated
- [x] README.md - Fixed file structure tree, removed SCSS, fixed dark mode note,
updated tech table
- [x] components/COMPONENTS.md - Fixed CartProduct styling (was outdated
w-64/text-red-500/bg-blue-600); added Navbar, Header, Footer sections
### D. New Documentation Created
- [x] WORKFLOW.md - Architecture, data flow, design token system, how-to guides,
dev workflow
### E. Mini-test Results
- [x] npm run lint - PASSED (0 errors, 0 warnings)
- [x] npm run build - PASSED (Compiled successfully, TypeScript clean, static
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)
### Cart & Ordering
- [ ] Cart sidebar/modal with item list and total (quick-access without going to
payment page)
- [ ] Order submission API integration
- [ ] Order history/tracking page for customers
- [ ] Toast notifications for cart actions
### Authentication & User Management
- [ ] Real backend authentication (replace MOCK_AUTH_DB)
- [ ] Real OTP delivery service (SMS integration)
- [ ] User profile page with edit capability
- [ ] Password reset/recovery flow
- [ ] Session management and token refresh
### Manager Features (Remaining ~30%)
- [ ] Order management & tracking for manager
- [ ] Inventory management beyond available toggle (stock counts, low-stock
alerts)
- [ ] Staff management tab in Manager Dashboard
- [ ] Shift approval workflow (manager approves staff shift requests)
### Backend Integration
- [ ] Replace MOCK_PRODUCTS with API calls (GET /api/products)
- [ ] Replace MOCK_SHOPS with API calls (GET /api/shops)
- [ ] Replace MOCK_USERS with real authentication (POST /api/auth/login)
- [ ] Real product images hosted on server/CDN (replace public/imgs/products/)
- [ ] Real analytics data from backend (GET /api/analytics)
- [ ] Real shift/schedule data from backend
### UX Improvements
- [ ] Dark mode toggle (CSS variables prepared, toggle UI needed)
- [ ] Loading skeletons for product grid
- [ ] Product detail modal/page with full description
- [ ] Wishlist/favorites feature
- [ ] Sort products (price, rating, etc.)
- [ ] Filter by price range
- [ ] Quantity selector in product card
- [ ] Related products suggestions
- [ ] Push notifications for order status updates
### Performance & SEO
- [ ] Dynamic route generation for products (`app/(main)/product/[id]/page.tsx`)
- [ ] Dynamic route generation for shops (`app/(feed)/shop/[id]/page.tsx`)
- [ ] Meta tags and Open Graph for SEO
- [ ] Image optimization and lazy loading
- [ ] Code splitting and dynamic imports
### Accessibility & Testing
- [ ] Keyboard navigation testing
- [ ] Full ARIA labels audit (WCAG 2.1 AA)
- [ ] Unit tests for contexts (auth, cart, menu, manager)
- [ ] E2E tests for user flows (checkout, login, manager CRUD)
- [ ] Accessibility audit tool (axe, Lighthouse)
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { SearchBar } from "@/components/molecules/search-bar";
import { ShopGrid } from "@/components/organisms/shop-grid";
import { useState } from "react";
export default function FeedPage() {
const [searchName, setSearchName] = useState("");
const [searchAddress, setSearchAddress] = useState("");
const hasFilters = searchName || searchAddress;
return (
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
<div className="mx-auto max-w-7xl px-4 py-8 md:px-6 lg:px-8">
{/* Page title */}
<div className="mb-8">
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
Khám phá quán nước
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Tìm chọn quán yêu thích của bạn
</p>
</div>
{/* Shop grid */}
<div className="mb-10">
<ShopGrid searchName={searchName} searchAddress={searchAddress} />
{hasFilters && (
<div className="mt-4 flex justify-center">
<button
onClick={() => {
setSearchName("");
setSearchAddress("");
}}
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
>
Xóa bộ lọc
</button>
</div>
)}
</div>
{/* Filter / Search bar — sticky bottom */}
<div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5">
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
<i className="fa-solid fa-filter text-(--color-primary)"></i>
<span>Lọc quán</span>
</div>
{/* Name search */}
<SearchBar
value={searchName}
onChange={setSearchName}
onClear={() => setSearchName("")}
placeholder="Tìm theo tên quán..."
className="min-w-0 flex-1"
/>
{/* Address search — different icon so not using SearchBar atom */}
<div className="relative min-w-0 flex-1">
<i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
<input
type="text"
value={searchAddress}
onChange={(e) => setSearchAddress(e.target.value)}
placeholder="Tìm theo địa chỉ..."
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
/>
{searchAddress && (
<button
onClick={() => setSearchAddress("")}
aria-label="Xóa tìm kiếm địa chỉ"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
</div>
</div>
</div>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { FeedLayout } from "@/components/templates/feed-layout";
export default function RootFeedLayout({
children,
}: {
children: React.ReactNode;
}) {
return <FeedLayout>{children}</FeedLayout>;
}
+1 -6
View File
@@ -1,14 +1,9 @@
import { MainLayout } from "@/components/templates/main-layout";
import { ManagerProvider } from "@/lib/manager-context";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<MainLayout>
<ManagerProvider>{children}</ManagerProvider>
</MainLayout>
);
return <MainLayout>{children}</MainLayout>;
}
-174
View File
@@ -1,174 +0,0 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
export default function LoginOtpPage() {
const router = useRouter();
const { setUser } = useAuth();
const [phone, setPhone] = useState("");
const [role, setRole] = useState("");
const [otp, setOtp] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ otp: "", general: "" });
useEffect(() => {
const storedPhone = sessionStorage.getItem("login_phone");
const storedRole = sessionStorage.getItem("login_role");
if (!storedPhone || !storedRole) {
router.replace("/login");
return;
}
setPhone(storedPhone);
setRole(storedRole);
}, [router]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!otp.trim()) {
setErrors({ otp: "Please enter your OTP code", general: "" });
return;
}
setIsLoading(true);
setErrors({ otp: "", general: "" });
try {
const endpoint =
role === "manager"
? "/api/manager/quick_login"
: "/api/customer/quick_login";
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, otp }),
});
if (res.ok) {
const userData = await res.json();
if (userData.role) userData.role = userData.role.toLowerCase();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
sessionStorage.removeItem("login_phone");
sessionStorage.removeItem("login_role");
router.push(role === "manager" ? "/manager" : "/");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const msg =
errorCode === "InvalidOTP"
? "Incorrect or expired OTP code"
: "An error occurred, please try again";
setErrors({ otp: msg, general: "" });
}
} catch {
setErrors({ otp: "", general: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
if (!phone) return null;
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo & Shop Name */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">
Verify your phone number
</p>
</div>
{errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Info */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="text-sm text-blue-800">
<i className="fa-solid fa-circle-info mr-2"></i>
OTP code sent to <strong>{phone}</strong>
</p>
</div>
{/* OTP Input */}
<div>
<label
htmlFor="otp"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
OTP code
</label>
<div className="relative">
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id="otp"
type="text"
value={otp}
onChange={(e) => {
setOtp(e.target.value);
setErrors({ otp: "", general: "" });
}}
placeholder="Enter OTP code"
maxLength={6}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors.otp && (
<ErrorMessageLogin message={errors.otp} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Sign in"
)}
</Button>
<button
type="button"
onClick={() => router.push("/login")}
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Change phone number
</button>
</div>
</form>
</div>
</div>
);
}
+28 -1
View File
@@ -25,12 +25,39 @@ export default function LoginPage() {
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">
Enter your phone number to sign in
Đăng nhập vào hệ thống
</p>
</div>
{/* Login Form */}
<LoginForm />
{/* Demo Credentials Info */}
<div className="bg-background mt-6 rounded-lg p-4">
<p className="mb-2 text-xs font-semibold text-(--color-text-muted)">
Tài khoản demo:
</p>
<ul className="space-y-1 text-xs text-(--color-text-muted)">
<li>
Quản :{" "}
<code className="rounded bg-white px-1.5 py-0.5">
admin / admin
</code>
</li>
<li>
Nhân viên:{" "}
<code className="rounded bg-white px-1.5 py-0.5">
Nguyễn Văn An / Nguyễn Văn An
</code>
</li>
<li>
Khách hàng:{" "}
<code className="rounded bg-white px-1.5 py-0.5">
0987654321 / user1
</code>
</li>
</ul>
</div>
</div>
</div>
);
-185
View File
@@ -1,185 +0,0 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
export default function LoginPasswordPage() {
const router = useRouter();
const { setUser } = useAuth();
const [phone, setPhone] = useState("");
const [role, setRole] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ password: "", general: "" });
useEffect(() => {
const storedPhone = sessionStorage.getItem("login_phone");
const storedRole = sessionStorage.getItem("login_role");
if (!storedPhone || (storedRole !== "manager" && storedRole !== "staff")) {
router.replace("/login");
return;
}
setPhone(storedPhone);
setRole(storedRole);
}, [router]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!password.trim()) {
setErrors({ password: "Please enter your password", general: "" });
return;
}
setIsLoading(true);
setErrors({ password: "", general: "" });
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, password }),
});
if (res.ok) {
const userData = await res.json();
if (userData.role) userData.role = userData.role.toLowerCase();
else userData.role = role;
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
sessionStorage.removeItem("login_phone");
sessionStorage.removeItem("login_role");
router.push(role === "staff" ? "/staff/schedule" : "/manager");
} else {
const STATUS_ERROR_MAP: Record<number, string> = {
400: "Incorrect login details, please try again",
401: "Incorrect password",
403: "Account is locked",
404: "Account does not exist",
};
const msg =
STATUS_ERROR_MAP[res.status] ??
(res.status >= 500
? "Server error, please try again later"
: "An error occurred, please try again");
setErrors({ password: "", general: msg });
}
} catch {
setErrors({
password: "",
general: "Unable to connect, please try again",
});
} finally {
setIsLoading(false);
}
};
if (!phone) return null;
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo & Shop Name */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">Manager sign in</p>
</div>
{errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Phone display */}
<div className="rounded-lg border border-(--color-border) bg-(--color-background) px-4 py-3 text-sm text-(--color-text-secondary)">
<i className="fa-solid fa-phone mr-2 text-(--color-text-muted)"></i>
{phone}
</div>
{/* Password Input */}
<div>
<label
htmlFor="password"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Password
</label>
<div className="relative">
<i className="fa-solid fa-lock absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({ password: "", general: "" });
}}
placeholder="Enter password"
disabled={isLoading}
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) disabled:opacity-60 lg:pl-11 ${errors.password ? "border-red-400" : "border-(--color-border)"}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
aria-label={showPassword ? "Hide password" : "Show password"}
>
<i
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
></i>
</button>
</div>
{errors.password && (
<ErrorMessageLogin message={errors.password} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Sign in"
)}
</Button>
<button
type="button"
onClick={() => router.push("/login")}
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Change phone number
</button>
</div>
</form>
</div>
</div>
);
}
-320
View File
@@ -1,320 +0,0 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
type PageState = "checking" | "available" | "closed" | "error";
export default function ManagerSignupPage() {
const router = useRouter();
const [pageState, setPageState] = useState<PageState>("checking");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
name: "",
phone: "",
password: "",
eateryName: "",
bankAccount: "", // 1. Thêm bankAccount vào state
});
const [errors, setErrors] = useState({
name: "",
phone: "",
password: "",
eateryName: "",
bankAccount: "", // 2. Thêm bankAccount vào errors state
submit: "",
});
useEffect(() => {
fetch("/api/manager/signup")
.then((res) => {
setPageState("available");
})
.catch(() => setPageState("error"));
}, []);
const validatePhone = (phone: string) =>
/^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const handleChange =
(field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const validate = () => {
const next = {
name: "",
phone: "",
password: "",
eateryName: "",
bankAccount: "",
submit: "",
};
if (!form.name.trim()) next.name = "Please enter your full name";
if (!form.phone.trim()) next.phone = "Please enter your phone number";
else if (!validatePhone(form.phone))
next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter your password";
else if (form.password.length < 6)
next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim())
next.eateryName = "Please enter the restaurant name";
if (!form.bankAccount.trim())
next.bankAccount = "Please enter your bank account number";
setErrors(next);
return (
!next.name &&
!next.phone &&
!next.password &&
!next.eateryName &&
!next.bankAccount
);
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
try {
const res = await fetch("/api/manager/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok || res.status === 201) {
router.push("/login");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const errorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
setErrors({
...errors,
submit: errorMap[errorCode] ?? "An error occurred, please try again",
});
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">
Create a manager account
</p>
</div>
{/* Checking */}
{pageState === "checking" && (
<div className="flex flex-col items-center gap-3 py-8 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-2xl text-(--color-primary)"></i>
<p className="text-sm">Checking...</p>
</div>
)}
{/* Closed */}
{pageState === "closed" && (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
Registration closed
</h2>
<p className="text-sm text-(--color-text-muted)">
The system already has a restaurant. Registration is no longer
available.
</p>
</div>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
Back to login
</Link>
</div>
)}
{/* Error */}
{pageState === "error" && (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100">
<i className="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
Unable to connect
</h2>
<p className="text-sm text-(--color-text-muted)">
Unable to check registration status. Please try again.
</p>
</div>
<Button
variant="primaryNoBorder"
style="login"
size="lg"
onClick={() => {
setPageState("checking");
fetch("/api/manager/signup")
.then((r) => {
if (r.ok) setPageState("available");
else if (r.status === 403) setPageState("closed");
else setPageState("error");
})
.catch(() => setPageState("error"));
}}
>
Retry
</Button>
<Link
href="/login"
className="text-sm text-(--color-primary) underline"
>
Back to login
</Link>
</div>
)}
{/* Signup Form */}
{pageState === "available" && (
<form onSubmit={handleSubmit} className="space-y-4">
{[
{
id: "name",
label: "Full name",
icon: "fa-user",
placeholder: "John Doe",
field: "name" as const,
type: "text",
},
{
id: "phone",
label: "Phone number",
icon: "fa-phone",
placeholder: "0987654321",
field: "phone" as const,
type: "tel",
},
{
id: "password",
label: "Password",
icon: "fa-lock",
placeholder: "At least 6 characters",
field: "password" as const,
type: "password",
},
{
id: "eateryName",
label: "Restaurant name",
icon: "fa-store",
placeholder: "Coffee & More",
field: "eateryName" as const,
type: "text",
},
{
id: "bankAccount",
label: "Bank account number (example: acb-44359797)",
icon: "fa-credit-card",
placeholder: "Enter account number (for QR payment)",
field: "bankAccount" as const,
type: "text",
},
].map(({ id, label, icon, placeholder, field, type }) => (
<div key={id}>
<label
htmlFor={id}
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
{label}
</label>
<div className="relative">
<i
className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}
></i>
<input
id={id}
type={type}
value={form[field]}
onChange={handleChange(field)}
placeholder={placeholder}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 pl-10 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors[field] && (
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{errors[field]}
</p>
)}
</div>
))}
{errors.submit && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<i className="fa-solid fa-circle-exclamation mr-2"></i>
{errors.submit}
</div>
)}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Register"
)}
</Button>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
Already have an account? Sign in
</Link>
</div>
</form>
)}
</div>
</div>
);
}
+38 -74
View File
@@ -1,82 +1,28 @@
"use client";
import { SearchBar } from "@/components/molecules/search-bar";
import { CategorySidebar } from "@/components/organisms/navigation";
import { ProductGrid } from "@/components/organisms/product-grid";
import { eateryClient } from "@/lib/apollo-clients";
import { useAuth } from "@/lib/auth-context";
import { allEateriesQuery } from "@/lib/types";
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
import { useRouter } from "next/navigation";
import { MENU_CATEGORIES } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import { useEffect, useState } from "react";
const GET_EATERY_COUNT = gql`
{
allEateries {
id
}
}
`;
function StaffHomePage() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsSidebarOpen(mq.matches);
const handler = (e: MediaQueryListEvent) => setIsSidebarOpen(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return (
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] flex-col items-start">
{/* ── Body — same as Customer ── */}
<main className="w-full min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<SearchBar
value={searchQuery}
onChange={(q) => setSearchQuery(q)}
onClear={() => setSearchQuery("")}
placeholder="Search items..."
className="sm:max-w-xs"
/>
</div>
<ProductGrid searchQuery={searchQuery} isSidebarOpen={isSidebarOpen} />
</main>
</div>
);
}
/**
* Main page — sidebar + product grid layout.
*
* Layout:
* [Sidebar (sticky, collapsible)] | [Main content (scrollable)]
*
* Sidebar state:
* - Desktop (≥ 1024px): expanded by default
* - Mobile (< 1024px): collapsed by default
*/
export default function Home() {
const router = useRouter();
const { user, isInitialized } = useAuth();
const { activeCategory, setActiveCategory } = useMenu();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { data, loading, error } = useQuery<allEateriesQuery>(
GET_EATERY_COUNT,
{
client: eateryClient,
fetchPolicy: "no-cache",
skip: user?.role === "staff",
},
);
useEffect(() => {
if (!loading && data) {
console.log(data);
const count = data.allEateries.length ?? 0;
if (count === 0) {
router.push("/manager-signup");
}
}
}, [data, loading, router]);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -86,26 +32,44 @@ export default function Home() {
return () => mq.removeEventListener("change", handler);
}, []);
if (!isInitialized) return <div>Loading...</div>;
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearchQuery("");
}, [activeCategory]);
if (user?.role === "staff") return <StaffHomePage />;
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
return (
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
{/* ── Sidebar ── */}
<CategorySidebar
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((prev) => !prev)}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
{/* ── Main content ── */}
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
{/* ── Section heading + search bar ── */}
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
{/* Title + count */}
<div className="shrink-0">
<h1 className="text-foreground text-xl font-bold">
{activeCategoryLabel}
</h1>
</div>
{/* Search bar */}
<SearchBar
value={searchQuery}
onChange={(q) => {
if (q && activeCategory !== "all") setActiveCategory("all");
setSearchQuery(q);
}}
onClear={() => setSearchQuery("")}
placeholder="Search items..."
placeholder="Tìm kiếm món..."
className="sm:max-w-xs"
/>
</div>
+68 -83
View File
@@ -2,33 +2,22 @@
import Button from "@/components/atoms/buttons/Button";
import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard";
import { useAuth } from "@/lib/auth-context";
import { useCart } from "@/lib/cart-context";
import { useManager } from "@/lib/manager-context";
import { MenuItemEntity } from "@/lib/types";
export const formatPrice = (value?: number) =>
(value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
const formatPrice = (value: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
export default function PaymentPage() {
const {
items,
totalPrice,
eateryId,
increaseQty,
decreaseQty,
removeFromCart,
setQuantity,
} = useCart();
const { products } = useManager();
const findProduct = (id: string): MenuItemEntity =>
products.find((i) => i.id == id) ??
({
name: "Unknown product",
description: "",
price: 0,
} as MenuItemEntity);
const { user } = useAuth();
return (
<div>
@@ -38,13 +27,13 @@ export default function PaymentPage() {
<div className="bg-card overflow-hidden rounded-2xl border border-(--color-border-light)">
<div className="border-b border-(--color-border-light) px-4 py-3">
<h1 className="text-foreground text-lg font-bold md:text-xl">
Payment
Trang thanh toán
</h1>
</div>
{items?.length === 0 ? (
{items.length === 0 ? (
<div className="px-4 py-10 text-center text-(--color-text-muted)">
Your cart is empty.
Chưa sản phẩm nào trong giỏ hàng.
</div>
) : (
<div className="overflow-x-auto">
@@ -52,85 +41,81 @@ export default function PaymentPage() {
<thead>
<tr className="bg-(--color-border-light)/40 text-left">
<th scope="col" className="px-4 py-3 font-semibold">
Product name
Tên sản phẩm
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Price
Giá tiền
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Description
tả
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Quantity
Số lượng
</th>
<th
scope="col"
className="px-4 py-3 text-right font-semibold"
>
Delete
Xóa
</th>
</tr>
</thead>
<tbody>
{items?.map(
({
productId: id,
priceAtTimeOfAdding: price,
quantity,
}) => {
const { name, description } = findProduct(id);
return (
<tr
key={id}
className={`border-t border-(--color-border-light) ${quantity == 0 ? "hidden" : ""}`}
{items.map((item) => (
<tr
key={item.id}
className="border-t border-(--color-border-light)"
>
<td className="text-foreground px-4 py-3 font-medium">
{item.name}
</td>
<td className="px-4 py-3 font-semibold text-(--color-primary)">
{formatPrice(item.price)}
</td>
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
<p className="line-clamp-2">{item.description}</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => decreaseQty(item.id)}
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}`}
>
-
</button>
<input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
setQuantity(item.id, Number(e.target.value))
}
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
title="Nhập số lượng"
/>
<button
onClick={() => increaseQty(item.id)}
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}`}
>
+
</button>
</div>
</td>
<td className="px-4 py-3 text-right">
<Button
onClick={() => removeFromCart(item.id)}
variant="danger"
size="md"
style="payment"
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
>
<td className="text-foreground px-4 py-3 font-medium">
{name}
</td>
<td className="px-4 py-3 font-semibold text-(--color-primary)">
{formatPrice(price)}
</td>
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
<p className="line-clamp-2">{description}</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => decreaseQty(id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
>
-
</button>
<input
type="number"
value={quantity}
onChange={(e) =>
setQuantity(id, Number(e.target.value))
}
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
/>
<button
onClick={() => increaseQty(id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
>
+
</button>
</div>
</td>
<td className="px-4 py-3 text-right">
<Button
onClick={() => removeFromCart(id)}
variant="danger"
size="md"
style="payment"
>
Delete product
</Button>
</td>
</tr>
);
},
)}
Xóa sản phẩm
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -139,10 +124,10 @@ export default function PaymentPage() {
</section>
<PaymentSummaryCard
cartItems={items}
totalPrice={totalPrice}
isCustomer={true}
role={user?.role ?? "customer"}
backHref="/"
eateryId={eateryId}
/>
</div>
</div>
+33 -148
View File
@@ -6,48 +6,19 @@ import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, useState } from "react";
// Static OTP for demo (in production, this would be sent via SMS)
const DEMO_OTP = "123456";
export default function RegisterPage() {
const router = useRouter();
const { setUser } = useAuth();
const { completeRegistration } = useAuth();
const [step, setStep] = useState<"phone" | "otp">("phone");
const [phone, setPhone] = useState("");
const [otp, setOtp] = useState("");
const [errors, setErrors] = useState({ phone: "", otp: "" });
const [isLoading, setIsLoading] = useState(false);
const [otpSending, setOtpSending] = useState(false);
const [otpSendError, setOtpSendError] = useState("");
const sendOtp = async () => {
setOtpSending(true);
setOtpSendError("");
try {
const res = await fetch("/api/sms_otp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (!res.ok) {
setOtpSendError("Unable to send OTP, please try again");
}
} catch {
setOtpSendError("Unable to connect, please try again");
} finally {
setOtpSending(false);
}
};
useEffect(() => {
if (step === "otp") {
sendOtp();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
// Validate Vietnamese phone number
const validatePhone = (phoneNumber: string): boolean => {
@@ -57,87 +28,43 @@ export default function RegisterPage() {
return phoneRegex.test(phoneNumber);
};
const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handlePhoneSubmit = (e: FormEvent) => {
e.preventDefault();
if (!phone.trim()) {
setErrors({ ...errors, phone: "Please enter your phone number" });
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
return;
}
if (!validatePhone(phone)) {
setErrors({
...errors,
phone: "Invalid phone number (e.g. 0987654321)",
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
});
return;
}
setIsLoading(true);
// Move to OTP step
setStep("otp");
setErrors({ phone: "", otp: "" });
try {
const res = await fetch("/api/customer/quick_signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (res.ok) {
setStep("otp");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const phoneErrorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
const msg =
phoneErrorMap[errorCode] ?? "An error occurred, please try again";
setErrors({ phone: msg, otp: "" });
}
} catch {
setErrors({ phone: "Unable to connect, please try again", otp: "" });
} finally {
setIsLoading(false);
}
};
const handleOtpSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleOtpSubmit = (e: FormEvent) => {
e.preventDefault();
if (!otp.trim()) {
setErrors({ ...errors, otp: "Please enter your OTP code" });
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
return;
}
setIsLoading(true);
setErrors({ phone: "", otp: "" });
try {
const res = await fetch("/api/customer/quick_login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, otp }),
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
router.push("/");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const msg =
errorCode === "InvalidOTP"
? "Incorrect or expired OTP code"
: "An error occurred, please try again";
setErrors({ phone: "", otp: msg });
}
} catch {
setErrors({ phone: "", otp: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
if (otp !== DEMO_OTP) {
setErrors({ ...errors, otp: "Mã OTP không đúng" });
return;
}
// Complete registration
completeRegistration(phone);
router.push("/");
};
const handleBackToPhone = () => {
@@ -167,8 +94,8 @@ export default function RegisterPage() {
</h1>
<p className="text-sm text-(--color-text-muted)">
{step === "phone"
? "Create a customer account"
: "Verify your phone number"}
? "Đăng ký tài khoản khách hàng"
: "Xác thực số điện thoại"}
</p>
</div>
@@ -204,7 +131,7 @@ export default function RegisterPage() {
htmlFor="phone"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Phone number
Số điện thoại
</label>
<div className="relative">
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
@@ -217,8 +144,7 @@ export default function RegisterPage() {
setErrors({ ...errors, phone: "" });
}}
placeholder="0987654321"
disabled={isLoading}
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) disabled:opacity-60 lg:pl-11 ${errors.phone ? "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.phone ? "border-red-400" : "border-(--color-border)"} `}
/>
</div>
{errors.phone && (
@@ -228,7 +154,7 @@ export default function RegisterPage() {
</p>
)}
<p className="mt-2 text-xs text-(--color-text-muted)">
Enter a Vietnamese phone number (10 digits, starting with 0)
Nhập số điện thoại Việt Nam (10 số, bắt đu bằng 0)
</p>
</div>
@@ -240,16 +166,8 @@ export default function RegisterPage() {
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Continue"
)}
Tiếp tục
</Button>
{/* Back to Login */}
@@ -257,7 +175,7 @@ export default function RegisterPage() {
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Back to sign in
Quay lại đăng nhập
</Link>
</div>
</form>
@@ -270,16 +188,7 @@ export default function RegisterPage() {
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="mb-2 text-sm text-blue-800">
<i className="fa-solid fa-circle-info mr-2"></i>
{otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Sending OTP to <strong>{phone}</strong>...
</>
) : (
<>
OTP code sent to <strong>{phone}</strong>
</>
)}
OTP đã đưc gửi đến số <strong>{phone}</strong>
</p>
<p className="text-xs text-blue-600">
Demo OTP:{" "}
@@ -288,12 +197,6 @@ export default function RegisterPage() {
</code>
</p>
</div>
{otpSendError && (
<p className="flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{otpSendError}
</p>
)}
{/* OTP Input */}
<div>
@@ -301,7 +204,7 @@ export default function RegisterPage() {
htmlFor="otp"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
OTP code
OTP
</label>
<div className="relative">
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
@@ -313,10 +216,9 @@ export default function RegisterPage() {
setOtp(e.target.value);
setErrors({ ...errors, otp: "" });
}}
placeholder="Enter OTP code"
placeholder="Nhập mã OTP"
maxLength={6}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
/>
</div>
{errors.otp && (
@@ -335,16 +237,8 @@ export default function RegisterPage() {
type="submit"
style="login"
size="lg"
disabled={isLoading || otpSending}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Complete registration"
)}
Hoàn tất đăng
</Button>
{/* Back Button */}
@@ -353,28 +247,19 @@ export default function RegisterPage() {
onClick={handleBackToPhone}
size="lg"
style="login"
disabled={isLoading}
>
Change phone number
Thay đi số điện thoại
</Button>
</div>
{/* Resend OTP */}
{/* Resend OTP (disabled in demo) */}
<div className="text-center">
<button
type="button"
onClick={sendOtp}
disabled={otpSending || isLoading}
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
disabled
className="cursor-not-allowed text-sm text-(--color-text-muted)"
>
{otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Resending...
</>
) : (
"Resend OTP code"
)}
Gửi lại OTP (60s)
</button>
</div>
</form>
+435
View File
@@ -0,0 +1,435 @@
"use client";
import {
BarChart,
LineChart,
PieChart,
ProductTable,
SummaryCard,
} from "@/components/organisms/analytics";
import type { PieSlice } from "@/components/organisms/analytics";
import {
calcChange,
formatCurrency,
formatCurrencyFull,
} from "@/lib/analytics-utils";
import {
MENU_CATEGORIES,
MOCK_PRODUCT_SALES,
MOCK_REVENUE_DAILY,
MOCK_REVENUE_MONTHLY,
MOCK_REVENUE_WEEKLY,
MOCK_REVENUE_YEARLY,
} from "@/lib/constants";
import type { AnalyticsPeriod, RevenueDataPoint } from "@/lib/types";
import Link from "next/link";
import { useMemo, useState } from "react";
// ─── Constants ────────────────────────────────────────────────────────────────
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
day: "Theo ngày",
week: "Theo tuần",
month: "Theo tháng",
year: "Theo năm",
};
const CATEGORY_COLORS = [
"#6F4E37",
"#C8973A",
"#A0785A",
"#8B6914",
"#D4A96A",
"#4A3728",
"#F0D9A8",
"#A08060",
"#3D2B1F",
];
const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = {
day: MOCK_REVENUE_DAILY,
week: MOCK_REVENUE_WEEKLY,
month: MOCK_REVENUE_MONTHLY,
year: MOCK_REVENUE_YEARLY,
};
const CHART_TYPES = ["line", "bar", "pie"] as const;
type ChartType = (typeof CHART_TYPES)[number];
const CHART_META: Record<ChartType, { icon: string; label: string }> = {
line: { icon: "fa-chart-line", label: "Line" },
bar: { icon: "fa-chart-bar", label: "Bar" },
pie: { icon: "fa-chart-pie", label: "Pie" },
};
// ─── Category filter select ───────────────────────────────────────────────────
function CategorySelect({
value,
onChange,
label = "Danh mục:",
}: {
value: string;
onChange: (v: string) => void;
label?: string;
}) {
const categories = MENU_CATEGORIES.filter((c) => c.id !== "all");
return (
<div className="flex items-center gap-2">
<label className="text-xs text-(--color-text-muted)">{label}</label>
<select
title="Danh mục"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
>
<option value="all">Tất cả</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function AnalyticsPage() {
const [period, setPeriod] = useState<AnalyticsPeriod>("month");
const [activeChart, setActiveChart] = useState<ChartType>("line");
const [categoryFilter, setCategoryFilter] = useState("all");
// Revenue data for selected period
const revenueData = REVENUE_MAP[period];
// Split into halves for bar comparison
const half = Math.floor(revenueData.length / 2);
const barCurrent = revenueData.slice(half);
const barPrevious = revenueData.slice(0, half).slice(0, barCurrent.length);
// Filtered product sales
const filteredSales = useMemo(
() =>
categoryFilter === "all"
? MOCK_PRODUCT_SALES
: MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter),
[categoryFilter],
);
// Summary stats
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
const totalProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
// Period-over-period comparisons
const curRevenue = barCurrent.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 prevOrders = barPrevious.reduce((s, d) => s + d.orders, 0);
const revComp = calcChange(curRevenue, prevRevenue);
const ordComp = calcChange(curOrders, prevOrders);
const proComp = calcChange(curRevenue * 0.65, prevRevenue * 0.65);
// Pie data: revenue by category
const pieData = useMemo((): PieSlice[] => {
const byCategory: Record<string, number> = {};
MOCK_PRODUCT_SALES.forEach((p) => {
byCategory[p.category] = (byCategory[p.category] ?? 0) + p.revenue;
});
return Object.entries(byCategory)
.map(([catId, rev], i) => ({
label: MENU_CATEGORIES.find((c) => c.id === catId)?.name ?? catId,
value: rev,
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
}))
.sort((a, b) => b.value - a.value);
}, []);
// Top 5 products
const top5 = useMemo(
() => [...filteredSales].sort((a, b) => b.revenue - a.revenue).slice(0, 5),
[filteredSales],
);
// Totals for summary row
const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0);
const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0);
const avgMargin =
filteredSales.length > 0
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) /
filteredSales.length
: 0;
return (
<div className="bg-background min-h-screen">
{/* ── Page Header ── */}
<header className="sticky top-0 z-30 border-b border-(--color-border-light) bg-(--color-bg-header) shadow-sm">
<div className="mx-auto flex max-w-screen-2xl items-center gap-4 px-4 py-3">
<Link
href="/manager"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-(--color-text-muted) transition-colors hover:bg-(--color-accent-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-arrow-left"></i>
</Link>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-accent-light) text-(--color-primary)">
<i className="fa-solid fa-chart-line"></i>
</span>
<div>
<h1 className="text-foreground text-lg leading-tight font-bold">
Thống & Phân tích tài chính
</h1>
<p className="text-xs text-(--color-text-muted)">
Financial Analytics Dashboard
</p>
</div>
</div>
{/* Period selector */}
<div className="ml-auto flex items-center gap-2">
{(Object.keys(PERIOD_LABELS) as AnalyticsPeriod[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`hidden rounded-lg px-3 py-1.5 text-xs font-medium transition-colors sm:block ${
period === p
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
}`}
>
{PERIOD_LABELS[p]}
</button>
))}
<select
title="Chọn kỳ"
value={period}
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"
>
{(
Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]
).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</div>
</div>
</header>
<main className="mx-auto max-w-screen-2xl space-y-6 p-4 pb-10">
{/* ── Summary Cards ── */}
<section>
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
Tổng quan
</h2>
<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"
value={formatCurrency(totalRevenue)}
subtitle={PERIOD_LABELS[period]}
change={revComp.change}
changePercent={revComp.changePercent}
isPositive={revComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-receipt"
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>
</section>
{/* ── Revenue Chart ── */}
<section className="bg-background rounded-2xl border border-(--color-border-light) p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
Biểu đ doanh thu
</h2>
<div className="flex gap-2">
{CHART_TYPES.map((t) => (
<button
key={t}
onClick={() => setActiveChart(t)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
activeChart === t
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
}`}
>
<i className={`fa-solid text-xs ${CHART_META[t].icon}`}></i>
<span className="hidden sm:inline">
{CHART_META[t].label}
</span>
</button>
))}
</div>
</div>
{activeChart === "line" && (
<>
<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} />
</>
)}
{activeChart === "bar" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
So sánh doanh thu nửa đu 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" && (
<>
<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} />
</>
)}
</section>
{/* ── Top 5 Products ── */}
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
Top sản phẩm bán chạy
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
/>
</div>
<div className="space-y-3">
{top5.map((p, i) => {
const pct = (p.revenue / top5[0].revenue) * 100;
return (
<div key={p.productId}>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<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}
</span>
<span className="text-foreground truncate text-sm font-medium">
{p.name}
</span>
</div>
<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="font-semibold text-(--color-primary) tabular-nums">
{formatCurrency(p.revenue)}
</span>
</div>
</div>
<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>
</div>
);
})}
</div>
</section>
{/* ── Full Product Table ── */}
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-table text-foreground mr-2"></i>
Phân tích chi tiết sản phẩm
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
label="Lọc danh mục:"
/>
</div>
<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.
</p>
<ProductTable data={filteredSales} />
{/* Summary row */}
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
<div>
<span className="text-(--color-text-muted)">
Tổng doanh thu:{" "}
</span>
<span className="font-semibold text-(--color-primary)">
{formatCurrencyFull(filteredRevenue)}
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Tổng lợi nhuận:{" "}
</span>
<span className="font-semibold text-green-600">
{formatCurrencyFull(filteredProfit)}
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Tổng sản lượng:{" "}
</span>
<span className="text-foreground font-semibold">
{filteredUnits.toLocaleString()} ly
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Biên LN trung bình:{" "}
</span>
<span className="font-semibold text-yellow-700">
{avgMargin.toFixed(1)}%
</span>
</div>
</div>
</section>
</main>
</div>
);
}
-181
View File
@@ -1,181 +0,0 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
export default function CreateStaffPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({ name: "", phone: "", password: "" });
const [errors, setErrors] = useState({
name: "",
phone: "",
password: "",
submit: "",
});
const validatePhone = (phone: string) =>
/^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const handleChange =
(field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const validate = () => {
const next = { name: "", phone: "", password: "", submit: "" };
if (!form.name.trim()) next.name = "Please enter full name";
if (!form.phone.trim()) next.phone = "Please enter phone number";
else if (!validatePhone(form.phone))
next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter password";
else if (form.password.length < 6)
next.password = "Password must be at least 6 characters";
setErrors(next);
return !next.name && !next.phone && !next.password;
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
try {
const res = await fetch("/api/Staff/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok || res.status === 201) {
router.push("/manager");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const errorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
InvalidManager: "Manager account not found",
};
setErrors({
...errors,
submit: errorMap[errorCode] ?? "An error occurred, please try again",
});
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className="fa-solid fa-user-plus text-2xl text-(--color-primary)"></i>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
Create Staff Account
</h1>
<p className="text-sm text-(--color-text-muted)">
Add a new staff member to the restaurant
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{[
{
id: "name",
label: "Full name",
icon: "fa-user",
placeholder: "John Doe",
field: "name" as const,
type: "text",
},
{
id: "phone",
label: "Phone number",
icon: "fa-phone",
placeholder: "0987654321",
field: "phone" as const,
type: "tel",
},
{
id: "password",
label: "Password",
icon: "fa-lock",
placeholder: "At least 6 characters",
field: "password" as const,
type: "password",
},
].map(({ id, label, icon, placeholder, field, type }) => (
<div key={id}>
<label
htmlFor={id}
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
{label}
</label>
<div className="relative">
<i
className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}
></i>
<input
id={id}
type={type}
value={form[field]}
onChange={handleChange(field)}
placeholder={placeholder}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 pl-10 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors[field] && (
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{errors[field]}
</p>
)}
</div>
))}
{errors.submit && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<i className="fa-solid fa-circle-exclamation mr-2"></i>
{errors.submit}
</div>
)}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Create account"
)}
</Button>
<Link
href="/manager"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
Back to Dashboard
</Link>
</div>
</form>
</div>
</div>
);
}
+78 -231
View File
@@ -1,48 +1,43 @@
"use client";
import { ProductsTab, ReviewsTab } from "@/components/organisms/manager";
import {
CategoriesTab,
CombosTab,
ProductsTab,
} from "@/components/organisms/manager";
import { useAuth } from "@/lib/auth-context";
import { useManager } from "@/lib/manager-context";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
export default function ManagerPage() {
const { user, logout } = useAuth();
const { activeTab, setActiveTab, products } = useManager();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { activeTab, setActiveTab, products, combos, categories } =
useManager();
const tabs = [
{
id: "products" as const,
label: "Menu",
label: "Thực đơn",
icon: "fa-solid fa-utensils",
count: products.length,
},
{
id: "reviews" as const,
label: "Reviews",
icon: "fa-solid fa-star",
count: null,
id: "combos" as const,
label: "Combo",
icon: "fa-solid fa-layer-group",
count: combos.length,
},
{
id: "categories" as const,
label: "Danh mục",
icon: "fa-solid fa-tags",
count: categories.length,
},
];
useEffect(() => {
function handleOutsideClick(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, []);
return (
<div className="flex min-h-screen">
{/* ── Sidebar (lg+) ── */}
{/* ── Sidebar ── */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-(--color-border-light) bg-white shadow-sm lg:flex">
<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)">
@@ -56,13 +51,13 @@ export default function ManagerPage() {
<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">
Menu Management
Quản thực đơn
</p>
{tabs.map((tab) => (
<button
key={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
? "bg-(--color-primary) text-white shadow-sm"
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
@@ -70,56 +65,42 @@ export default function ManagerPage() {
>
<i className={`${tab.icon} w-4 text-center`}></i>
<span className="flex-1 text-left">{tab.label}</span>
{tab.count !== null && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
activeTab === tab.id
? "bg-white/20 text-white"
: "bg-(--color-border-light) text-(--color-text-muted)"
}`}
>
{tab.count}
</span>
)}
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
activeTab === tab.id
? "bg-white/20 text-white"
: "bg-(--color-border-light) text-(--color-text-muted)"
}`}
>
{tab.count}
</span>
</button>
))}
<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">
Analytics
Phân tích
</p>
<Link
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>
<span className="flex-1 text-left">Finance</span>
<span className="flex-1 text-left">Tài chính</span>
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
New
Mới
</span>
</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)"
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">Shifts</span>
<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)">
New
Mới
</span>
</Link>
</div>
<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">
Staff
</p>
<Link
href="/manager/create-staff"
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-user-plus w-4 text-center"></i>
<span className="flex-1 text-left">Create Staff</span>
</Link>
</div>
</nav>
<div className="border-t border-(--color-border-light) p-3">
@@ -129,9 +110,9 @@ export default function ManagerPage() {
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Manager"}
{user?.name ?? "Quản lý"}
</p>
<p className="text-xs text-(--color-text-muted)">Store Manager</p>
<p className="text-xs text-(--color-text-muted)">Quản quán</p>
</div>
</div>
<div className="mt-1 flex gap-2 px-1">
@@ -140,14 +121,14 @@ export default function ManagerPage() {
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>
Home
Trang chủ
</Link>
<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"
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>
Logout
Đăng xuất
</button>
</div>
</div>
@@ -155,204 +136,70 @@ export default function ManagerPage() {
{/* ── Main content ── */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-40 flex items-center justify-between gap-3 border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm">
{/* Title */}
<div className="min-w-0 shrink">
<h1 className="text-foreground truncate text-lg font-bold">
{tabs.find((t) => t.id === activeTab)?.label ?? "Manager"}
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm">
<div>
<h1 className="text-foreground text-lg font-bold">
{tabs.find((t) => t.id === activeTab)?.label ?? "Quản lý"}
</h1>
<p className="truncate text-xs text-(--color-text-muted)">
<p className="text-xs text-(--color-text-muted)">
Quản {" "}
{activeTab === "products"
? "Manage menu items for your store"
: "Customer reviews for your eatery"}
? "thực đơn"
: activeTab === "combos"
? "combo"
: "danh mục"}{" "}
của quán
</p>
</div>
{/* ── Actions: hidden on lg+ (sidebar handles nav there) ── */}
<div className="flex shrink-0 items-center gap-1.5 lg:hidden">
{/* smlg: inline icon+label buttons */}
<div className="hidden items-center gap-1.5 sm:flex">
{tabs.map((tab) => (
<button
key={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 ${
activeTab === tab.id
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
}`}
>
<i className={tab.icon}></i>
<span>{tab.label}</span>
</button>
))}
<Link
href="/manager/analytics"
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
>
<i className="fa-solid fa-chart-line"></i>
<span>Finance</span>
</Link>
<Link
href="/staff/schedule"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-calendar-days"></i>
<span>Shifts</span>
</Link>
<Link
href="/manager/create-staff"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-user-plus"></i>
<span>Create Staff</span>
</Link>
<Link
href="/"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house"></i>
<span>Home</span>
</Link>
{/* Mobile tabs */}
<div className="flex items-center gap-1 lg:hidden">
{tabs.map((tab) => (
<button
onClick={logout}
className="flex cursor-pointer items-center gap-1.5 rounded-xl border border-red-200 bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
key={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 focus-visible:ring-2 focus-visible:ring-(--color-primary)/50 focus-visible:outline-none ${
activeTab === tab.id
? "bg-(--color-primary) text-white"
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
}`}
>
<i className="fa-solid fa-right-from-bracket"></i>
<span>Logout</span>
<i className={tab.icon}></i>
<span className="hidden sm:inline">{tab.label}</span>
</button>
</div>
{/* < sm: hamburger → dropdown */}
<div className="relative sm:hidden" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen((prev) => !prev)}
aria-label="Open menu"
className="flex cursor-pointer items-center justify-center rounded-xl border border-(--color-border-light) bg-transparent p-2 text-sm text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
<i
className={
dropdownOpen ? "fa-solid fa-xmark" : "fa-solid fa-bars"
}
></i>
</button>
{dropdownOpen && (
<div className="absolute top-full right-0 z-50 mt-2 w-52 overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-xl">
<div className="p-1.5">
{/* Tab buttons */}
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setDropdownOpen(false);
}}
className={`flex w-full cursor-pointer items-center gap-2.5 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition ${
activeTab === tab.id
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light)"
}`}
>
<i className={`${tab.icon} w-4 text-center`}></i>
<span className="flex-1 text-left">{tab.label}</span>
{tab.count !== null && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
activeTab === tab.id
? "bg-white/20 text-white"
: "bg-(--color-border-light) text-(--color-text-muted)"
}`}
>
{tab.count}
</span>
)}
</button>
))}
<div className="my-1.5 border-t border-(--color-border-light)"></div>
{/* Navigation links */}
<Link
href="/manager/analytics"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)"
>
<i className="fa-solid fa-chart-line w-4 text-center"></i>
<span>Finance</span>
</Link>
<Link
href="/staff/schedule"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
>
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
<span>Shifts</span>
</Link>
<Link
href="/manager/create-staff"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
>
<i className="fa-solid fa-user-plus w-4 text-center"></i>
<span>Create Staff</span>
</Link>
<Link
href="/"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
>
<i className="fa-solid fa-house w-4 text-center"></i>
<span>Home</span>
</Link>
<div className="my-1.5 border-t border-(--color-border-light)"></div>
<button
onClick={() => {
setDropdownOpen(false);
logout();
}}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
<span>Logout</span>
</button>
</div>
</div>
)}
</div>
))}
<Link
href="/manager/analytics"
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
>
<i className="fa-solid fa-chart-line"></i>
<span className="hidden sm:inline">Tài chính</span>
</Link>
</div>
{/* ── Desktop actions (lg+): sidebar handles nav, just show shortcut ── */}
{/* Desktop actions */}
<div className="hidden items-center gap-2 lg:flex">
<Link
href="/manager/analytics"
className="flex items-center gap-1.5 rounded-xl bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
>
<i className="fa-solid fa-chart-line"></i>
Financial Analytics
</Link>
<Link
href="/manager/create-staff"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary-dark)"
>
<i className="fa-solid fa-user-plus"></i>
Create Staff
Thống tài chính
</Link>
<Link
href="/"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
>
<i className="fa-solid fa-house"></i>
Home
Trang chủ
</Link>
</div>
</header>
<main className="flex-1 p-5 md:p-8">
{activeTab === "products" && <ProductsTab />}
{activeTab === "reviews" && <ReviewsTab />}
{activeTab === "combos" && <CombosTab />}
{activeTab === "categories" && <CategoriesTab />}
</main>
</div>
</div>
+176 -229
View File
@@ -7,23 +7,23 @@ import ShiftDetailModal from "@/components/organisms/shift-schedule/ShiftDetailM
import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule";
import { useAuth } from "@/lib/auth-context";
import { useShift } from "@/lib/shift-context";
import type { ShiftEntity } from "@/lib/types";
import type { ShiftSlot } from "@/lib/types";
import Link from "next/link";
import { useState } from "react";
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
"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 {
@@ -50,332 +50,279 @@ export default function StaffSchedulePage() {
goToPrevMonth,
goToToday,
getWeeklyBudget,
shifts,
} = useShift();
const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
const [selectedShift, setSelectedShift] = useState<ShiftSlot | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createDate, setCreateDate] = useState<Date | undefined>();
const [createDate, setCreateDate] = useState<string | undefined>();
const isManager = user?.role === "manager";
const handleShiftClick = (shift: ShiftEntity) => {
const handleShiftClick = (shift: ShiftSlot) => {
setSelectedShift(shift);
setDetailOpen(true);
};
const handleCreateShift = (date: Date) => {
const handleCreateShift = (date: string) => {
setCreateDate(date);
setCreateOpen(true);
};
const handleDateSelect = (date: Date) => {
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();
// Count shifts this week for stats
const totalShiftsThisWeek = shifts.filter((s) => {
if (!s.date) return false;
const d =
s.date instanceof Date ? s.date : new Date(s.date as unknown as string);
return d >= monday && d <= sunday;
}).length;
return (
<div className="flex min-h-screen bg-gray-50/50">
{/* ── Sidebar (Desktop) ─────────────────────────────────────────────────── */}
<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 — gradient accent */}
<div className="bg-gradient-to-br from-(--color-primary) to-(--color-primary-dark) px-5 py-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20 shadow-sm backdrop-blur-sm">
<i className="fa-solid fa-calendar-days text-base text-white"></i>
</div>
<div>
<p className="text-sm font-bold text-white">Work Schedule</p>
<p className="text-xs text-white/70">
{isManager ? "Manager View" : "Staff View"}
</p>
</div>
{/* 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>
<nav className="flex-1 space-y-0.5 overflow-y-auto p-3">
{/* View toggle */}
<p className="mb-1 px-3 pt-2 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
View
{/* 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)/10 text-(--color-primary) shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-gray-50 hover:text-(--color-primary-dark)"
? "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 ${view === "week" ? "text-(--color-primary)" : ""}`}
></i>
<span className="flex-1 text-left">Weekly</span>
{view === "week" && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)"></span>
)}
<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)/10 text-(--color-primary) shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-gray-50 hover:text-(--color-primary-dark)"
? "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 ${view === "month" ? "text-(--color-primary)" : ""}`}
></i>
<span className="flex-1 text-left">Monthly</span>
{view === "month" && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)"></span>
)}
<i className="fa-solid fa-calendar w-4 text-center"></i>
<span className="flex-1 text-left">Theo tháng</span>
</button>
{/* Navigation */}
<div className="mt-2 pt-2">
<p className="mb-1 px-3 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
Navigation
{/* 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="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:bg-gray-50 hover:text-(--color-primary-dark)"
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">Today</span>
<span className="flex-1 text-left">Hôm nay</span>
</button>
</div>
{/* Manager link */}
{isManager && (
<div className="mt-2 pt-2">
<p className="mb-1 px-3 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
Manager
<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
</p>
<Link
href="/manager"
className="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:bg-gray-50 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)"
>
<i className="fa-solid fa-store w-4 text-center"></i>
<span className="flex-1 text-left">Dashboard</span>
<i className="fa-solid fa-arrow-right text-xs opacity-40"></i>
</Link>
{/* Create shift button */}
<button
type="button"
onClick={() => {
setCreateDate(undefined);
setCreateOpen(true);
}}
className="mt-1 flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-(--color-primary)/10 px-3 py-2.5 text-sm font-semibold text-(--color-primary) transition-all hover:bg-(--color-primary)/20"
>
<i className="fa-solid fa-plus w-4 text-center"></i>
<span className="flex-1 text-left">Create shift</span>
</button>
</div>
)}
{/* Stats cards */}
<div className="mt-3 space-y-2 border-t border-(--color-border-light) pt-3">
{isManager && (
<div className="rounded-xl bg-gradient-to-br from-(--color-primary)/10 to-(--color-primary)/5 p-3">
<p className="text-[10px] font-bold tracking-wider text-(--color-text-muted) uppercase">
Weekly budget
</p>
<p className="mt-1 text-xl font-bold text-(--color-primary)">
{weeklyBudget >= 1_000_000
? `${(weeklyBudget / 1_000_000).toFixed(1)}M`
: weeklyBudget.toLocaleString("vi-VN")}
</p>
<p className="text-[10px] text-(--color-text-muted)">VND</p>
</div>
)}
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-bold tracking-wider text-(--color-text-muted) uppercase">
Shifts this week
{/* 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-xl font-bold text-(--color-text-secondary)">
{totalShiftsThisWeek}
<p className="mt-1 text-lg font-bold text-(--color-primary)">
{weeklyBudget.toLocaleString("vi-VN")}
</p>
<p className="text-[10px] text-(--color-text-muted)">shifts</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 bg-gray-50 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-primary)/15">
<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 ?? "Staff"}
{user?.name ?? "Nhân viên"}
</p>
<p className="text-xs text-(--color-text-muted)">
{isManager ? "Quản lý" : "Nhân viên"}
</p>
<span
className={`inline-block rounded-full px-2 py-px text-[10px] font-semibold ${
isManager
? "bg-(--color-primary)/10 text-(--color-primary)"
: "bg-gray-100 text-(--color-text-muted)"
}`}
>
{isManager ? "Manager" : "Staff"}
</span>
</div>
</div>
<div className="mt-2 flex gap-2 px-1">
<div className="mt-1 flex gap-2 px-1">
<Link
href="/"
className="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 hover:bg-gray-50"
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 text-[10px]"></i>
Home
<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 text-[10px]"></i>
Logout
<i className="fa-solid fa-right-from-bracket"></i>
Đăng xuất
</button>
</div>
</div>
</aside>
{/* ── Main content ──────────────────────────────────────────────────────── */}
{/* ── Main content ── */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header */}
<header className="sticky top-0 z-40 border-b border-(--color-border-light) bg-white/95 px-4 py-3 shadow-sm backdrop-blur-sm md:px-6 md:py-4">
<div className="flex items-center justify-between gap-3">
{/* Title */}
<div>
<h1 className="text-foreground text-base font-bold md:text-lg">
{isManager ? "Manage Shifts" : "Register Shifts"}
</h1>
<p className="text-xs text-(--color-text-muted)">
{view === "week"
? `Week: ${weekLabel}`
: `Month: ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
</p>
<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 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>
<div className="flex items-center gap-2">
{/* Mobile view toggle */}
<div className="flex items-center overflow-hidden 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-semibold transition ${
view === "week"
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Week
</button>
<button
type="button"
onClick={() => setView("month")}
className={`cursor-pointer rounded-r-xl border-none px-3 py-2 text-xs font-semibold transition ${
view === "month"
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary)"
}`}
>
Month
</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>
{/* Desktop navigation arrows */}
<div className="hidden items-center gap-1 md:flex">
<button
title="Previous"
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-white text-(--color-text-muted) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
>
<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-white px-3 py-1.5 text-xs font-semibold text-(--color-text-secondary) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
>
Today
</button>
<button
title="Next"
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-white text-(--color-text-muted) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
>
<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>
)}
{/* Create shift (manager, desktop) */}
{isManager && (
<button
type="button"
onClick={() => {
setCreateDate(undefined);
setCreateOpen(true);
}}
className="hidden cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:opacity-90 md:flex"
>
<i className="fa-solid fa-plus"></i>
Create shift
</button>
)}
{/* Mobile navigation arrows */}
<div className="flex items-center gap-1 md:hidden">
<button
title="Previous"
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="Next"
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>
{/* 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 */}
{/* Desktop views */}
<div className="hidden md:block">
{view === "week" ? (
<WeeklySchedule
@@ -390,7 +337,7 @@ export default function StaffSchedulePage() {
)}
</div>
{/* Mobile */}
{/* Mobile view */}
<div className="md:hidden">
{view === "week" ? (
<WeeklySchedule
@@ -403,16 +350,16 @@ export default function StaffSchedulePage() {
)}
</div>
{/* FAB for mobile manager */}
{/* Mobile FAB for manager */}
{isManager && (
<button
title="Create shift"
title="Tạo ca"
type="button"
onClick={() => {
setCreateDate(undefined);
setCreateOpen(true);
}}
className="fixed right-4 bottom-6 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-xl transition hover:opacity-90 md:hidden"
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>
+109 -18
View File
@@ -24,12 +24,16 @@ app/
│ ├── layout.tsx # Feed layout
│ └── feed/
│ └── page.tsx # Trang khám phá quán (/feed)
── (manager)/
├── layout.tsx # Manager layout - auth guard + ManagerProvider
└── manager/
├── page.tsx # Manager Dashboard - quản lý thực đơn (/manager)
└── analytics/
└── page.tsx # Financial Analytics (/manager/analytics)
── (manager)/
├── layout.tsx # Manager layout - auth guard + ManagerProvider
└── manager/
├── page.tsx # Manager Dashboard - quản lý thực đơn (/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:**
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
@@ -251,27 +255,46 @@ summary aside with payment actions.
- Empty state message when cart is empty
- Horizontal scroll on small screens (min-w-190)
- **Invoice Aside:**
- **PaymentSummaryCard (Invoice Aside):**
- Sticky on desktop (top offset = header height + 1rem)
- Shows total price
- Payment buttons: Tiền mặt, QR Code (UI-only; trigger review modal for
customers)
- Shows all cart items with subtotals and grand total
- Payment method buttons: Tiền mặt (Cash), QR Code
- **"Đánh giá" button** — only visible when `user.role === "customer"`; opens
ReviewModal
- **"Quay về" button** — links back to `/`; spans full width when review
button is absent
- **Review Modal (ReviewModal):**
- Opened when a customer clicks "Đánh giá" OR clicks a payment button (Tiền
mặt/QR)
- Closed via "Quay lại" button, backdrop click, or after submitting and
closing
#### Payment Modal Flow
- **Cash Payment (CashPaymentModal):**
- Opened when user clicks "Tiền mặt"
- 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
#### Context Usage
- **useCart()** — items, totalPrice, increaseQty, decreaseQty, removeFromCart,
setQuantity
setQuantity, clearCart
- **useAuth()** — user (to check `user.role === "customer"` for review button
visibility)
@@ -279,6 +302,9 @@ summary aside with payment actions.
```tsx
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"
```
@@ -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
- [x] Payment page implementation
- [x] Customer review modal (ReviewModal) with 5-star rating + textarea
- [x] Manager dashboard (menu management)
- [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
- [ ] User profile page
- [ ] Cart checkout flow
- [ ] Real backend API integration
+4 -1
View File
@@ -2,6 +2,7 @@
import { AuthProvider } from "@/lib/auth-context";
import { CartProvider } from "@/lib/cart-context";
import { MenuProvider } from "@/lib/menu-context";
/**
* Client-side providers wrapper.
@@ -11,7 +12,9 @@ import { CartProvider } from "@/lib/cart-context";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<CartProvider>{children}</CartProvider>
<MenuProvider>
<CartProvider>{children}</CartProvider>
</MenuProvider>
</AuthProvider>
);
}
+180 -7
View File
@@ -7,14 +7,31 @@
```
components/
├── atoms/ # Basic building blocks (Button, Input, Text, Badge...)
├── molecules/ # Groups of atoms (ProductCard, SearchBar...)
├── organisms/ # Complex UI sections (CategorySidebar, CartFab, ProductGrid, ReviewModal, analytics charts...)
└── templates/ # Page layout structures (MainLayout, AuthLayout...)
├── atoms/ # Basic building blocks — 14 atoms (Button, Input, Text, Badge...) — see atoms/ATOMS.md
├── molecules/ # Groups of atoms — 5 molecules (ProductCard, ShopCard, ShiftCard, SearchBar, PaymentSummaryCard)
├── organisms/ # Complex UI sections — 27 organisms (analytics, cart, forms, manager, modals, navigation, grids, shift-schedule)
└── 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
### ProductCard (`molecules/cards/ProductCard.tsx`)
@@ -25,16 +42,58 @@ parent grid (w-full).
**Atomic level:** Molecule (composed of Button, Caption, Text atoms)
---
### ShopCard (`molecules/cards/ShopCard.tsx`)
**Description:** Shop discovery card. Displays shop image, name, address, and a
link to the menu.
**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`)
**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
---
## ORGANISMS
@@ -44,25 +103,35 @@ and onChange come from parent.
**Description:** Left sidebar with collapsible category filter. Sticky below
header, full viewport height minus header.
---
### CartFab (`organisms/cart/CartFab.tsx`)
**Description:** Floating Action Button displaying cart item count. Links to
/payment page.
---
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
**Description:** Full product grid organism with mobile category tabs,
filtering, and empty state. Uses useCart and useMenu contexts.
---
### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`)
**Description:** Responsive grid of ShopCard molecules for the feed/discovery
page.
### 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/`)
@@ -81,6 +150,101 @@ dashboard. All components are interactive with hover tooltips.
---
### 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
### MainLayout (`templates/main-layout/MainLayout.tsx`)
@@ -102,6 +266,11 @@ login/register pages.
**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.
---
## Contexts Documentation
@@ -125,3 +294,7 @@ mobile menu and CategorySidebar selection.
Manages menu CRUD state (products, combos, categories) for the manager
dashboard.
---
**Last Updated:** 2026-04-18
+131 -35
View File
@@ -1,7 +1,7 @@
# Atoms Components Library
**Status:**Phase 1 Implementation Complete **Created:** 2026-04-03
**Foundation:** Atomic Design Pattern
**Status:**Complete — 14 atoms **Created:** 2026-04-03 **Foundation:**
Atomic Design Pattern
---
@@ -22,7 +22,7 @@ potentially other atoms).
---
## 🎯 Atoms Created
## 🎯 Atoms Created (14 total)
### Buttons (`buttons/`)
@@ -181,6 +181,58 @@ import { Textarea } from "@/components/atoms";
---
#### **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/`)
#### **Heading.tsx**
@@ -346,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
All atoms use CSS variables for consistent theming:
@@ -378,7 +474,8 @@ Change one variable in `globals.css` to update all atoms across the app.
import { Badge, PriceBadge } from "@/components/atoms/badges";
import { Button } from "@/components/atoms/buttons";
import { Divider } from "@/components/atoms/dividers";
import { SearchInput, TextInput } from "@/components/atoms/inputs";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import { LoginInput, SearchInput, TextInput } from "@/components/atoms/inputs";
import { Caption, Heading, Text } from "@/components/atoms/typography";
```
@@ -400,6 +497,9 @@ import {
} 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
```tsx
@@ -483,50 +583,46 @@ All atoms include:
```
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/
│ ├── Badge.tsx
│ ├── Badge.types.ts
│ ├── PriceBadge.tsx
│ └── index.ts
├── buttons/
│ ├── Button.tsx
│ ├── Button.types.ts
│ ├── IconButton.tsx
│ └── index.ts
├── dividers/
│ ├── Divider.tsx
│ └── 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)
└── ATOMS.md (this file)
```
---
## 🚀 Next Steps
## 🚀 Status
Once atoms are comfortable, the next phase is creating **Molecules** -
combinations of atoms:
- **ProductCard** - Image + Text + Badge + Button
- **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.
Atoms are stable and in use across molecules and organisms. Molecules are
implemented — see `components/COMPONENTS.md` for details. See
`OPTIMIZATION_PLAN.md` for the full roadmap.
---
@@ -540,5 +636,5 @@ See `OPTIMIZATION_PLAN.md` for the full roadmap.
---
**Status:** ✅ Complete & Ready for Use **Last Updated:** 2026-04-03 **Phase:**
1 of 5
**Status:** ✅ Complete & Ready for Use **Last Updated:** 2026-04-18 **Atoms:**
14
+31
View File
@@ -0,0 +1,31 @@
import type { BadgeProps } from "./Badge.types";
export default function Badge({
variant = "primary",
size = "md",
children,
className = "",
...props
}: BadgeProps) {
const variants = {
primary: "bg-(--color-primary) text-white",
secondary: "bg-(--color-border-light) text-(--color-text-secondary)",
success: "bg-green-100 text-green-700",
danger: "bg-red-100 text-red-700",
warning: "bg-yellow-100 text-yellow-700",
};
const sizes = {
sm: "px-2 py-1 text-xs",
md: "px-3 py-1.5 text-sm",
};
return (
<span
className={`inline-flex items-center justify-center rounded-full font-semibold ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</span>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { HTMLAttributes } from "react";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: "primary" | "secondary" | "success" | "danger" | "warning";
size?: "sm" | "md";
children: React.ReactNode;
}
+31
View File
@@ -0,0 +1,31 @@
import { HTMLAttributes } from "react";
export interface PriceBadgeProps extends HTMLAttributes<HTMLSpanElement> {
price: number;
currency?: string;
className?: string;
}
export default function PriceBadge({
price,
currency = "VND",
className = "",
...props
}: PriceBadgeProps) {
const formattedPrice =
currency === "VND"
? price.toLocaleString("vi-VN", {
style: "currency",
currency: "VND",
})
: `${price.toFixed(2)} ${currency}`;
return (
<span
className={`text-sm font-bold text-(--color-primary) ${className}`}
{...props}
>
{formattedPrice}
</span>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { default as Badge } from "./Badge";
export { default as PriceBadge } from "./PriceBadge";
export type { BadgeProps } from "./Badge.types";
export type { PriceBadgeProps } from "./PriceBadge";
+46
View File
@@ -0,0 +1,46 @@
"use client";
import type { ButtonProps } from "./Button.types";
export default function IconButton({
variant = "primary",
size = "md",
icon,
disabled = false,
className = "",
children,
style: _style,
...props
}: ButtonProps) {
const baseStyles =
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary:
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
secondary:
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
primaryNoBorder:
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
bgWhite:
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
};
const sizes = {
sm: "h-8 w-8 text-sm",
md: "h-10 w-10 text-base",
lg: "h-12 w-12 text-lg",
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
</button>
);
}
+6 -2
View File
@@ -3,13 +3,17 @@ export { Button } from "./buttons";
export type { ButtonProps } from "./buttons";
// Inputs
export { TextInput, Textarea } from "./inputs";
export type { TextInputProps, TextareaProps } from "./inputs";
export { TextInput, SearchInput, Textarea } from "./inputs";
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs";
// Typography
export { Heading, Text, Caption } from "./typography";
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
// Badges
export { Badge, PriceBadge } from "./badges";
export type { BadgeProps } from "./badges";
// Dividers
export { Divider } from "./dividers";
export type { DividerProps } from "./dividers";
+14
View File
@@ -13,3 +13,17 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
error?: string;
className?: string;
}
export interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
onClear?: () => void;
className?: string;
}
export interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
type: string;
name: string;
value: string;
errors?: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from "react";
import { LoginInputProps } from "./Input.types";
export default function LoginInput({
label,
type,
name,
value,
errors,
onChange,
...restProps
}: LoginInputProps) {
const [showPassword, setShowPassword] = useState(false);
function isPassword() {
if (type === "password") {
return (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
aria-label={showPassword ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
>
<i
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
></i>
</button>
);
}
return "";
}
return (
<div>
<label
htmlFor={name}
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
{label}
</label>
<div className="relative">
<i className="fa-solid fa-user absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id={name}
type={showPassword ? "text" : type}
value={value}
onChange={onChange}
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)"} `}
/>
{isPassword()}
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import type { SearchInputProps } from "./Input.types";
export default function SearchInput({
value,
onChange,
onClear,
className = "",
...props
}: SearchInputProps) {
return (
<div className="relative w-full">
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
<input
type="text"
value={value}
onChange={onChange}
className={`w-full rounded-lg border border-(--color-border) bg-transparent py-2 pr-9 pl-9 text-sm transition-all duration-150 placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20 focus:outline-none ${className}`}
{...props}
/>
{value && onClear && (
<button
title="Xóa"
type="button"
onClick={onClear}
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>
</button>
)}
</div>
);
}
+1
View File
@@ -24,6 +24,7 @@ export default function TextInput({
/>
{icon && (
<button
title="Thao tác biểu tượng"
type="button"
onClick={onIconClick}
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
+7 -1
View File
@@ -1,3 +1,9 @@
export { default as TextInput } from "./TextInput";
export { default as SearchInput } from "./SearchInput";
export { default as Textarea } from "./Textarea";
export type { TextInputProps, TextareaProps } from "./Input.types";
export type {
TextInputProps,
SearchInputProps,
TextareaProps,
LoginInputProps,
} from "./Input.types";
+5 -3
View File
@@ -1,3 +1,6 @@
import type { CartItem } from "@/lib/cart-context";
import type { Product, UserRole } from "@/lib/types";
export interface ProductCardProps {
image: string;
imageAlt?: string;
@@ -16,9 +19,8 @@ export interface ShopCardProps {
}
export interface PaymentSummaryCardProps {
cartItems: CartItem[];
totalPrice: number;
isCustomer: boolean;
role: UserRole;
backHref: string;
eateryId?: string;
onPay?: () => void;
}
@@ -1,126 +1,116 @@
import { formatPrice } from "@/app/(main)/payment/page";
"use client";
import Button from "@/components/atoms/buttons/Button";
import { ReviewModal } from "@/components/organisms/modals";
import { cartClient } from "@/lib/apollo-clients";
import {
CashPaymentModal,
PaymentSuccessModal,
QRPaymentModal,
} from "@/components/organisms/modals";
import type { InvoiceItem } from "@/components/organisms/modals";
import { useCart } from "@/lib/cart-context";
import { gql } from "@apollo/client";
import { useMutation } from "@apollo/client/react";
import Link from "next/link";
import { useState } from "react";
import type { PaymentSummaryCardProps } from "./Card.types";
type ModalState = "none" | "cash" | "qr" | "success";
const formatPrice = (value: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
export default function PaymentSummaryCard({
cartItems,
totalPrice,
isCustomer = false,
role,
backHref,
eateryId,
}: PaymentSummaryCardProps) {
const { cart,removeCart } = useCart();
const [isReviewOpen, setIsReviewOpen] = useState(false);
const [isQrModalOpen, setIsQrModalOpen] = useState(false);
const handlePayment = () => {
if (isCustomer) {
setIsReviewOpen(true);
}
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");
};
return (
<aside className="shrink-0 xl:w-85">
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
<h2 className="mb-4 text-lg font-bold">Bill</h2>
<h2 className="mb-4 text-lg font-bold">Hóa đơn</h2>
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
<span className="text-(--color-text-muted)">Total</span>
<span className="text-(--color-text-muted)">Tổng cộng</span>
<span className="text-xl font-bold text-(--color-primary)">
{formatPrice(totalPrice)}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button
style="payment"
onClick={handlePayment}
icon="fa-solid fa-money-bill-wave"
size="md"
variant="primary"
>
Cash
</Button>
{!isCustomer && (
<Button
style="payment"
onClick={() => setOpenModal("cash")}
icon="fa-solid fa-money-bill-wave"
size="md"
variant="primary"
>
Tiền mặt
</Button>
)}
<Button
style="payment"
onClick={() => {
setIsQrModalOpen(true);
}}
onClick={() => setOpenModal("qr")}
icon="fa-solid fa-qrcode"
size="md"
variant="secondary"
variant={isCustomer ? "primary" : "secondary"}
className={isCustomer ? "col-span-2" : ""}
>
QR Code
</Button>
<Link href={backHref || "/"} className={"col-span-2"}>
<Link href={backHref || "/"} className="col-span-2">
<Button
style="payment"
onClick={() => setIsReviewOpen(false)}
icon="fa-solid fa-arrow-rotate-left"
size="md"
variant="secondary"
className="w-full"
>
Return
Quay về
</Button>
</Link>
</div>
</div>
<ReviewModal
isOpen={isReviewOpen}
onClose={() => {
setIsReviewOpen(false);
removeCart();
}}
eateryId={eateryId}
<CashPaymentModal
isOpen={openModal === "cash"}
onClose={() => setOpenModal("none")}
onConfirm={handlePaymentConfirm}
items={invoiceItems}
totalAmount={totalPrice}
/>
{isQrModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="animate-in fade-in zoom-in w-full max-w-sm rounded-3xl bg-white p-6 text-center shadow-2xl duration-300">
<h2 className="mb-4 text-xl font-bold">Scan to Pay</h2>
<QRPaymentModal
isOpen={openModal === "qr"}
onClose={() => setOpenModal("none")}
onConfirm={handlePaymentConfirm}
items={invoiceItems}
totalAmount={totalPrice}
/>
{cart.paymentQrUrl ? (
<div className="mb-4 rounded-2xl bg-gray-100 p-4">
<img
src={cart.paymentQrUrl}
alt="Payment QR Code"
className="mx-auto h-auto w-full"
/>
</div>
) : (
<div className="p-10 text-gray-400">QR Code not available</div>
)}
<p className="mb-6 text-sm text-gray-500">
Total:{" "}
<span className="font-bold text-black">
{formatPrice(totalPrice)}
</span>
</p>
<Button
onClick={() => {
setIsQrModalOpen(false);
setIsReviewOpen(true);
}}
variant="secondary"
className="w-full"
>
Close
</Button>
</div>
</div>
)}
<PaymentSuccessModal
isOpen={openModal === "success"}
onClose={handleSuccessClose}
/>
</aside>
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ export default function ProductCard({
</div>
{/* Product image */}
<Image
src={image || "/"}
src={image}
alt={imageAlt}
fill
className="z-1 object-cover"
@@ -69,7 +69,7 @@ export default function ProductCard({
icon="fa-cart-plus"
aria-label={`Mua ${productName}`}
>
Buy
Mua
</Button>
</div>
</div>
+84 -87
View File
@@ -1,74 +1,62 @@
"use client";
import type { ShiftSlot } from "@/lib/types";
import type { ShiftCardProps } from "./ShiftCard.types";
function formatWage(wage: number): string {
if (wage >= 1_000_000) return `${(wage / 1_000_000).toFixed(1)}M`;
if (wage >= 1000) return `${(wage / 1000).toFixed(0)}k`;
return wage.toLocaleString("vi-VN");
}
function getShiftPeriod(
startTime: string,
): "morning" | "afternoon" | "evening" {
const h = parseInt(startTime.split(":")[0] ?? "0", 10);
if (h < 12) return "morning";
if (h < 18) return "afternoon";
return "evening";
}
const PERIOD_STYLES = {
morning: {
border: "border-l-indigo-500",
text: "text-indigo-700",
badge: "bg-indigo-100 text-indigo-700",
dot: "bg-indigo-400",
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",
},
afternoon: {
border: "border-l-amber-500",
text: "text-amber-700",
badge: "bg-amber-100 text-amber-700",
dot: "bg-amber-400",
registered: {
bg: "bg-blue-100 border-blue-400",
text: "text-blue-900",
label: "Đã đăng ký",
},
evening: {
border: "border-l-violet-500",
text: "text-violet-700",
badge: "bg-violet-100 text-violet-700",
dot: "bg-violet-400",
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 registeredCount = shift.registeredStaff?.length ?? 0;
const period = getShiftPeriod(shift.startTime);
const s = PERIOD_STYLES[period];
const style = STATUS_STYLES[shift.status];
if (compact) {
return (
<button
type="button"
onClick={() => onClick?.(shift)}
className={`group w-full cursor-pointer rounded-xl border-l-[3px] bg-white text-left shadow-sm transition hover:-translate-y-px hover:shadow-md ${s.border}`}
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}`}
>
<div className="px-2.5 py-2">
<p className={`text-xs leading-tight font-bold ${s.text}`}>
{shift.startTime}{shift.endTime}
</p>
<div className="mt-1 flex items-center justify-between gap-1">
<p className="text-[10px] text-(--color-text-muted)">
{formatWage(shift.wage ?? 0)}đ
</p>
<span
className={`rounded-full px-1.5 py-px text-[9px] font-semibold ${s.badge}`}
>
{registeredCount}/{shift.maxStaff}
</span>
</div>
</div>
<p className="font-semibold">
{shift.startTime} {shift.endTime}
</p>
<p className="mt-0.5 opacity-75">
{shift.durationHours}h · {formatWage(shift.wage)}
</p>
</button>
);
}
@@ -77,46 +65,55 @@ export default function ShiftCard({
<button
type="button"
onClick={() => onClick?.(shift)}
className={`group w-full cursor-pointer rounded-2xl border-l-[4px] bg-white text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg ${s.border}`}
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md ${style.bg} ${style.text}`}
>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<p className={`text-base font-bold ${s.text}`}>
{shift.startTime} {shift.endTime}
</p>
</div>
<p className="mt-1 text-xs text-(--color-text-muted)">
{(shift.wage ?? 0).toLocaleString("vi-VN")} VND/ca
</p>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${s.badge}`}
>
{registeredCount}/{shift.maxStaff} staff
</span>
<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>
{registeredCount > 0 && (
<div className="mt-3 border-t border-gray-100 pt-3">
<p className="mb-2 text-[10px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Registered staff
</p>
<div className="flex flex-wrap gap-1.5">
{shift.registeredStaff!.map((staff) => (
<span
key={staff.id}
className="rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] font-medium text-(--color-text-secondary)"
>
{staff.staffId}
</span>
))}
</div>
</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>
);
}
@@ -1,7 +1,7 @@
import type { ShiftEntity } from "@/lib/types";
import type { ShiftSlot } from "@/lib/types";
export interface ShiftCardProps {
shift: ShiftEntity;
shift: ShiftSlot;
compact?: boolean;
onClick?: (shift: ShiftEntity) => void;
onClick?: (shift: ShiftSlot) => void;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function ShopCard({ name, address, image }: ShopCardProps) {
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-book-open text-[10px]"></i>
View menu
Xem menu
</Link>
</div>
@@ -6,7 +6,7 @@ export default function SearchBar({
value,
onChange,
onClear,
placeholder = "Search...",
placeholder = "Tìm kiếm...",
className = "",
}: SearchBarProps) {
return (
@@ -17,14 +17,14 @@ export default function SearchBar({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label="Search items"
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"
/>
{value && (
<button
onClick={onClear}
title="Clear search"
aria-label="Clear search"
title="Xóa tìm kiếm"
aria-label="Xóa tìm kiếm"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { formatCurrency } from "@/lib/analytics-utils";
import type { RevenueDataPoint } from "@/lib/types";
import { useState } from "react";
interface BarChartProps {
current: RevenueDataPoint[];
previous: RevenueDataPoint[];
height?: number;
}
/**
* Pure-SVG grouped bar chart comparing current vs previous period revenue.
* Hover bars show tooltip with label, revenue, and order count.
* Tooltip auto-flips above/below bar top to stay inside the viewBox.
*/
export function BarChart({ current, previous, height = 200 }: BarChartProps) {
const [hovered, setHovered] = useState<{
set: "cur" | "prev";
idx: number;
} | null>(null);
const W = 800;
const H = height;
const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const n = current.length;
const maxVal =
Math.max(
...current.map((d) => d.revenue),
...previous.map((d) => d.revenue),
) || 1;
const groupW = chartW / n;
const barW = groupW * 0.35;
const gap = groupW * 0.05;
const yTicks = 5;
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
val: (maxVal / yTicks) * (yTicks - i),
y: padT + (i / yTicks) * chartH,
}));
const step = Math.ceil(n / 8);
return (
<div className="relative w-full overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ height: H, minWidth: 320 }}
onMouseLeave={() => setHovered(null)}
>
{gridLines.map((g, i) => (
<g key={i}>
<line
x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
</g>
))}
{current.map((d, i) => {
const groupX = padL + i * groupW;
const curH = (d.revenue / maxVal) * chartH;
const prevH = ((previous[i]?.revenue ?? 0) / maxVal) * chartH;
const curX = groupX + gap;
const prevX = curX + barW + gap;
const isHovCur = hovered?.set === "cur" && hovered.idx === i;
const isHovPrev = hovered?.set === "prev" && hovered.idx === i;
return (
<g key={i}>
<rect
x={prevX}
y={padT + chartH - prevH}
width={barW}
height={prevH}
rx="3"
fill={isHovPrev ? "#A0785A" : "#E2C9A8"}
style={{ cursor: "pointer", transition: "fill 150ms" }}
onMouseEnter={() => setHovered({ set: "prev", idx: i })}
/>
<rect
x={curX}
y={padT + chartH - curH}
width={barW}
height={curH}
rx="3"
fill={isHovCur ? "#4A3728" : "#6F4E37"}
style={{ cursor: "pointer", transition: "fill 150ms" }}
onMouseEnter={() => setHovered({ set: "cur", idx: i })}
/>
{i % step === 0 && (
<text
x={groupX + groupW / 2}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{d.label}
</text>
)}
</g>
);
})}
{hovered !== null &&
(() => {
const d =
hovered.set === "cur"
? current[hovered.idx]
: previous[hovered.idx];
if (!d) return null;
const groupX = padL + hovered.idx * groupW;
const tipW = 130,
tipH = 50;
const tipX = Math.min(
Math.max(groupX - tipW / 2, padL),
W - padR - tipW,
);
const barH = (d.revenue / maxVal) * chartH;
const barTopY = padT + chartH - barH;
const aboveY = barTopY - tipH - 8;
const tipY = Math.min(
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 */}
<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>
<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>
</div>
);
}
@@ -0,0 +1,191 @@
"use client";
import { formatCurrency } from "@/lib/analytics-utils";
import type { RevenueDataPoint } from "@/lib/types";
import { useState } from "react";
interface LineChartProps {
data: RevenueDataPoint[];
height?: number;
}
/**
* Pure-SVG interactive line chart for revenue over time.
* Hover dots show tooltip with label, revenue, and order count.
* Tooltip auto-flips above/below the dot to stay inside the viewBox.
*/
export function LineChart({ data, height = 200 }: LineChartProps) {
const [hovered, setHovered] = useState<number | null>(null);
const W = 800;
const H = height;
const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const maxRev = Math.max(...data.map((d) => d.revenue));
const range = maxRev || 1;
const points = data.map((d, i) => ({
x: padL + (i / (data.length - 1)) * chartW,
y: padT + chartH - (d.revenue / range) * chartH,
data: d,
index: i,
}));
const pathD = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(" ");
const areaD =
pathD +
` L ${points[points.length - 1].x.toFixed(1)} ${(padT + chartH).toFixed(1)}` +
` L ${points[0].x.toFixed(1)} ${(padT + chartH).toFixed(1)} Z`;
const yTicks = 5;
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
val: (range / yTicks) * (yTicks - i),
y: padT + (i / yTicks) * chartH,
}));
const step = Math.ceil(data.length / 10);
return (
<div className="relative w-full overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ height: H, minWidth: 320 }}
onMouseLeave={() => setHovered(null)}
>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6F4E37" stopOpacity="0.25" />
<stop offset="100%" stopColor="#6F4E37" stopOpacity="0.02" />
</linearGradient>
</defs>
{gridLines.map((g, i) => (
<g key={i}>
<line
x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
</g>
))}
<path d={areaD} fill="url(#areaGrad)" />
<path
d={pathD}
fill="none"
stroke="#6F4E37"
strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map((p, i) =>
i % step === 0 ? (
<text
key={i}
x={p.x}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.label}
</text>
) : null,
)}
{points.map((p) => (
<circle
key={p.index}
cx={p.x}
cy={p.y}
r={hovered === p.index ? 5 : 3}
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
stroke="#FDF6EC"
strokeWidth="2"
style={{ cursor: "pointer", transition: "r 150ms" }}
onMouseEnter={() => setHovered(p.index)}
/>
))}
{hovered !== null &&
(() => {
const p = points[hovered];
const tipW = 120,
tipH = 48;
const tipX = Math.min(
Math.max(p.x - tipW / 2, padL),
W - padR - tipW,
);
const aboveY = p.y - tipH - 10;
const tipY = Math.min(
Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
padT + chartH - tipH,
);
return (
<g>
<rect
x={tipX}
y={tipY}
width={tipW}
height={tipH}
rx="6"
fill="#3D2B1F"
opacity="0.92"
/>
<text
x={tipX + tipW / 2}
y={tipY + 16}
textAnchor="middle"
fontSize="10"
fill="#F0D9A8"
>
{p.data.label}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 30}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="#C8973A"
>
{formatCurrency(p.data.revenue)}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 44}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.orders} đơn
</text>
</g>
);
})()}
</svg>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useMemo, useState } from "react";
export interface PieSlice {
label: string;
value: number;
color: string;
}
interface PieChartProps {
data: PieSlice[];
}
/**
* Pure-SVG interactive pie chart.
* Hover a slice or legend item to highlight it and show its percentage.
*/
export function PieChart({ data }: PieChartProps) {
const [hovered, setHovered] = useState<number | null>(null);
const R = 80;
const CX = 110;
const CY = 110;
const total = data.reduce((s, d) => s + d.value, 0) || 1;
const slices = useMemo(() => {
type Acc = { items: ReturnType<typeof makeSlice>[]; angle: number };
const makeSlice = (d: PieSlice, i: number, startAngle: number) => {
const angle = (d.value / total) * 2 * Math.PI;
const endAngle = startAngle + angle;
const midAngle = startAngle + angle / 2;
const x1 = CX + R * Math.cos(startAngle);
const y1 = CY + R * Math.sin(startAngle);
const x2 = CX + R * Math.cos(endAngle);
const y2 = CY + R * Math.sin(endAngle);
const largeArc = angle > Math.PI ? 1 : 0;
const pathD = `M ${CX} ${CY} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${R} ${R} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z`;
return {
...d,
pathD,
labelX: CX + R * 0.65 * Math.cos(midAngle),
labelY: CY + R * 0.65 * Math.sin(midAngle),
percent: (d.value / total) * 100,
index: i,
endAngle,
};
};
const { items } = data.reduce<Acc>(
(acc, d, i) => {
const slice = makeSlice(d, i, acc.angle);
return { items: [...acc.items, slice], angle: slice.endAngle };
},
{ items: [], angle: -Math.PI / 2 },
);
return items;
}, [data, total]);
return (
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<svg
viewBox="0 0 220 220"
className="w-full max-w-55 shrink-0"
style={{ height: 220 }}
onMouseLeave={() => setHovered(null)}
>
{slices.map((s) => (
<path
key={s.index}
d={s.pathD}
fill={s.color}
stroke="#FDF6EC"
strokeWidth="2"
style={{ cursor: "pointer", transition: "opacity 200ms" }}
onMouseEnter={() => setHovered(s.index)}
opacity={hovered !== null && hovered !== s.index ? 0.65 : 1}
/>
))}
{hovered !== null && (
<text
x={CX}
y={CY + 5}
textAnchor="middle"
fontSize="12"
fontWeight="bold"
fill="#3D2B1F"
>
{slices[hovered].percent.toFixed(1)}%
</text>
)}
</svg>
{/* Legend */}
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-col">
{slices.map((s) => (
<div
key={s.index}
className="flex cursor-pointer items-center gap-2 text-sm"
onMouseEnter={() => setHovered(s.index)}
onMouseLeave={() => setHovered(null)}
>
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: s.color }}
/>
<span
className="max-w-35 truncate"
style={{
color: hovered === s.index ? "#3D2B1F" : "#6F4E37",
fontWeight: hovered === s.index ? 600 : 400,
}}
>
{s.label}
</span>
<span className="text-xs text-(--color-text-muted)">
{s.percent.toFixed(1)}%
</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,145 @@
"use client";
import { formatCurrencyFull } from "@/lib/analytics-utils";
import { MENU_CATEGORIES } from "@/lib/constants";
import type { ProductSalesStats } from "@/lib/types";
import { useMemo, useState } from "react";
interface ProductTableProps {
data: ProductSalesStats[];
}
const categoryName = (id: string) =>
MENU_CATEGORIES.find((c) => c.id === id)?.name ?? id;
/**
* Sortable product sales table.
* Click column headers to sort ascending/descending.
*/
export function ProductTable({ data }: ProductTableProps) {
const [sortKey, setSortKey] = useState<keyof ProductSalesStats>("revenue");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const sorted = useMemo(
() =>
[...data].sort((a, b) => {
const av = a[sortKey] as number;
const bv = b[sortKey] as number;
return sortDir === "desc" ? bv - av : av - bv;
}),
[data, sortKey, sortDir],
);
const handleSort = (key: keyof ProductSalesStats) => {
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
else {
setSortKey(key);
setSortDir("desc");
}
};
const sortIcon = (col: keyof ProductSalesStats) => (
<i
className={`fa-solid ml-1 text-xs ${
sortKey === col
? 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;
}) => (
<th
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
onClick={() => handleSort(col)}
>
{label} {sortIcon(col)}
</th>
);
return (
<div className="overflow-x-auto rounded-xl border border-(--color-border-light)">
<table className="w-full min-w-175 text-sm">
<thead>
<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)">
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="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)">
Giá bán
</th>
<SortTh col="profit" label="Lợi nhuận" className="text-right" />
<SortTh col="profitMargin" label="Biên LN" className="text-right" />
</tr>
</thead>
<tbody>
{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"
>
<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="px-4 py-3">
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)">
{categoryName(row.category)}
</span>
</td>
<td className="text-foreground px-4 py-3 text-right tabular-nums">
{row.unitsSold.toLocaleString()}
</td>
<td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">
{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">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
row.profitMargin >= 70
? "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)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,61 @@
import { formatCurrency } from "@/lib/analytics-utils";
export interface SummaryCardProps {
icon: string;
title: string;
value: string;
change: number;
changePercent: number;
isPositive: boolean;
subtitle?: string;
}
/**
* Summary metric card with period-over-period comparison indicator.
* Used in the Financial Analytics dashboard header row.
*/
export function SummaryCard({
icon,
title,
value,
change,
changePercent,
isPositive,
subtitle,
}: SummaryCardProps) {
return (
<div className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
<div className="mb-3 flex items-center gap-3">
<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>
</span>
<span className="text-sm font-medium text-(--color-text-muted)">
{title}
</span>
</div>
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
{subtitle && (
<p className="mt-0.5 text-xs text-(--color-text-muted)">{subtitle}</p>
)}
<div
className={`mt-3 flex items-center gap-1.5 text-sm font-medium ${
isPositive ? "text-green-600" : "text-red-500"
}`}
>
<i
className={`fa-solid text-xs ${
isPositive ? "fa-arrow-trend-up" : "fa-arrow-trend-down"
}`}
></i>
<span>
{isPositive ? "+" : ""}
{changePercent.toFixed(1)}%
</span>
<span className="text-xs font-normal text-(--color-text-muted)">
({isPositive ? "+" : ""}
{formatCurrency(change)}) so với kỳ trước
</span>
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export { BarChart } from "./BarChart";
export { LineChart } from "./LineChart";
export { PieChart } from "./PieChart";
export type { PieSlice } from "./PieChart";
export { ProductTable } from "./ProductTable";
export { SummaryCard } from "./SummaryCard";
export type { SummaryCardProps } from "./SummaryCard";
+74 -83
View File
@@ -1,131 +1,122 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import LoginInput from "@/components/atoms/inputs/LoginInput";
import { useAuth } from "@/lib/auth-context";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
export default function LoginForm() {
const router = useRouter();
const { login } = useAuth();
const [phone, setPhone] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ phone: "", general: "" });
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState({
username: "",
password: "",
general: "",
});
const validate = (): boolean => {
if (!phone.trim()) {
setErrors({ phone: "Please enter your phone number", general: "" });
return false;
const newErrors = { username: "", password: "", general: "" };
let isValid = true;
if (!username.trim()) {
newErrors.username = "Vui lòng nhập tên đăng nhập";
isValid = false;
}
if (!PHONE_REGEX.test(phone)) {
setErrors({
phone: "Invalid phone number (e.g. 0987654321)",
general: "",
});
return false;
if (!password.trim()) {
newErrors.password = "Vui lòng nhập mật khẩu";
isValid = false;
} else if (password.length < 4) {
newErrors.password = "Mật khẩu phải có ít nhất 4 ký tự";
isValid = false;
}
return true;
setErrors(newErrors);
return isValid;
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
setErrors({ phone: "", general: "" });
const success = login(username, password);
try {
const res = await fetch("/api/sms_otp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
if (success) {
router.push("/");
} else {
setErrors({
username: "",
password: "",
general: "Tên đăng nhập hoặc mật khẩu không đúng",
});
const role = (await res.text().catch(() => "")).trim().toLowerCase();
if (
res.ok &&
(role === "customer" || role === "manager" || role === "staff")
) {
sessionStorage.setItem("login_phone", phone);
sessionStorage.setItem("login_role", role);
router.push(role === "customer" ? "/login/otp" : "/login/password");
} else if (res.status === 404) {
setErrors({ phone: "", general: "Phone number not registered" });
} else {
setErrors({
phone: "",
general: "An error occurred, please try again",
});
}
} catch {
setErrors({ phone: "", general: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
return (
<div>
{/* Error Message */}
{errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
htmlFor="phone"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Phone number
</label>
<div className="relative">
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id="phone"
type="tel"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
setErrors({ phone: "", general: "" });
}}
placeholder="0987654321"
disabled={isLoading}
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) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors.phone && (
<ErrorMessageLogin message={errors.phone} type="secondary" />
{/* Username Input */}
<LoginInput
label="Tên đăng nhập"
type="text"
name="username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setErrors({ ...errors, username: "", general: "" });
}}
errors={errors.username}
/>
{errors.username && (
<ErrorMessageLogin message={errors.username} type="secondary" />
)}
<p className="mt-2 text-xs text-(--color-text-muted)">
Enter a Vietnamese phone number (10 digits, starting with 0)
</p>
</div>
{/* Password Input */}
<div>
<LoginInput
label="Mật khẩu"
type="password"
name="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({ ...errors, password: "", general: "" });
}}
errors={errors.password}
/>
{errors.password && (
<ErrorMessageLogin message={errors.password} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2">
{/* Login Button */}
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Continue"
)}
Đăng nhập
</Button>
{/* Register Button */}
<Link
href="/register"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Create account
Đăng tài khoản
</Link>
</div>
</form>
+7 -1
View File
@@ -15,7 +15,7 @@ export { ReviewModal } from "./modals";
export type { ReviewModalProps, ConfirmModalProps } from "./modals";
// Shop Grid
// export { ShopGrid } from "./shop-grid";
export { ShopGrid } from "./shop-grid";
export type { ShopGridProps } from "./shop-grid";
// Manager
@@ -23,10 +23,16 @@ export {
StatusBadge,
DeleteConfirm,
ProductModal,
CategoryModal,
ComboModal,
ProductsTab,
CategoriesTab,
CombosTab,
} from "./manager";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
} from "./manager";
@@ -0,0 +1,110 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import CategoryModal from "./CategoryModal";
import DeleteConfirm from "./DeleteConfirm";
export default function CategoriesTab() {
const { categories, products, addCategory, updateCategory, deleteCategory } =
useManager();
const [modalCategory, setModalCategory] = useState<
MenuCategory | null | "new"
>(null);
const [deleteTarget, setDeleteTarget] = useState<MenuCategory | null>(null);
const getProductCount = (catId: string) =>
products.filter((p) => p.category === catId).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{categories.length}</strong> danh
mục
</p>
<button
onClick={() => setModalCategory("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm danh mục</span>
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{categories.map((cat) => {
const count = getProductCount(cat.id);
return (
<div
key={cat.id}
className="group relative flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-4 shadow-sm transition hover:shadow-md"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-(--color-accent-light)">
<i className={`${cat.icon} text-xl text-(--color-primary)`}></i>
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate font-semibold">
{cat.name}
</p>
<p className="text-xs text-(--color-text-muted)">{count} món</p>
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => setModalCategory(cat)}
title="Chỉnh sửa"
className="flex h-7 w-7 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-[11px]"></i>
</button>
<button
onClick={() => setDeleteTarget(cat)}
title="Xóa"
className="flex h-7 w-7 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-[11px]"></i>
</button>
</div>
</div>
);
})}
{categories.length === 0 && (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
<p className="text-sm">Chưa danh mục nào</p>
</div>
)}
</div>
{modalCategory !== null && (
<CategoryModal
category={modalCategory === "new" ? null : modalCategory}
onSave={(data) => {
if ("id" in data) {
updateCategory(data as MenuCategory);
} else {
addCategory(data);
}
setModalCategory(null);
}}
onClose={() => setModalCategory(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCategory(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import type { CategoryModalProps } from "./Manager.types";
const FA_ICONS = [
"fa-solid fa-mug-hot",
"fa-solid fa-leaf",
"fa-solid fa-jar",
"fa-solid fa-blender",
"fa-solid fa-mug-saucer",
"fa-solid fa-ice-cream",
"fa-solid fa-layer-group",
"fa-solid fa-burger",
"fa-solid fa-pizza-slice",
"fa-solid fa-bowl-food",
"fa-solid fa-candy-cane",
"fa-solid fa-cookie",
"fa-solid fa-cake-candles",
"fa-solid fa-drumstick-bite",
"fa-solid fa-fish",
"fa-solid fa-carrot",
];
export default function CategoryModal({
category,
onSave,
onClose,
}: CategoryModalProps) {
const isEdit = category !== null;
const [form, setForm] = useState<Omit<MenuCategory, "id">>({
name: category?.name ?? "",
icon: category?.icon ?? FA_ICONS[0],
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEdit && category) {
onSave({ ...form, id: category.id });
} else {
onSave(form);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Chỉnh sửa danh mục" : "Thêm danh mục mới"}
</h2>
<button
title="Close"
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-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Tên danh mục <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
placeholder="Ví dụ: Cà Phê"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Icon
</label>
<div className="grid grid-cols-8 gap-2">
{FA_ICONS.map((icon) => (
<button
key={icon}
type="button"
onClick={() => setForm({ ...form, icon })}
title={icon}
className={`flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition ${
form.icon === icon
? "border-(--color-primary) bg-(--color-primary) text-white"
: "bg-background border-(--color-border-light) text-(--color-text-secondary) hover:border-(--color-primary-light) hover:text-(--color-primary)"
}`}
>
<i className={`${icon} text-sm`}></i>
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<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:bg-(--color-primary-dark) active:scale-95"
>
{isEdit ? "Lưu thay đổi" : "Thêm danh mục"}
</button>
</div>
</form>
</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import type { Combo, Product } from "@/lib/types";
import { useState } from "react";
import type { ComboModalProps } from "./Manager.types";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function ComboModal({
combo,
products,
onSave,
onClose,
}: ComboModalProps) {
const isEdit = combo !== null;
const [form, setForm] = useState<Omit<Combo, "id">>({
name: combo?.name ?? "",
description: combo?.description ?? "",
price: combo?.price ?? 0,
image: combo?.image ?? "/imgs/products/placeholder.jpg",
items: combo?.items ?? [],
available: combo?.available ?? true,
});
const updateItemQty = (productId: number, qty: number) => {
if (qty <= 0) {
setForm((prev) => ({
...prev,
items: prev.items.filter((i) => i.productId !== productId),
}));
} else {
setForm((prev) => {
const existing = prev.items.find((i) => i.productId === productId);
if (existing) {
return {
...prev,
items: prev.items.map((i) =>
i.productId === productId ? { ...i, quantity: qty } : i,
),
};
}
return {
...prev,
items: [...prev.items, { productId, quantity: qty }],
};
});
}
};
const getQty = (productId: number) =>
form.items.find((i) => i.productId === productId)?.quantity ?? 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (form.items.length === 0) return;
if (isEdit && combo) {
onSave({ ...form, id: combo.id });
} else {
onSave(form);
}
};
const inputCls =
"w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="flex max-h-[90vh] w-full max-w-xl flex-col rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Chỉnh sửa combo" : "Thêm combo mới"}
</h2>
<button
title="Close"
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-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Tên combo <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="Ví dụ: Combo Cà Phê Đôi"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá combo (đ) <span className="text-red-500">*</span>
</label>
<input
title="Giá combo"
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả
</label>
<textarea
title="Mô tả combo"
rows={2}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className={`${inputCls} resize-none`}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Món trong combo{" "}
{form.items.length === 0 && (
<span className="text-xs text-red-500">
(Chọn ít nhất 1 món)
</span>
)}
</label>
<div className="bg-background max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-(--color-border-light) p-2">
{products.map((p) => {
const qty = getQty(p.id);
return (
<div
key={p.id}
className="flex items-center justify-between rounded-lg bg-white px-3 py-2 text-sm"
>
<span className="text-foreground flex-1 truncate">
{p.name}
</span>
<span className="mr-3 text-xs text-(--color-text-muted)">
{formatPrice(p.price)}
</span>
<div className="flex items-center gap-1">
<button
title="Giảm"
type="button"
onClick={() => updateItemQty(p.id, qty - 1)}
disabled={qty === 0}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary) disabled:cursor-not-allowed disabled:opacity-40"
>
<i className="fa-solid fa-minus"></i>
</button>
<span className="text-foreground w-5 text-center text-sm font-semibold">
{qty}
</span>
<button
title="Tăng"
type="button"
onClick={() => updateItemQty(p.id, qty + 1)}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus"></i>
</button>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
<div>
<p className="text-foreground text-sm font-medium">
Trạng thái
</p>
<p className="text-xs text-(--color-text-muted)">
{form.available ? "Còn hàng" : "Tạm hết"}
</p>
</div>
<button
title="Chuyển đổi trạng thái"
type="button"
onClick={() => setForm({ ...form, available: !form.available })}
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
form.available ? "bg-(--color-primary)" : "bg-gray-300"
}`}
>
<span
className={`absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
form.available ? "translate-x-5.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
<div className="flex gap-3 border-t border-(--color-border-light) px-6 py-4">
<button
type="button"
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<button
type="submit"
disabled={form.items.length === 0}
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:bg-(--color-primary-dark) active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{isEdit ? "Lưu thay đổi" : "Thêm combo"}
</button>
</div>
</form>
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { Combo } from "@/lib/types";
import { useState } from "react";
import ComboModal from "./ComboModal";
import DeleteConfirm from "./DeleteConfirm";
import StatusBadge from "./StatusBadge";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function CombosTab() {
const {
combos,
products,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
} = useManager();
const [modalCombo, setModalCombo] = useState<Combo | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
const getProductName = (id: number) =>
products.find((p) => p.id === id)?.name ?? `Món #${id}`;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{combos.length}</strong> combo
</p>
<button
onClick={() => setModalCombo("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm combo</span>
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{combos.length === 0 ? (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
<i className="fa-solid fa-layer-group text-4xl opacity-30"></i>
<p className="text-sm">Chưa combo nào</p>
</div>
) : (
combos.map((combo) => (
<div
key={combo.id}
className="flex flex-col rounded-2xl border border-(--color-border-light) bg-white shadow-sm transition hover:shadow-md"
>
<div className="flex items-start justify-between p-4">
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate font-semibold">
{combo.name}
</h3>
{combo.description && (
<p className="mt-1 line-clamp-2 text-xs text-(--color-text-muted)">
{combo.description}
</p>
)}
</div>
<button
onClick={() => toggleComboAvailability(combo.id)}
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
title="Đổi trạng thái"
>
<StatusBadge available={combo.available} />
</button>
</div>
<div className="bg-background mx-4 mb-3 rounded-xl px-3 py-2">
<p className="mb-1 text-[11px] font-semibold tracking-wide text-(--color-text-muted) uppercase">
Bao gồm
</p>
<ul className="space-y-0.5">
{combo.items.map((item) => (
<li
key={item.productId}
className="flex items-center justify-between text-xs text-(--color-text-secondary)"
>
<span>{getProductName(item.productId)}</span>
<span className="font-medium">×{item.quantity}</span>
</li>
))}
</ul>
</div>
<div className="flex items-center justify-between border-t border-(--color-border-light) px-4 py-3">
<span className="text-base font-bold text-(--color-primary)">
{formatPrice(combo.price)}
</span>
<div className="flex gap-1.5">
<button
onClick={() => setModalCombo(combo)}
title="Chỉnh sửa"
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>
</button>
<button
onClick={() => setDeleteTarget(combo)}
title="Xóa"
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>
</button>
</div>
</div>
</div>
))
)}
</div>
{modalCombo !== null && (
<ComboModal
combo={modalCombo === "new" ? null : modalCombo}
products={products}
onSave={(data) => {
if ("id" in data) {
updateCombo(data as Combo);
} else {
addCombo(data);
}
setModalCombo(null);
}}
onClose={() => setModalCombo(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCombo(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -17,11 +17,9 @@ export default function DeleteConfirm({
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<i className="fa-solid fa-trash-can text-xl text-red-500"></i>
</div>
<h3 className="text-foreground text-base font-bold">
Delete "{name}"?
</h3>
<h3 className="text-foreground text-base font-bold">Xóa "{name}"?</h3>
<p className="text-sm text-(--color-text-muted)">
This action cannot be undone.
Hành đng này không thể hoàn tác.
</p>
</div>
<div className="flex gap-3">
@@ -29,13 +27,13 @@ export default function DeleteConfirm({
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Cancel
Hủy
</button>
<button
onClick={onConfirm}
className="flex-1 cursor-pointer rounded-xl border-none bg-red-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-600 active:scale-95"
>
Delete
Xóa
</button>
</div>
</div>
+17 -12
View File
@@ -1,17 +1,22 @@
import type { MenuItemEntity } from "@/lib/types";
export interface ReviewEntity {
id?: string;
reviewerId: string;
eateryId: string;
rating: number;
comment?: string;
createdAt?: string;
}
import type { Combo, MenuCategory, Product } from "@/lib/types";
export interface ProductModalProps {
product: MenuItemEntity | null;
onSave: (p: MenuItemEntity) => void;
product: Product | null; // null = add mode
categories: MenuCategory[];
onSave: (p: Omit<Product, "id"> | Product) => void;
onClose: () => void;
}
export interface CategoryModalProps {
category: MenuCategory | null;
onSave: (c: Omit<MenuCategory, "id"> | MenuCategory) => void;
onClose: () => void;
}
export interface ComboModalProps {
combo: Combo | null;
products: Product[];
onSave: (c: Omit<Combo, "id"> | Combo) => void;
onClose: () => void;
}
@@ -1,256 +0,0 @@
"use client";
import { eateryClient } from "@/lib/apollo-clients";
import { useAuth } from "@/lib/auth-context";
import {
MenuItemEntity,
addMenuItemMutation,
allEateriesQuery,
} from "@/lib/types";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { useEffect, useState } from "react";
const GET_EATERY_MENU = gql`
query GetEateryMenu {
allEateries {
id
menuItems {
id
name
price
}
}
}
`;
const ADD_MENU_ITEM = gql`
mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) {
id
name
price
}
}
`;
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function MenuItemsTab() {
const { user } = useAuth();
const [menuItems, setMenuItems] = useState<MenuItemEntity[]>([]);
const {
data,
loading: gqlLoading,
error: gqlError,
} = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
client: eateryClient,
fetchPolicy: "network-only",
});
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
client: eateryClient,
});
const [showForm, setShowForm] = useState(false);
const [newName, setNewName] = useState("");
const [newPrice, setNewPrice] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
if (data?.allEateries?.[0]) {
setMenuItems(data.allEateries[0].menuItems);
}
}, [data]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newPrice) return;
setSubmitting(true);
setSubmitError(null);
try {
const { data: mutationResult } = await mutateAddMenuItem({
variables: {
menuItem: {
name: newName,
price: parseFloat(newPrice),
},
},
});
if (mutationResult?.addMenuItem) {
setMenuItems((prev) => [...prev, mutationResult.addMenuItem]);
closeForm();
}
} catch (err: any) {
console.error("Mutation Error:", err);
setSubmitError(err.message || "Không thể thêm món. Vui lòng thử lại.");
} finally {
setSubmitting(false);
}
};
const closeForm = () => {
setShowForm(false);
setSubmitError(null);
setNewName("");
setNewPrice("");
};
if (gqlLoading && menuItems.length === 0)
return <div className="py-16 text-center">Đang tải menu...</div>;
if (gqlError)
return (
<div className="py-16 text-center text-red-500">
Lỗi: {gqlError.message}
</div>
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{menuItems.length}</strong> món
trong menu
</p>
<button
onClick={() => setShowForm(true)}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm món</span>
</button>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
<table className="min-w-full divide-y divide-(--color-border-light) text-sm">
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Tên món
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Giá
</th>
</tr>
</thead>
<tbody className="divide-y divide-(--color-border-light)">
{menuItems.length === 0 ? (
<tr>
<td
colSpan={2}
className="py-12 text-center text-(--color-text-muted)"
>
<i className="fa-solid fa-bowl-food mb-2 block text-3xl opacity-30"></i>
Chưa món nào trong menu
</td>
</tr>
) : (
menuItems.map((item) => (
<tr
key={item.id}
className="hover:bg-background transition-colors"
>
<td className="px-4 py-3">
<p className="text-foreground font-medium">{item.name}</p>
</td>
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
{formatPrice(item.price)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Add Item Modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-foreground font-bold">Thêm món mới</h2>
<button
onClick={closeForm}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Tên món <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Nhập tên món..."
required
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Giá (VNĐ) <span className="text-red-500">*</span>
</label>
<input
type="number"
value={newPrice}
onChange={(e) => setNewPrice(e.target.value)}
placeholder="Nhập giá..."
required
min={0}
step={500}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
{submitError && (
<p className="text-sm text-red-500">
<i className="fa-solid fa-circle-exclamation mr-1"></i>
{submitError}
</p>
)}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={closeForm}
className="hover:bg-background flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition"
>
Hủy
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) disabled:opacity-60"
>
{submitting ? (
<>
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
Đang lưu...
</>
) : (
"Thêm món"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+141 -126
View File
@@ -1,73 +1,53 @@
"use client";
import type { MenuItemEntity } from "@/lib/types";
import { useState } from "react";
import type { Product } from "@/lib/types";
import Image from "next/image";
import { useRef, useState } from "react";
import type { ProductModalProps } from "./Manager.types";
export default function ProductModal({
product,
categories,
onSave,
onClose,
}: ProductModalProps) {
const isEdit = product !== null;
const [form, setForm] = useState<MenuItemEntity>({
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">>({
name: product?.name ?? "",
category: product?.category ?? categories[0]?.id ?? "",
price: product?.price ?? 0,
imageUrl: product?.imageUrl ?? "",
image: product?.image ?? "/imgs/products/placeholder.jpg",
description: product?.description ?? "",
available: product?.available ?? true,
});
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const inputCls =
"text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
const uploadImage = async (file: File) => {
setUploading(true);
setUploadError(null);
try {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/file", {
method: "POST",
body,
});
if (!res.ok) {
throw new Error("Image upload failed.");
}
const raw = await res.text();
let filename = "";
try {
const parsed = JSON.parse(raw.trim());
filename = parsed.filename || "";
} catch {
const match = raw.match(/"filename"\s*:\s*"([^"]+)"/i);
filename = match?.[1] || "";
}
if (!filename) {
throw new Error("No image filename received from server.");
}
setForm((prev) => ({ ...prev, imageUrl: filename }));
} catch (error: any) {
setUploadError(error?.message || "Unable to upload image.");
} finally {
setUploading(false);
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) => {
e.preventDefault();
if (form.price <= 0) {
setPriceError("Giá phải lớn hơn 0đ");
return;
}
if (isEdit && product) {
onSave({ ...form, id: product.id });
} else {
@@ -75,29 +55,22 @@ export default function ProductModal({
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
await uploadImage(file);
};
const toDisplayUrl = (filename: string) =>
filename && !filename.startsWith("/") && !filename.startsWith("http")
? `/api/file/${filename}`
: filename;
const previewUrl = toDisplayUrl(form.imageUrl);
const inputCls =
"text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Edit item" : "Add new item"}
{isEdit ? "Chỉnh sửa món" : "Thêm món mới"}
</h2>
<button
onClick={onClose}
title="Close"
title="Đóng"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
@@ -107,7 +80,7 @@ export default function ProductModal({
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Item name <span className="text-red-500">*</span>
Tên món <span className="text-red-500">*</span>
</label>
<input
required
@@ -115,71 +88,55 @@ export default function ProductModal({
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="e.g. Black Coffee"
placeholder="Ví dụ: Cà Phê Đen"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Price () <span className="text-red-500">*</span>
</label>
<input
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
placeholder="25000"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Item image
</label>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Danh mục <span className="text-red-500">*</span>
</label>
<select
required
title="Chọn danh mục"
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputCls}
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá (đ) <span className="text-red-500">*</span>
</label>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={uploading}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm disabled:opacity-60"
required
type="number"
min={1}
step={1000}
value={form.price}
onChange={(e) => {
setPriceError("");
setForm({ ...form, price: Number(e.target.value) });
}}
className={`${inputCls} ${priceError ? "border-red-400 focus:border-red-400 focus:ring-red-400/20" : ""}`}
placeholder="25000"
/>
{uploading && (
<p className="text-sm text-(--color-text-muted)">
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
Uploading image...
</p>
{priceError && (
<p className="mt-1 text-xs text-red-500">{priceError}</p>
)}
</div>
{previewUrl && (
<div className="mt-2">
<img
src={previewUrl}
alt="Item image preview"
className="h-24 w-24 rounded-lg border border-(--color-border-light) object-cover"
/>
</div>
)}
{uploadError && (
<p className="mt-1 text-sm text-red-500">
<i className="fa-solid fa-circle-exclamation mr-1"></i>
{uploadError}
</p>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Description
tả
</label>
<textarea
rows={3}
@@ -188,19 +145,78 @@ export default function ProductModal({
setForm({ ...form, description: e.target.value })
}
className={`${inputCls} resize-none`}
placeholder="Short description..."
placeholder="Mô tả ngắn về món..."
/>
</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>
<p className="text-foreground text-sm font-medium">Status</p>
<p className="text-foreground text-sm font-medium">Trạng thái</p>
<p className="text-xs text-(--color-text-muted)">
{form.available ? "In stock" : "Out of stock"}
{form.available ? "Còn hàng" : "Tạm hết"}
</p>
</div>
<button
title="Toggle status"
title="Chuyển trạng thái"
type="button"
onClick={() => setForm({ ...form, available: !form.available })}
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
@@ -221,14 +237,13 @@ export default function ProductModal({
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Cancel
Hủy
</button>
<button
type="submit"
disabled={uploading}
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:bg-(--color-primary-dark) active:scale-95 disabled:opacity-60"
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:bg-(--color-primary-dark) active:scale-95"
>
{isEdit ? "Save changes" : "Add item"}
{isEdit ? "Lưu thay đổi" : "Thêm món"}
</button>
</div>
</form>
+68 -44
View File
@@ -1,7 +1,7 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { MenuItemEntity } from "@/lib/types";
import type { Product } from "@/lib/types";
import { useState } from "react";
import DeleteConfirm from "./DeleteConfirm";
@@ -12,31 +12,28 @@ function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
function toDisplayUrl(filename: string) {
if (!filename) return "/imgs/products/placeholder.jpg";
if (filename.startsWith("/") || filename.startsWith("http")) return filename;
return `/api/file/${filename}`;
}
export default function ProductsTab() {
const {
products,
categories,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
} = useManager();
const [filterCategory, setFilterCategory] = useState("all");
const [filterStatus, setFilterStatus] = useState<
"all" | "available" | "unavailable"
>("all");
const [search, setSearch] = useState("");
const [modalProduct, setModalProduct] = useState<
MenuItemEntity | null | "new"
>(null);
const [deleteTarget, setDeleteTarget] = useState<MenuItemEntity>(null!);
const [modalProduct, setModalProduct] = useState<Product | null | "new">(
null,
);
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
const filtered = products.filter((p) => {
if (filterCategory !== "all" && p.category !== filterCategory) return false;
if (filterStatus === "available" && p.available === false) return false;
if (filterStatus === "unavailable" && p.available !== false) return false;
if (
@@ -48,6 +45,9 @@ export default function ProductsTab() {
return true;
});
const getCategoryName = (id: string) =>
categories.find((c) => c.id === id)?.name ?? id;
return (
<div className="space-y-4">
{/* Toolbar */}
@@ -59,12 +59,13 @@ export default function ProductsTab() {
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search items..."
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"
/>
{search && (
<button
title="Clear search"
title="Xóa tìm kiếm"
onClick={() => setSearch("")}
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
>
@@ -73,6 +74,21 @@ export default function ProductsTab() {
)}
</div>
<select
value={filterCategory}
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)"
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>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) =>
@@ -81,26 +97,27 @@ 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)"
title="Filter by status"
title="Lọc theo trạng thái"
aria-label="Lọc theo trạng thái"
>
<option value="all">All statuses</option>
<option value="available">In stock</option>
<option value="unavailable">Out of stock</option>
<option value="all">Tất cả trạng thái</option>
<option value="available">Còn hàng</option>
<option value="unavailable">Tạm hết</option>
</select>
<button
title="Add item"
title="Thêm món"
onClick={() => setModalProduct("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Add item</span>
<span className="hidden sm:inline">Thêm món</span>
</button>
</div>
<p className="text-sm text-(--color-text-muted)">
Showing <strong className="text-foreground">{filtered.length}</strong> /{" "}
{products.length} items
Hiển thị <strong className="text-foreground">{filtered.length}</strong>{" "}
/ {products.length} món
</p>
{/* Table */}
@@ -109,19 +126,19 @@ export default function ProductsTab() {
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Image
Tên món
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Item name
Danh mục
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Price
Giá
</th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Status
Trạng thái
</th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Actions
Thao tác
</th>
</tr>
</thead>
@@ -133,7 +150,7 @@ export default function ProductsTab() {
className="py-12 text-center text-(--color-text-muted)"
>
<i className="fa-solid fa-mug-hot mb-2 block text-3xl opacity-30"></i>
<span className="ml-3">No items found</span>
Không tìm thấy món nào
</td>
</tr>
) : (
@@ -142,30 +159,34 @@ export default function ProductsTab() {
key={p.id}
className="hover:bg-background transition-colors"
>
<td className="px-4 py-3">
<img
src={toDisplayUrl(p.imageUrl)}
alt={p.name}
className="h-10 w-10 rounded-lg border border-(--color-border-light) object-cover"
/>
</td>
<td className="px-4 py-3">
<div>
<p className="text-foreground font-medium">{p.name}</p>
{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>
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-(--color-accent-light) px-2.5 py-0.5 text-xs font-medium text-(--color-primary-dark)">
<i
className={`${categories.find((c) => c.id === p.category)?.icon ?? "fa-solid fa-tag"} text-[10px]`}
></i>
{getCategoryName(p.category)}
</span>
</td>
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
{formatPrice(p.price)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleProductAvailability(p)}
title="Click to toggle status"
onClick={() => toggleProductAvailability(p.id)}
title="Nhấn để đổi trạng thái"
className="cursor-pointer border-none bg-transparent"
>
<StatusBadge available={p.available ?? true} />
@@ -175,14 +196,16 @@ export default function ProductsTab() {
<div className="flex items-center justify-center gap-1.5">
<button
onClick={() => setModalProduct(p)}
title="Edit"
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)"
>
<i className="fa-solid fa-pen text-xs"></i>
</button>
<button
onClick={() => setDeleteTarget(p)}
title="Delete"
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"
>
<i className="fa-solid fa-trash text-xs"></i>
@@ -199,9 +222,10 @@ export default function ProductsTab() {
{modalProduct !== null && (
<ProductModal
product={modalProduct === "new" ? null : modalProduct}
onSave={(data: MenuItemEntity) => {
categories={categories}
onSave={(data) => {
if ("id" in data) {
updateProduct(data);
updateProduct(data as Product);
} else {
addProduct(data);
}
@@ -215,10 +239,10 @@ export default function ProductsTab() {
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteProduct(deleteTarget.id!);
setDeleteTarget(null!);
deleteProduct(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null!)}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
-192
View File
@@ -1,192 +0,0 @@
"use client";
import { useManager } from "@/lib/manager-context";
import { useEffect, useState } from "react";
import type { ReviewEntity } from "./Manager.types";
interface FetchState {
reviews: ReviewEntity[];
loading: boolean;
error: string | null;
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<i
key={star}
className={
star <= rating
? "fa-solid fa-star text-sm text-yellow-400"
: "fa-regular fa-star text-sm text-(--color-border)"
}
/>
))}
</div>
);
}
export default function ReviewsTab() {
const { eateryId } = useManager();
const [{ reviews, loading, error }, setState] = useState<FetchState>({
reviews: [],
loading: true,
error: null,
});
useEffect(() => {
if (!eateryId) return;
const controller = new AbortController();
fetch(`/api/eatery/review/${eateryId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Failed to load reviews: ${res.status}`);
return res.json() as Promise<ReviewEntity[]>;
})
.then((data) => setState({ reviews: data, loading: false, error: null }))
.catch((err: unknown) => {
if (err instanceof Error && err.name === "AbortError") return;
setState({
reviews: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to load reviews.",
});
});
return () => controller.abort();
}, [eateryId]);
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Loading reviews...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-20 text-red-500">
<i className="fa-solid fa-triangle-exclamation mr-2"></i>
{error}
</div>
);
}
if (reviews.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center text-(--color-text-muted)">
<i className="fa-regular fa-star mb-3 text-4xl"></i>
<p className="text-sm font-medium">No reviews yet</p>
<p className="mt-1 text-xs">Reviews from customers will appear here.</p>
</div>
);
}
const avgRating =
reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
const CustomerName = ({ customerId }: { customerId: string }) => {
const [name, setName] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Hàm gọi API
const getName = async () => {
try {
setLoading(true);
const response = await fetch(`/api/customer/${customerId}`);
if (!response.ok) {
throw new Error("Mạng lỗi hoặc không tìm thấy nhân viên");
}
const data = await response.json();
setName(data["name"]);
} catch (error) {
console.error("Lỗi khi lấy tên:", error);
setName("Lỗi tải tên");
} finally {
setLoading(false);
}
};
if (customerId) {
getName();
}
}, [customerId]); // Chạy lại nếu customerId thay đổi
if (loading)
return <span className="animate-pulse text-gray-400">...</span>;
return <span>{name || "N/A"}</span>;
};
return (
<div className="space-y-4">
{/* Summary header */}
<div className="flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-5">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-(--color-accent-light)">
<i className="fa-solid fa-star text-2xl text-yellow-400"></i>
</div>
<div>
<p className="text-foreground text-2xl font-bold">
{avgRating.toFixed(1)}
<span className="ml-1 text-sm font-normal text-(--color-text-muted)">
/ 5
</span>
</p>
<StarRating rating={Math.round(avgRating)} />
<p className="mt-0.5 text-xs text-(--color-text-muted)">
{reviews.length} review{reviews.length !== 1 ? "s" : ""}
</p>
</div>
</div>
{/* Review list */}
<div className="space-y-3">
{reviews.map((review, idx) => (
<div
key={review.id ?? idx}
className="rounded-2xl border border-(--color-border-light) bg-white p-4"
>
<div className="mb-2 flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className="fa-solid fa-user text-xs text-(--color-primary)"></i>
</div>
<span className="text-sm font-medium text-(--color-text-secondary)">
<CustomerName customerId={review.reviewerId!} />
</span>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StarRating rating={review.rating} />
{review.createdAt && (
<span className="text-xs text-(--color-text-muted)">
{new Date(review.createdAt).toLocaleDateString("vi-VN")}
</span>
)}
</div>
</div>
{review.comment ? (
<p className="text-sm leading-relaxed text-(--color-text-secondary)">
{review.comment}
</p>
) : (
<p className="text-sm text-(--color-text-muted) italic">
No comment provided.
</p>
)}
</div>
))}
</div>
</div>
);
}
+2 -2
View File
@@ -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 ${
available
? "bg-emerald-100 text-emerald-700"
: "bg-amber-100 text-amber-700"
: "bg-amber-100 text-amber-800"
}`}
>
<span
@@ -14,7 +14,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
available ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
{available ? "In stock" : "Out of stock"}
{available ? "Còn hàng" : "Tạm hết"}
</span>
);
}
+6 -3
View File
@@ -1,12 +1,15 @@
export { default as StatusBadge } from "./StatusBadge";
export { default as DeleteConfirm } from "./DeleteConfirm";
export { default as ProductModal } from "./ProductModal";
export { default as CategoryModal } from "./CategoryModal";
export { default as ComboModal } from "./ComboModal";
export { default as ProductsTab } from "./ProductsTab";
export { default as MenuItemsTab } from "./MenuItemsTab";
export { default as ReviewsTab } from "./ReviewsTab";
export { default as CategoriesTab } from "./CategoriesTab";
export { default as CombosTab } from "./CombosTab";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
ReviewEntity,
} from "./Manager.types";
@@ -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>
);
}
+27 -1
View File
@@ -1,7 +1,6 @@
export interface ReviewModalProps {
isOpen: boolean;
onClose: () => void;
eateryId?: string;
}
export interface ConfirmModalProps {
@@ -13,3 +12,30 @@ export interface ConfirmModalProps {
confirmLabel?: 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 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>
);
}
+26 -67
View File
@@ -1,68 +1,34 @@
"use client";
import { Button, Heading, Text, Textarea } from "@/components/atoms";
import { useAuth } from "@/lib/auth-context";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { ReviewModalProps } from "./Modal.types";
interface CreateReviewInput {
reviewerId: string;
eateryId: string;
rating: number;
comment?: string;
}
export default function ReviewModal({
isOpen,
onClose,
eateryId,
}: ReviewModalProps) {
const { user } = useAuth();
export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
const [rating, setRating] = useState(0);
const [hovered, setHovered] = useState(0);
const [review, setReview] = useState("");
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!submitted) return;
const timer = setTimeout(handleClose, 1000);
return () => clearTimeout(timer);
}, [submitted]);
if (!isOpen) return null;
const handleSubmit = async () => {
if (!eateryId) return;
const input: CreateReviewInput = {
reviewerId: user?.id || "",
eateryId,
rating,
...(review.trim() ? { comment: review.trim() } : {}),
};
setLoading(true);
setError(null);
try {
const res = await fetch("/api/eatery/review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit review.");
} finally {
setLoading(false);
}
const handleSubmit = () => {
setSubmitted(true);
};
const handleClose = () => {
// Reset state when closing
setRating(0);
setHovered(0);
setReview("");
setSubmitted(false);
setError(null);
onClose();
};
@@ -89,14 +55,11 @@ export default function ReviewModal({
<i className="fa-solid fa-heart text-(--color-accent)"></i>
</div>
<Heading level={2} id="review-modal-title">
Thank you
Cảm ơn quý khách
</Heading>
<Text variant="body2" className="mt-2">
We appreciate your feedback!
Chúng tôi trân trọng đánh giá của bạn!
</Text>
<Button onClick={handleClose} variant="primary" className="mt-4">
Close
</Button>
</div>
) : (
/* Review form */
@@ -105,21 +68,21 @@ export default function ReviewModal({
id="review-modal-title"
className="text-foreground mb-1 text-xl font-bold"
>
Your Review
Đánh giá của bạn
</h2>
<p className="mb-5 text-sm text-(--color-text-muted)">
Tell us about your experience today
Hãy cho chúng tôi biết trải nghiệm của bạn hôm nay
</p>
{/* Star rating */}
<div className="mb-5">
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
Satisfaction level
Mức đ hài lòng
</p>
<div
className="flex gap-2"
role="radiogroup"
aria-label="Star rating"
aria-label="Xếp hạng sao"
>
{[1, 2, 3, 4, 5].map((star) => {
const isActive = star <= (hovered || rating);
@@ -130,7 +93,7 @@ export default function ReviewModal({
onClick={() => setRating(star)}
onMouseEnter={() => setHovered(star)}
onMouseLeave={() => setHovered(0)}
aria-label={`${star} star`}
aria-label={`${star} sao`}
aria-pressed={rating === star}
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
>
@@ -138,7 +101,7 @@ export default function ReviewModal({
className={
isActive
? "fa-solid fa-star text-yellow-400"
: "fa-regular fa-star text-(--color-border)"
: "fa-regular fa-star text-gray-300"
}
></i>
</button>
@@ -148,7 +111,7 @@ export default function ReviewModal({
{rating > 0 && (
<p className="mt-1.5 text-xs text-(--color-text-muted)">
{
["", "Very poor", "Poor", "Average", "Good", "Excellent"][
["", "Rất tệ", "Tệ", "Bình thường", "Tốt", "Xuất sắc"][
rating
]
}
@@ -160,17 +123,14 @@ export default function ReviewModal({
<div className="mb-6">
<Textarea
id="review-text"
label="Comment (optional)"
label="Nhận xét (tùy chọn)"
value={review}
onChange={(e) => setReview(e.target.value)}
placeholder="Share your thoughts on the drinks, service..."
placeholder="Chia sẻ cảm nhận của bạn về đồ uống, dịch vụ..."
rows={4}
/>
</div>
{/* Error message */}
{error && <p className="mb-4 text-sm text-red-500">{error}</p>}
{/* Footer buttons */}
<div className="flex gap-3">
<Button
@@ -179,18 +139,17 @@ export default function ReviewModal({
variant="secondary"
className="flex-1"
icon="fa-arrow-left"
disabled={loading}
>
Go back
Quay lại
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={rating === 0 || loading}
disabled={rating === 0}
className="flex-1"
icon={loading ? "fa-spinner fa-spin" : "fa-check"}
icon="fa-check"
>
{loading ? "Submitting..." : "Confirm"}
Xác nhận
</Button>
</div>
</>
+5
View File
@@ -1,2 +1,7 @@
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 { CashPaymentModalProps, InvoiceItem } from "./Modal.types";
export type { QRPaymentModalProps } from "./Modal.types";
@@ -1,6 +1,7 @@
"use client";
import { SHOP_INFO } from "@/lib/constants";
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
import type { MenuCategory } from "@/lib/types";
import type React from "react";
import type { CategorySidebarProps } from "./Navigation.types";
@@ -17,6 +18,8 @@ import type { CategorySidebarProps } from "./Navigation.types";
export default function CategorySidebar({
isOpen,
onToggle,
activeCategory = "all",
onCategoryChange,
}: CategorySidebarProps) {
return (
<aside
@@ -37,14 +40,14 @@ export default function CategorySidebar({
className={`text-xs font-bold tracking-widest whitespace-nowrap text-(--color-text-muted) uppercase ${isOpen ? "block" : "hidden"} xl:block`}
>
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
Menu
Thực Đơn
</span>
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
<button
onClick={onToggle}
title={isOpen ? "Collapse menu" : "Expand menu"}
aria-label={isOpen ? "Collapse menu" : "Expand 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"
>
<i
@@ -55,6 +58,40 @@ export default function CategorySidebar({
</button>
</div>
{/* ── Category list ── */}
<nav className="flex-1 py-2">
<ul className="flex flex-col gap-0.5 px-2">
{MENU_CATEGORIES.map((cat: MenuCategory) => {
const isActive = activeCategory === cat.id;
return (
<li key={cat.id}>
<button
onClick={() => onCategoryChange?.(cat.id)}
title={!isOpen ? cat.name : undefined}
className={`flex w-full cursor-pointer items-center rounded-xl border-none text-sm font-medium transition-all duration-150 xl:justify-start xl:gap-3 xl:px-3 xl:py-2.5 ${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"} ${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
} `}
>
{/* Icon */}
<i
className={` ${cat.icon} w-5 shrink-0 text-center text-base ${isActive ? "text-white" : "text-(--color-primary)"} `}
></i>
{/* Label — hidden when collapsed, always shown on xl+ */}
<span
className={`overflow-hidden text-ellipsis whitespace-nowrap ${isOpen ? "block" : "hidden"} xl:block`}
>
{cat.name}
</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* ── Sidebar footer: opening hours ── */}
<div
className={`shrink-0 border-t border-(--color-border) py-3 xl:px-4 ${isOpen ? "px-4" : "flex justify-center px-0"} `}
@@ -2,54 +2,77 @@
import { ProductCard } from "@/components/molecules/cards";
import { useCart } from "@/lib/cart-context";
import { useManager } from "@/lib/manager-context";
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import type { ProductGridProps } from "./ProductGrid.types";
function toDisplayUrl(filename: string) {
if (!filename) return "/imgs/products/placeholder.jpg";
if (filename.startsWith("/") || filename.startsWith("http")) return filename;
return `/api/file/${filename}`;
}
export default function ProductGrid({
searchQuery = "",
isSidebarOpen = false,
}: ProductGridProps) {
const { activeCategory, setActiveCategory } = useMenu();
const { addToCart } = useCart();
const { products } = useManager();
const filteredProducts = products.filter((p) => {
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
const isAvailable = p.available !== false;
const matchesCategory =
activeCategory === "all" || p.category === activeCategory;
const matchesSearch =
searchQuery.trim() === "" ||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
return isAvailable && matchesSearch;
return isAvailable && matchesCategory && matchesSearch;
});
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
const gridCols = isSidebarOpen
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
return (
<>
{/* ── Mobile category menu — visible only on < md ── */}
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
<div className="flex items-center gap-1.5 pb-1">
{MENU_CATEGORIES.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
} `}
>
<i
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
></i>
<span>{cat.name}</span>
</button>
);
})}
</div>
</div>
{/* ── Product grid ── */}
{filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map(
({ id, imageUrl, name, price, description }) => (
<ProductCard
key={id}
image={toDisplayUrl(imageUrl)}
imageAlt={name}
productName={name}
price={price}
description={description}
onBuy={() => addToCart({ productId: id!, quantity: 1 })}
/>
),
)}
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
image={product.image}
imageAlt={product.name}
productName={product.name}
price={product.price}
description={product.description}
onBuy={() => addToCart(product)}
/>
))}
</div>
) : (
/* Empty state */
@@ -1,28 +1,15 @@
"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 = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
function formatDateKey(d: Date): string {
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");
@@ -31,15 +18,35 @@ function formatDateKey(d: Date): string {
function isToday(d: Date): boolean {
const today = new Date(2026, 3, 10);
return formatDateKey(d) === formatDateKey(today);
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, getShiftsForDate, goToNextMonth, goToPrevMonth } =
useShift();
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
const [selectedDate, setSelectedDate] = useState<string>(
formatDateISO(new Date(2026, 3, 10)),
);
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
@@ -52,137 +59,186 @@ export default function MobileShiftView({
const days: (Date | null)[] = [];
for (let i = 0; i < startOffset; i++) days.push(null);
for (let d = 1; d <= lastDay.getDate(); d++)
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 selectedShifts = useMemo(
() => getShiftsForDate(selectedDate),
[selectedDate, getShiftsForDate],
);
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 dayOfWeek = DAY_HEADERS[(selectedDate.getDay() + 6) % 7];
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">
{/* Month calendar card */}
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
{/* Compact month calendar */}
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
{/* Month navigation */}
<div className="flex items-center justify-between border-b border-(--color-border-light) bg-gradient-to-r from-(--color-primary)/8 to-(--color-primary)/3 px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<button
title="Previous month"
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-white/70 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<h3 className="text-sm font-bold text-(--color-primary-dark)">
<h3 className="text-foreground text-sm font-bold">
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Next month"
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-white/70 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
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="p-3">
{/* Day headers */}
<div className="mb-1 grid grid-cols-7">
{DAY_HEADERS.map((day, i) => (
<div
key={day}
className={`py-1 text-center text-[10px] font-bold uppercase ${
i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"
{/* 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" : ""
}`}
>
{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 today = isToday(date);
const selected =
formatDateKey(date) === formatDateKey(selectedDate);
const dayShifts = getShiftsForDate(date);
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<button
type="button"
key={i}
onClick={() => setSelectedDate(date)}
className="flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition"
<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"
}`}
>
<span
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition ${
today
? "bg-(--color-primary) text-white shadow-sm"
: selected
? "bg-(--color-primary)/15 text-(--color-primary) ring-2 ring-(--color-primary)/30"
: isWeekend
? "text-rose-500"
: "text-(--color-text-secondary)"
}`}
>
{date.getDate()}
</span>
<div className="mt-0.5 flex min-h-2 items-center justify-center gap-px">
{dayShifts.length > 0 && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)/60" />
)}
</div>
</button>
);
})}
{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>
<div className="mb-3 flex items-center justify-between px-1">
<h3 className="text-sm font-bold text-(--color-primary-dark)">
{dayOfWeek}, {selectedDate.getDate()}/{selectedDate.getMonth() + 1}/
{selectedDate.getFullYear()}
</h3>
<span
className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
selectedShifts.length > 0
? "bg-(--color-primary)/10 text-(--color-primary)"
: "bg-gray-100 text-(--color-text-muted)"
}`}
>
{selectedShifts.length} shifts
<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>
</div>
</h3>
{selectedShifts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-(--color-border-light) py-10 text-center">
<i className="fa-regular fa-calendar-xmark mb-3 text-3xl text-gray-200"></i>
<p className="text-sm font-medium text-(--color-text-muted)">
No shifts
</p>
<p className="mt-0.5 text-xs text-(--color-text-muted)">
No shifts created for this day
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
<p className="text-sm text-(--color-text-muted)">
Không ca làm trong ngày này
</p>
</div>
) : (
<div className="space-y-2.5">
{selectedShifts.map((shift) => (
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
))}
<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>
@@ -5,9 +5,9 @@ import { useMemo } from "react";
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
export function formatDateISO(d: Date): string {
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");
@@ -27,7 +27,7 @@ export default function MonthlyCalendar({
onShiftClick,
onDateSelect,
}: MonthlyCalendarProps) {
const { currentDate, getShiftsForDate } = useShift();
const { currentDate, shifts } = useShift();
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
@@ -35,27 +35,50 @@ export default function MonthlyCalendar({
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)[] = [];
for (let i = 0; i < startOffset; i++) days.push(null);
for (let d = 1; d <= lastDay.getDate(); d++)
// 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));
while (days.length % 7 !== 0) days.push(null);
}
// 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-2xl border border-(--color-border-light) shadow-sm">
<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-gradient-to-r from-(--color-primary)/8 to-(--color-primary)/3">
{DAY_HEADERS.map((day, i) => (
<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-3 text-center text-[11px] font-bold tracking-wider uppercase ${
i >= 5 ? "text-rose-400" : "text-(--color-primary)"
}`}
className="px-2 py-2.5 text-center text-xs font-semibold text-(--color-text-muted) uppercase"
>
{day}
</div>
@@ -63,60 +86,75 @@ export default function MonthlyCalendar({
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 bg-white">
<div className="grid grid-cols-7">
{calendarDays.map((date, i) => {
if (!date) {
return (
<div
key={`empty-${i}`}
className="min-h-[100px] border-r border-b border-(--color-border-light) bg-gray-50/50"
className="min-h-25 border-r border-b border-(--color-border-light) bg-gray-50/30"
/>
);
}
const summary = getShiftSummary(date);
const today = isToday(date);
const shifts = getShiftsForDate(date);
const shiftCount = shifts.length;
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<button
type="button"
key={i}
onClick={() => onDateSelect?.(date)}
className={`group min-h-[100px] cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-(--color-primary)/5 ${
today ? "bg-(--color-primary)/8" : ""
} ${isWeekend && !today ? "bg-rose-50/30" : ""}`}
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" : ""
}`}
>
{/* Date number */}
<span
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-bold transition ${
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
today
? "bg-(--color-primary) text-white shadow-sm"
: isWeekend
? "text-rose-500"
: "text-(--color-text-secondary) group-hover:text-(--color-primary)"
? "bg-(--color-primary) font-bold text-white"
: "text-foreground font-medium"
}`}
>
{date.getDate()}
</span>
{/* Shift count badge */}
{shiftCount > 0 && (
<div className="mt-1.5 space-y-1">
<span className="inline-flex items-center gap-1 rounded-full bg-(--color-primary)/10 px-1.5 py-0.5 text-[10px] font-semibold text-(--color-primary)">
<i className="fa-solid fa-clock text-[8px]"></i>
{shiftCount} shifts
</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>
)}
{/* Add indicator for manager */}
{shiftCount === 0 && onDateSelect && (
<p className="mt-1 text-[10px] text-(--color-text-muted) opacity-0 transition group-hover:opacity-100">
+ Add shift
</p>
)}
</button>
);
})}
@@ -4,71 +4,8 @@ import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context";
import { useEffect, useState } from "react";
import { formatDateISO } from "./MonthlyCalendar";
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
function TimeInput({
value,
onChange,
title,
}: {
value: string;
onChange: (v: string) => void;
title: string;
}) {
const [h24, m] = value.split(":").map(Number);
const isPM = h24 >= 12;
const hour12 = h24 === 0 ? 12 : h24 > 12 ? h24 - 12 : h24;
const emit = (newH12: number, newM: number, newIsPM: boolean) => {
let h = newIsPM
? newH12 === 12
? 12
: newH12 + 12
: newH12 === 12
? 0
: newH12;
onChange(
`${h.toString().padStart(2, "0")}:${newM.toString().padStart(2, "0")}`,
);
};
return (
<div className="flex items-center overflow-hidden rounded-xl border border-(--color-border-light) transition focus-within:border-(--color-primary) focus-within:ring-1 focus-within:ring-(--color-primary)">
<input
title={`${title} hour`}
type="number"
min={1}
max={12}
value={hour12}
onChange={(e) =>
emit(Math.min(12, Math.max(1, Number(e.target.value))), m, isPM)
}
className="text-foreground w-12 border-none bg-transparent py-2.5 pl-3 text-sm outline-none"
/>
<span className="text-sm text-(--color-text-muted) select-none">:</span>
<input
title={`${title} minute`}
type="number"
min={0}
max={59}
value={m.toString().padStart(2, "0")}
onChange={(e) =>
emit(hour12, Math.min(59, Math.max(0, Number(e.target.value))), isPM)
}
className="text-foreground w-10 border-none bg-transparent py-2.5 text-sm outline-none"
/>
<button
type="button"
onClick={() => emit(hour12, m, !isPM)}
className="mr-2 ml-1 cursor-pointer rounded-lg border-none bg-(--color-primary)/10 px-2 py-1 text-xs font-bold text-(--color-primary) transition hover:bg-(--color-primary) hover:text-white"
>
{isPM ? "PM" : "AM"}
</button>
</div>
);
}
export default function ShiftCreateModal({
isOpen,
onClose,
@@ -76,7 +13,7 @@ export default function ShiftCreateModal({
}: ShiftCreateModalProps) {
const { createShift } = useShift();
const [date, setDate] = useState<Date>(new Date(defaultDate ?? "2026-04-10"));
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");
@@ -86,7 +23,7 @@ export default function ShiftCreateModal({
useEffect(() => {
if (isOpen) {
setDate(new Date(defaultDate ?? "2026-04-10"));
setDate(defaultDate ?? "2026-04-10");
}
}, [defaultDate, isOpen]);
@@ -98,7 +35,7 @@ export default function ShiftCreateModal({
// Validate
if (!date || !startTime || !endTime) {
setError("Please fill in all required fields.");
setError("Vui lòng điền đầy đủ thông tin.");
return;
}
@@ -108,20 +45,22 @@ export default function ShiftCreateModal({
const endMinutes = eh * 60 + em;
if (endMinutes <= startMinutes) {
setError("End time must be after start time.");
setError("Giờ kết thúc phải sau giờ bắt đầu.");
return;
}
const deptName =
DEPARTMENTS.find((d) => d.id === department)?.name ?? department;
const durationHours = (endMinutes - startMinutes) / 60;
createShift({
name: `${deptName} - ${formatDateISO(date)}`,
date,
startTime,
endTime,
durationHours,
wage,
department,
maxStaff,
registeredStaff: [],
status: "available",
});
onClose();
@@ -138,10 +77,10 @@ export default function ShiftCreateModal({
<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">
Create New Shift
Tạo ca làm mới
</h2>
<p className="text-xs text-(--color-text-muted)">
Add a shift time slot for staff
Thêm khung giờ ca làm cho nhân viên
</p>
</div>
<button
@@ -159,13 +98,13 @@ export default function ShiftCreateModal({
{/* Date */}
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Date
Ngày
</label>
<input
title="Date"
type="date"
value={formatDateISO(date)}
onChange={(e) => setDate(new Date(e.target.value))}
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>
@@ -174,22 +113,26 @@ export default function ShiftCreateModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Start Time
Giờ bắt đu
</label>
<TimeInput
<input
title="Start Time"
type="time"
value={startTime}
onChange={setStartTime}
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)">
End Time
Giờ kết thúc
</label>
<TimeInput
<input
title="End Time"
type="time"
value={endTime}
onChange={setEndTime}
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>
@@ -197,7 +140,7 @@ export default function ShiftCreateModal({
{/* Department */}
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Department
Bộ phận
</label>
<select
title="Department"
@@ -217,7 +160,7 @@ export default function ShiftCreateModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Max staff
Số nhân viên tối đa
</label>
<input
title="Max Staff"
@@ -231,7 +174,7 @@ export default function ShiftCreateModal({
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Shift wage (VND)
Lương ca (VND)
</label>
<input
title="Wage"
@@ -260,14 +203,14 @@ export default function ShiftCreateModal({
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>
Create shift
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"
>
Cancel
Hủy
</button>
</div>
</form>
@@ -3,20 +3,10 @@
import { useAuth } from "@/lib/auth-context";
import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context";
import { useEffect, useState } from "react";
import { useState } from "react";
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
function parseShiftDate(date: Date | string | undefined): Date | null {
if (!date) return null;
// Date object → dùng thẳng
if (date instanceof Date) return isNaN(date.getTime()) ? null : date;
// ISO string hoặc "YYYY-MM-DD" → chỉ lấy 10 ký tự đầu tránh lệch timezone
const [y, m, d] = (date as string).slice(0, 10).split("-").map(Number);
if (!y || !m || !d) return null;
return new Date(y, m - 1, d);
}
export default function ShiftDetailModal({
shift,
isOpen,
@@ -29,24 +19,23 @@ export default function ShiftDetailModal({
if (!isOpen || !shift) return null;
// const dept = DEPARTMENTS.find((d) => d.id === shift.department);
const dept = DEPARTMENTS.find((d) => d.id === shift.department);
const isManager = user?.role === "manager";
const registeredStaff = shift.registeredStaff ?? [];
const isRegistered = user
? registeredStaff.some((s) => s.id === user.id)
? shift.registeredStaff.some((s) => s.id === user.id)
: false;
const isFull = registeredStaff.length >= shift.maxStaff;
const isFull = shift.registeredStaff.length >= shift.maxStaff;
const handleRegister = async () => {
const handleRegister = () => {
if (!user) return;
setError(null);
setSuccess(null);
const result = await registerShift(shift.id, user.id, user.name);
const result = registerShift(shift.id, user.id, user.name);
if (result.success) {
setSuccess("Shift registered successfully!");
setSuccess("Đăng ký ca thành công!");
setTimeout(onClose, 1200);
} else {
setError(result.error ?? "An error occurred.");
setError(result.error ?? "Có lỗi xảy ra.");
}
};
@@ -54,25 +43,25 @@ export default function ShiftDetailModal({
if (!user) return;
setError(null);
unregisterShift(shift.id, user.id);
setSuccess("Shift registration cancelled.");
setSuccess("Đã hủy đăng ký ca.");
setTimeout(onClose, 1200);
};
const handleManagerUnregister = (staffId: string) => {
const handleManagerUnregister = (staffId: number) => {
unregisterShift(shift.id, staffId);
setSuccess("Staff removed from shift.");
setSuccess("Đã xóa nhân viên khỏi ca.");
};
const handleDelete = async () => {
await deleteShift(shift.id);
const handleDelete = () => {
deleteShift(shift.id);
onClose();
};
const statusLabel = {
available: "Available",
registered: "Registered",
approved_leave: "On leave",
absent: "Absent",
available: "Còn trống",
registered: "Đã đăng ký",
approved_leave: "Nghỉ phép",
absent: "Vắng mặt",
};
const statusColor = {
@@ -82,44 +71,6 @@ export default function ShiftDetailModal({
absent: "bg-red-100 text-red-700",
};
const StaffName = ({ staffId }: { staffId: string }) => {
const [name, setName] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Hàm gọi API
const getName = async () => {
try {
setLoading(true);
const response = await fetch(`/api/staff/${staffId}`);
if (!response.ok) {
throw new Error("Mạng lỗi hoặc không tìm thấy nhân viên");
}
const data = await response.json();
setName(data["name"]);
} catch (error) {
console.error("Lỗi khi lấy tên:", error);
setName("Lỗi tải tên");
} finally {
setLoading(false);
}
};
if (staffId) {
getName();
}
}, [staffId]); // Chạy lại nếu staffId thay đổi
if (loading)
return <span className="animate-pulse text-gray-400">...</span>;
return <span>{name || "N/A"}</span>;
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
@@ -130,12 +81,12 @@ export default function ShiftDetailModal({
{/* 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>} */}
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
<div>
<h2 className="text-foreground text-base font-bold">
Shift Details
Chi tiết ca làm
</h2>
{/* <p className="text-xs text-(--color-text-muted)">{dept?.name}</p> */}
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
</div>
</div>
<button
@@ -151,34 +102,35 @@ export default function ShiftDetailModal({
{/* Body */}
<div className="space-y-4 px-5 py-4">
{/* Status badge */}
{/* <div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
>
{statusLabel[shift.status]}
</span>
</div> */}
</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">
Date
Ngày
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{parseShiftDate(
shift.date as Date | string | undefined,
)?.toLocaleDateString("vi-VN", {
weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"}
{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">
Working hours
Giờ làm
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.startTime} {shift.endTime}
@@ -186,16 +138,18 @@ export default function ShiftDetailModal({
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Duration
Thời lượng
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.durationHours} giờ
</p>
<p className="text-foreground mt-1 text-sm font-bold">{""} giờ</p>
</div>
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Shift wage
Lương ca
</p>
<p className="mt-1 text-sm font-bold text-(--color-primary)">
{(shift.wage ?? 0).toLocaleString("vi-VN")} VND
{shift.wage.toLocaleString("vi-VN")} VND
</p>
</div>
</div>
@@ -203,15 +157,16 @@ export default function ShiftDetailModal({
{/* Registered staff */}
<div>
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
Registered staff ({registeredStaff.length}/{shift.maxStaff})
Nhân viên đã đăng ({shift.registeredStaff.length}/
{shift.maxStaff})
</p>
{registeredStaff.length === 0 ? (
{shift.registeredStaff.length === 0 ? (
<p className="text-xs text-(--color-text-muted) italic">
No staff registered yet
Chưa ai đăng
</p>
) : (
<div className="space-y-2">
{registeredStaff.map((staff) => (
{shift.registeredStaff.map((staff) => (
<div
key={staff.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
@@ -221,7 +176,7 @@ export default function ShiftDetailModal({
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
</div>
<span className="text-foreground text-sm font-medium">
<StaffName staffId={staff.staffId} />
{staff.name}
</span>
</div>
{isManager && (
@@ -231,7 +186,7 @@ export default function ShiftDetailModal({
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>
Remove
Xóa
</button>
)}
</div>
@@ -257,16 +212,19 @@ export default function ShiftDetailModal({
{/* Footer actions */}
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
{!isManager && !isRegistered && !isFull && (
<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>
Register
</button>
)}
{!isRegistered &&
!isFull &&
shift.status !== "approved_leave" &&
shift.status !== "absent" && (
<button
type="button"
onClick={handleRegister}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
>
<i className="fa-solid fa-calendar-plus mr-2"></i>
Đăng ca
</button>
)}
{isRegistered && (
<button
type="button"
@@ -274,7 +232,7 @@ export default function ShiftDetailModal({
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>
Unregister
Hủy đăng
</button>
)}
{isManager && (
@@ -284,7 +242,7 @@ export default function ShiftDetailModal({
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>
Delete shift
Xóa ca
</button>
)}
<button
@@ -292,7 +250,7 @@ export default function ShiftDetailModal({
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"
>
Close
Đóng
</button>
</div>
</div>
@@ -1,22 +1,22 @@
import type { ShiftEntity } from "@/lib/types";
import type { ShiftSlot } from "@/lib/types";
export interface WeeklyScheduleProps {
onShiftClick: (shift: ShiftEntity) => void;
onCreateShift?: (date: Date) => void;
onShiftClick: (shift: ShiftSlot) => void;
onCreateShift?: (date: string) => void;
mobileCalendarHeader?: boolean;
}
export interface MonthlyCalendarProps {
onShiftClick: (shift: ShiftEntity) => void;
onDateSelect?: (date: Date) => void;
onShiftClick: (shift: ShiftSlot) => void;
onDateSelect?: (date: string) => void;
}
export interface MobileShiftViewProps {
onShiftClick: (shift: ShiftEntity) => void;
onShiftClick: (shift: ShiftSlot) => void;
}
export interface ShiftDetailModalProps {
shift: ShiftEntity | null;
shift: ShiftSlot | null;
isOpen: boolean;
onClose: () => void;
}
@@ -24,5 +24,5 @@ export interface ShiftDetailModalProps {
export interface ShiftCreateModalProps {
isOpen: boolean;
onClose: () => void;
defaultDate?: Date;
defaultDate?: string;
}
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
const MONTH_NAMES = [
const MONTH_NAMES_EN = [
"January",
"February",
"March",
@@ -22,14 +22,22 @@ const MONTH_NAMES = [
"December",
];
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
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(Date.now());
const today = new Date(2026, 3, 10);
return (
d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() &&
@@ -51,233 +59,231 @@ export default function WeeklySchedule({
} = useShift();
const weekDates = getWeekDates();
const [selectedDate, setSelectedDate] = useState<Date>(
weekDates[0] ?? currentDate,
const [selectedDate, setSelectedDate] = useState<string>(
formatDateISO(weekDates[0] ?? currentDate),
);
const selectedDateRef = useMemo(() => {
return (
weekDates.find((d) => d === selectedDate) ?? 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]);
// ── Mobile calendar header view ────────────────────────────────────────────
const selectedDateStr = formatDateISO(selectedDateObj);
if (mobileCalendarHeader) {
const dayShifts = getShiftsForDate(new Date(selectedDateRef));
return (
<div className="space-y-4">
{/* Week strip */}
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-(--color-border-light) bg-gradient-to-r from-(--color-primary)/5 to-transparent px-4 py-3">
<button
title="Previous week"
type="button"
onClick={goToPrevWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/80 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-left text-xs"></i>
</button>
<h3 className="text-sm font-bold text-(--color-primary-dark)">
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
title="Next week"
type="button"
onClick={goToNextWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/80 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
>
<i className="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
<div className="grid grid-cols-7 gap-1 p-3">
{weekDates.map((date, i) => {
const active = date === selectedDateRef;
const today = isToday(new Date(date));
const shifts = getShiftsForDate(new Date(date));
return (
<button
key={i}
type="button"
onClick={() => setSelectedDate(date)}
className="flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none bg-transparent py-1.5 transition"
>
<span
className={`text-[10px] font-semibold uppercase ${i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"}`}
>
{DAY_LABELS[i]}
</span>
<span
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold transition ${
today
? "bg-(--color-primary) text-white shadow-sm"
: active
? "bg-(--color-primary)/15 text-(--color-primary)"
: i >= 5
? "text-rose-500"
: "text-(--color-text-secondary)"
}`}
>
{new Date(date).getDate()}
</span>
<div className="flex min-h-2 items-center gap-px">
{shifts.length > 0 && (
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)/60" />
)}
</div>
</button>
);
})}
</div>
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>
{/* Day shifts */}
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="text-sm font-bold text-(--color-primary-dark)">
{DAY_LABELS[(new Date(selectedDateRef).getDay() + 6) % 7]},{" "}
{formatDateShort(new Date(selectedDateRef))}
</h3>
<span className="rounded-full bg-(--color-primary)/10 px-2.5 py-1 text-xs font-semibold text-(--color-primary)">
{dayShifts.length} shifts
</span>
</div>
{dayShifts.length === 0 && !onCreateShift ? (
<div className="rounded-2xl border border-dashed border-(--color-border-light) py-10 text-center">
<i className="fa-regular fa-calendar-xmark mb-2 text-3xl text-gray-200"></i>
<p className="text-sm text-(--color-text-muted)">
No shifts today
</p>
</div>
) : (
<div className="space-y-2.5">
{dayShifts.map((shift) => (
<ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))}
{onCreateShift && (
<button
type="button"
onClick={() => onCreateShift(selectedDateRef)}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-(--color-border-light) bg-transparent py-3 text-sm font-medium text-(--color-text-muted) transition hover:border-(--color-primary) hover:text-(--color-primary)"
<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)"}`}
>
<i className="fa-solid fa-plus"></i>
Add shift
</button>
)}
</div>
)}
{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>
);
}
// ── Desktop table view ──────────────────────────────────────────────────────
return (
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] border-collapse">
<thead>
<tr>
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50/80 px-4 py-3 text-left text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Department
</th>
{weekDates.map((date, i) => {
const today = isToday(new Date(date));
return (
<th
key={i}
className={`border-r border-b border-(--color-border-light) px-3 py-3 text-center text-xs transition ${
today
? "bg-gradient-to-b from-(--color-primary)/15 to-(--color-primary)/5 font-bold text-(--color-primary)"
: "bg-gray-50/80 font-semibold text-(--color-text-muted)"
}`}
<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)"
>
<span className="block text-[11px] font-semibold tracking-wider uppercase">
{DAY_LABELS[i]}
</span>
<span
className={`mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-bold ${
today
? "bg-(--color-primary) text-white shadow-sm"
: "text-(--color-text-secondary)"
}`}
>
{date.getDate()}
</span>
<span
className={`mt-0.5 block text-[10px] font-normal ${today ? "text-(--color-primary)/70" : "text-(--color-text-muted)"}`}
>
{(date.getMonth() + 1).toString().padStart(2, "0")}/
{date.getFullYear()}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{DEPARTMENTS.map((dept, deptIdx) => (
<tr
key={dept.id}
className={deptIdx % 2 === 0 ? "bg-white" : "bg-gray-50/40"}
>
<td className="border-r border-b border-(--color-border-light) px-4 py-3 align-top">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-(--color-primary)/10">
<i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
</div>
<span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name}
</span>
</div>
</td>
{weekDates.map((date, i) => {
const today = isToday(new Date(date));
const shifts = getShiftsForDate(new Date(date));
return (
<td
key={i}
className={`border-r border-b border-(--color-border-light) p-2 align-top transition ${
today ? "bg-(--color-primary)/5" : ""
}`}
>
<div className="flex min-h-[88px] flex-col gap-1.5">
{shifts.map((shift) => (
<ShiftCard
key={shift.id}
shift={shift}
compact
onClick={onShiftClick}
/>
))}
{onCreateShift && (
<button
type="button"
onClick={() => onCreateShift(date)}
className="mt-auto flex cursor-pointer items-center justify-center gap-1 rounded-lg border border-dashed border-gray-200 bg-transparent py-1.5 text-[10px] font-medium text-gray-300 transition hover:border-(--color-primary)/50 hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus"></i>
Add
</button>
)}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
<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>
);
}
+38 -40
View File
@@ -1,46 +1,44 @@
"use client";
// import { ShopCard } from "@/components/molecules/cards";
// import { MOCK_SHOPS } from "@/lib/constants";
import { ShopCard } from "@/components/molecules/cards";
import { MOCK_SHOPS } from "@/lib/constants";
// import type { ShopGridProps } from "./ShopGrid.types";
import type { ShopGridProps } from "./ShopGrid.types";
// export default function ShopGrid({
// searchName = "",
// searchAddress = "",
// }: ShopGridProps) {
// const filtered = MOCK_SHOPS.filter((shop) => {
// const matchesName =
// searchName.trim() === "" ||
// shop.name.toLowerCase().includes(searchName.toLowerCase());
// const matchesAddress =
// searchAddress.trim() === "" ||
// shop.address.toLowerCase().includes(searchAddress.toLowerCase());
// return matchesName && matchesAddress;
// });
export default function ShopGrid({
searchName = "",
searchAddress = "",
}: ShopGridProps) {
const filtered = MOCK_SHOPS.filter((shop) => {
const matchesName =
searchName.trim() === "" ||
shop.name.toLowerCase().includes(searchName.toLowerCase());
const matchesAddress =
searchAddress.trim() === "" ||
shop.address.toLowerCase().includes(searchAddress.toLowerCase());
return matchesName && matchesAddress;
});
// if (filtered.length === 0) {
// return (
// <div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
// <i className="fa-solid fa-store text-5xl opacity-30"></i>
// <p className="text-base font-medium">
// No shops found matching your search
// </p>
// </div>
// );
// }
if (filtered.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-store text-5xl opacity-30"></i>
<p className="text-base font-medium">Không tìm thấy quán nào phù hợp</p>
</div>
);
}
// return (
// <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
// {filtered.map((shop) => (
// <ShopCard
// key={shop.id}
// id={shop.id}
// name={shop.name}
// address={shop.address}
// image={shop.image}
// />
// ))}
// </div>
// );
// }
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((shop) => (
<ShopCard
key={shop.id}
id={shop.id}
name={shop.name}
address={shop.address}
image={shop.image}
/>
))}
</div>
);
}
+1 -1
View File
@@ -1,2 +1,2 @@
// export { default as ShopGrid } from "./ShopGrid";
export { default as ShopGrid } from "./ShopGrid";
export type { ShopGridProps } from "./ShopGrid.types";
@@ -0,0 +1,13 @@
import type { AuthLayoutProps } from "./AuthLayout.types";
/**
* Auth layout template — centers content in the screen.
* Used by login and register pages.
*/
export default function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
{children}
</div>
);
}
@@ -0,0 +1,3 @@
export interface AuthLayoutProps {
children: React.ReactNode;
}
@@ -0,0 +1,2 @@
export { default as AuthLayout } from "./AuthLayout";
export type { AuthLayoutProps } from "./AuthLayout.types";
+4
View File
@@ -2,6 +2,10 @@
export { MainLayout } from "./main-layout";
export type { MainLayoutProps } from "./main-layout";
// Auth Layout
export { AuthLayout } from "./auth-layout";
export type { AuthLayoutProps } from "./auth-layout";
// Feed Layout
export { FeedLayout } from "./feed-layout";
export type { FeedLayoutProps } from "./feed-layout";
@@ -1,7 +1,6 @@
import { CartFab } from "@/components/organisms/cart";
import Footer from "@/layouts/footer";
import Header from "@/layouts/header";
import { ManagerProvider } from "@/lib/manager-context";
import type { MainLayoutProps } from "./MainLayout.types";
@@ -13,9 +12,7 @@ export default function MainLayout({ children }: MainLayoutProps) {
return (
<>
{/* Sticky top header */}
<ManagerProvider>
<Header />
</ManagerProvider>
<Header />
{/* Page content (grows to fill remaining height) */}
<div className="flex-1">{children}</div>
@@ -13,16 +13,16 @@ import type { ManagerLayoutProps } from "./ManagerLayout.types";
* Redirects non-managers away; shows loading state while auth resolves.
*/
export default function ManagerLayout({ children }: ManagerLayoutProps) {
const { user, isInitialized } = useAuth();
const { user } = useAuth();
const router = useRouter();
useEffect(() => {
if (isInitialized && user !== null && user.role !== "manager") {
if (user !== null && user.role !== "manager") {
router.replace("/");
}
}, [user, isInitialized, router]);
}, [user, router]);
if (!isInitialized || user === null) {
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)">
+1 -1
View File
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
# Cài đặt pnpm
RUN npm install -g pnpm@10.9.0
RUN npm install -g pnpm
# Copy file định nghĩa package để tận dụng cache của Docker
COPY package.json pnpm-lock.yaml ./
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec:
containers:
- name: frontend-container
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.12
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.2
ports:
- containerPort: 3000
resources:
+224 -276
View File
@@ -2,6 +2,8 @@
> Tài liệu chi tiết về layout components (Header, Footer) và responsive behavior
> **Last Updated:** 2026-04-18
---
## Overview
@@ -21,180 +23,197 @@ layouts/
## Header (layouts/header.tsx)
**File:** layouts/header.tsx **Type:** Client component **Description:** Sticky
top navigation bar with brand info and authentication status display.
**File:** layouts/header.tsx **Type:** Client component (`"use client"`)
**Description:** Sticky top navigation bar with brand info and authentication
status display. Uses `useAuth()` context for real auth state and `useRouter()`
for navigation.
### 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:**
- **Left:** Shop branding (logo + name + tagline)
- **Right:** Auth status indicator
- **Left:** Shop branding (logo + name + tagline) — wrapped in `<Link href="/">`
- **Right:** Auth status indicator (varies by user role)
### Features
#### Logo & Branding
- Shop logo (40x40px)
- Shop name (bold text)
- Shop logo rendered with `next/image` (fill, 40x40px / 44x44px on md+)
- Shop name (bold, color transitions on hover)
- Tagline (hidden on mobile < md, visible md+)
- Entire left section is a link to `/`
**Responsive:** | Breakpoint | Logo | Name | Tagline |
|-----------|------|------|---------| | < sm (640px) | Yes | Yes | Hidden | |
sm-md | Yes | Yes | Hidden | | md+ (768px) | Yes | Yes | Visible |
**Responsive:**
| 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
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 nhp" ← icon always shown, label hidden on xs
```
- Guest state (user = null)
- Brown primary button
- Click: toggles to next state
- `title="Đăng nhập"` on button
- Clicking navigates to `/login`
- 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
- Gold/caramel colored badge
- User tie icon
- Name: Nguyễn Văn An
- Dashboard link → `/manager`
- Icon: `fa-solid fa-user-tie`
- Label "Dashboard" hidden on xs, visible sm+
- 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
- Bordered button style
- Name: Trần Thị Bình
- Click: toggles back to Guest state
- Ca làm link → `/staff/schedule`
- Icon: `fa-solid fa-calendar-check`
- Label "Ca làm" hidden on xs, visible sm+
- 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
| Breakpoint | Layout |
| ------------- | ------------------------------- |
| < sm | Stacked vertically |
| sm-md (640px) | Side-by-side, reduced spacing |
| md+ (768px) | Full width, comfortable spacing |
| Breakpoint | Layout |
| ------------- | -------------------------------------- |
| < sm (640px) | Button labels hidden, icons only |
| sm-md (640px) | Button labels visible, reduced spacing |
| md+ (768px) | Full layout including tagline |
**Mobile (<640px):**
- Logo smaller
- Name only (no tagline)
- Auth button text hidden, icon shown
- Logo 40x40px
- Shop name only (no tagline)
- Auth button shows icon only (label hidden via `hidden sm:inline`)
- Touch-friendly padding
**Desktop (≥640px):**
**Desktop (≥768px):**
- Normal logo size
- Logo 44x44px
- Full layout with tagline
- Button with text and icon
- Buttons show icon + text label
- 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
| Element | Classes |
| -------------------- | --------------------------------------------------------- |
| Header wrapper | `sticky top-0 z-50 bg-white shadow-md` |
| Container | `max-w-7xl mx-auto px-4 md:px-6 h-72px flex items-center` |
| Left section (brand) | `flex items-center gap-3 flex-1` |
| Right section (auth) | `flex items-center gap-3 shrink-0` |
| Logo image | `w-10 h-10 md:w-12 md:h-12` |
| Brand text | `font-bold text-sm md:text-base` |
| Tagline | `hidden md:block text-xs text-muted-foreground` |
| Auth button | `px-4 py-2 rounded-lg text-sm font-medium` |
| Element | Classes |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Header wrapper | `sticky top-0 z-50 h-(--spacing-header-height) w-full border-b bg-(--color-bg-header) shadow-[...]` |
| 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) | `group flex shrink-0 items-center gap-4 no-underline` (Link) |
| Logo wrapper | `relative h-10 w-10 shrink-0 md:h-11 md:w-11` |
| Brand text | `text-base font-bold text-(--color-primary-dark) md:text-lg` |
| Tagline | `hidden text-xs text-(--color-text-muted) md:block` |
| Right section (auth) | `flex shrink-0 items-center gap-3` |
| 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
--spacing-header-height: 72px;
--color-primary: brown;
--color-primary-dark: darker brown;
--color-text-primary: text color;
--color-text-muted: gray text;
--color-shadow-sm: subtle shadow;
--spacing-header-height /* 72px — header height */
--color-bg-header /* header background */
--color-border /* border color */
--color-shadow-sm /* subtle shadow */
--color-primary /* brand primary (brown) */
--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
- **next/image** - Logo image rendering
- **next/link** - Navigation links
- **lib/constants:** SHOP_INFO, MOCK_USERS
- **lib/types:** User
- **FontAwesome icons:** fa-sign-in-alt, fa-sign-out-alt, fa-user-tie,
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>
```
- **next/image** Logo image rendering
- **next/link** — Brand link, dashboard link, schedule link
- **next/navigation** — `useRouter` for programmatic navigation
- **lib/auth-context:** `useAuth` — user state, logout
- **lib/constants:** `SHOP_INFO`
---
## 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
details.
details. Copyright year is dynamic via `new Date().getFullYear()`.
### Layout
```
┌────────────────────────────────────────────┐
│ [Brand Info] │ [Social + WiFi] │
├────────────────────────────────────────────┤
│ © 2024 Coffee Shop. Made with ❤️ in VN
└────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────
│ [Brand Info] │ [Social + WiFi]
├────────────────────────────────────────────────────────────────
│ © {year} Coffee Shop. All rights reserved. | Powered by ...
└────────────────────────────────────────────────────────────────
```
**12-Column Grid Layout:**
| Breakpoint | Brand Info | Social + WiFi |
| ------------ | ----------- | ------------- |
| Mobile | col-span-12 | col-span-12 |
| md (768px) | col-span-6 | col-span-6 |
| lg+ (1024px) | col-span-6 | col-span-6 |
| Breakpoint | Brand Info | Right column (Social + WiFi) |
| ------------ | ----------- | -------------------------------- |
| Mobile | col-span-12 | col-span-12 |
| md (768px) | 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
@@ -202,179 +221,114 @@ details.
**Content:**
- Shop logo (40x40px)
- Shop name (bold)
- Tagline (gray text)
- Address with location icon
- Phone with phone icon
- Email with envelope icon
- Open hours with clock icon
- Shop logo (40x40px, `next/image`)
- Shop name (bold, accent color)
- Tagline (gray, 75% opacity)
- Address with `fa-solid fa-location-dot`
- Phone (clickable `tel:` link) with `fa-solid fa-phone`
- Email (clickable `mailto:` link) with `fa-solid fa-envelope`
- Open hours with `fa-solid fa-clock`
**Responsive:**
#### 2. Social Links
```
Mobile:
└─ Logo
└─ Name & Tagline (stacked)
└─ Address
└─ Phone
└─ Email
└─ Open Hours
**Links (heading: "Kết nối"):**
Desktop:
└─ Logo + Name/Tagline (flexbox, side-by-side)
└─ Address, Phone, Email, Hours (stacked below)
```
#### 2. Social Links (Top Right)
**Links:**
- Facebook icon → SHOP_INFO.facebook
- TikTok icon → SHOP_INFO.tiktok
- Website icon → SHOP_INFO.website
- Facebook → `SOCIAL_LINKS.facebook` (branded blue icon, opens `_blank`)
- TikTok → `SOCIAL_LINKS.tiktok` (black icon, opens `_blank`)
- Website → `SOCIAL_LINKS.website` (primary color icon, uses `next/link`)
**Styling:**
- Icons in circle backgrounds
- Hover effect (color change)
- Flex row layout
- Centered alignment
- Each item: icon badge (8x8 rounded-lg) + label text
- Hover: accent color + full opacity
#### 3. WiFi Card (Bottom Right)
#### 3. WiFi Card
**Content:**
**Content (heading: "WiFi Miễn Phí"):**
- "📶 WiFi miễn phí" label
- Network name (monospace)
- Password (monospace, can be hidden/shown)
- WiFi icon (`fa-solid fa-wifi`)
- Network name (monospace, accent-bordered)
- 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:**
- Light gray background
- Rounded borders
- Semi-transparent dark panel (`bg-(--color-primary-dark) bg-opacity-30`)
- Rounded xl border
- Stacked label + value rows (no overflow risk)
- Monospace font for credentials
- Eye icon toggle for password visibility
**Example:**
```
📶 WiFi miễn phí
┌────────────────────────┐
│ Network: CoffeeShop │
│ Password: •••••••• │
│ (eye icon) │
└────────────────────────┘
```
#### 4. Copyright Bar (Bottom)
#### 4. Bottom Bar
**Content:**
- Copyright text: "© 2024 Coffee Shop"
- "Made with ❤️ in Vietnam"
- Centered
**Styling:**
- Small text
- Gray color
- Subtle separator line above
- Left: `© {year} {SHOP_INFO.name}. All rights reserved.`
- Right: `Được vận hành ❤ bằng Drinkool`
- Responsive: stacked on mobile, side-by-side on sm+
### Responsive Behavior
**Mobile (<768px):**
- Single column layout
- Stacked sections
- Stacked sections (brand, then social+WiFi)
- Full width
- Padding around edges
**Tablet (768px):**
- 2-column layout
- Brand info left, Social + WiFi right
- Equal width columns
- 2-column: brand (6 cols) | social+WiFi (6 cols)
- Social and WiFi stacked within right column
**Desktop (≥768px):**
**Desktop (≥1024px):**
- 2-column layout with more spacing
- Brand info slightly smaller
- Comfortable padding
- Brand 8 cols, right 4 cols
- Social and WiFi stacked in right col
**XL (≥1280px):**
- Brand 6 cols, right 6 cols
- Social and WiFi side-by-side in right col sub-grid
### Styling
| Element | Classes |
| -------------- | --------------------------------------------------------- |
| Footer wrapper | `bg-gray-100 border-t` |
| Container | `max-w-7xl mx-auto px-4 py-8` |
| Grid | `grid grid-cols-12 gap-8` |
| Brand section | `col-span-12 md:col-span-6` |
| Social section | `col-span-12 md:col-span-6` |
| Logo | `w-10 h-10` |
| Info text | `text-sm text-gray-600` |
| Social icons | `w-10 h-10 rounded-full flex items-center justify-center` |
| WiFi card | `bg-white p-4 rounded-lg border` |
| Copyright bar | `border-t mt-8 pt-6 text-center text-xs text-gray-500` |
| Element | Classes |
| ----------------- | -------------------------------------------------------------------------------------------------------------------- |
| Footer wrapper | `w-full overflow-x-hidden bg-(--color-bg-footer) text-(--color-text-on-dark)` |
| Container | `mx-auto max-w-screen-2xl px-4 py-10 md:px-6 lg:px-8` |
| Grid | `grid grid-cols-12 gap-x-8 gap-y-8` |
| Brand section | `col-span-12 md:col-span-6 lg:col-span-8 xl: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 | `relative h-10 w-10 shrink-0` |
| Shop name | `text-lg font-bold text-(--color-accent)` |
| Tagline | `text-sm leading-relaxed opacity-75` |
| Contact list | `flex flex-col gap-2 text-sm opacity-80` |
| 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
--color-primary: brand color;
--color-text-secondary: secondary text color;
--color-text-muted: muted/gray text color;
--color-accent: accent color;
--color-border: border color;
--color-bg-*: background colors;
--color-shadow-sm: subtle shadow;
--color-bg-footer /* dark footer background */
--color-text-on-dark /* light text for dark backgrounds */
--color-accent /* accent/gold color */
--color-primary /* brand primary (brown) */
--color-primary-dark /* darker brown */
--color-primary-light /* lighter brown */
--color-border /* border color */
```
### Dependencies
- **next/image** - Shop logo
- **next/link** - Social links
- **lib/constants:** SHOP_INFO, SOCIAL_LINKS
- **FontAwesome icons:** location-dot, phone, envelope, clock, facebook, tiktok,
globe, wifi, eye, eye-slash
### 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>
```
- **next/image** Shop logo
- **next/link** — Website social link
- **lib/constants:** `SHOP_INFO`, `SOCIAL_LINKS`
- **FontAwesome icons:** `location-dot`, `phone`, `envelope`, `clock`,
`fa-brands fa-facebook-f`, `fa-brands fa-tiktok`, `globe`, `wifi`, `heart`
---
@@ -398,7 +352,7 @@ export default function RootLayout({ children }) {
**Result:**
- Header: Always at top (sticky)
- Header: Always at top (sticky, z-50)
- Content: Takes full width between header/footer
- Footer: Always at bottom
@@ -409,30 +363,22 @@ export default function RootLayout({ children }) {
### Header Responsive Pattern
```css
/* Mobile first */
.header {
flex-direction: column;
gap: 1rem;
padding: 1rem;
/* Mobile first — icon-only auth buttons */
.header-auth-label {
display: none; /* hidden on xs */
}
/* Tablet+ */
/* sm+ — show button labels */
@media (min-width: 640px) {
.header {
flex-direction: row;
justify-content: space-between;
padding: 1.5rem;
.header-auth-label {
display: inline; /* hidden sm:inline */
}
}
/* Desktop+ */
/* md+ — show tagline */
@media (min-width: 768px) {
.header {
padding: 2rem;
}
.tagline {
display: block; /* Show tagline on desktop */
display: block; /* hidden md:block */
}
}
```
@@ -440,17 +386,14 @@ export default function RootLayout({ children }) {
### Footer Responsive Pattern
```css
/* Mobile: stacked */
/* Mobile: single column */
.footer-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(12, 1fr);
}
/* Tablet: 2 columns */
@media (min-width: 768px) {
.footer-grid {
grid-template-columns: 1fr 1fr;
}
}
/* md: brand 6 cols, right 6 cols */
/* lg: brand 8 cols, right 4 cols */
/* xl: brand 6 cols, right 6 cols — right col uses xl:grid-cols-2 */
```
---
@@ -483,33 +426,38 @@ CSS variables are set up for dark mode support. To enable:
### Header
- Logo has alt text
- Auth button has aria-label
- Icons have semantic meaning
- Good contrast ratios
- Logo image has descriptive `alt` text: `"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"`
- Interactive elements have hover and active states (visual feedback)
- Touch targets are sized appropriately (px-4/5 + py-2/2.5)
### Footer
- Headings use semantic tags
- Links have descriptive text
- Icons are decorative (aria-hidden)
- Monospace font for technical info (WiFi credentials)
- Phone and email are clickable `<a>` links (`tel:`, `mailto:`)
- Social links open in `_blank` with `rel="noopener noreferrer"`
- Section headings use `<h3>` semantic tags
- Icons are decorative within labelled list items
- WiFi credentials in monospace font for readability
### General
- Touch targets ≥ 48px on mobile
- Sufficient color contrast
- Semantic HTML structure
- ARIA labels where needed
- Touch targets ≥ 44px (py-2.5 + px-4/5 buttons)
- `active:scale-95` on interactive buttons for tactile feedback
- Semantic HTML structure (`<header>`, `<footer>`, `<nav>` implicit via links)
- ARIA labels on icon-only buttons (logout buttons)
---
## Best Practices
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
4. **Props over hardcoding:** Use lib/constants for data
5. **Type safety:** Use TypeScript for component props
6. **Performance:** Optimize images with next/image
7. **Accessibility:** Add ARIA labels and semantic HTML
6. **Performance:** Optimize images with next/image (`fill`, `sizes`,
`priority`)
7. **Accessibility:** Add title/aria-label on icon-only buttons
+9 -9
View File
@@ -48,7 +48,7 @@ export default function Footer() {
<ul className="flex flex-col gap-2 text-sm opacity-80">
<li className="flex items-start gap-2">
<i className="fa-solid fa-location-dot mt-0.5 w-4 shrink-0 text-center text-(--color-accent)"></i>
<span>Address: {SHOP_INFO.address}</span>
<span>Đa chỉ: {SHOP_INFO.address}</span>
</li>
<li className="flex items-center gap-2">
<i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i>
@@ -56,7 +56,7 @@ export default function Footer() {
href={`tel:${SHOP_INFO.phone}`}
className="transition-colors duration-150 hover:text-(--color-accent)"
>
Phone: {SHOP_INFO.phone}
Số điện thoại: {SHOP_INFO.phone}
</a>
</li>
<li className="flex items-center gap-2">
@@ -83,7 +83,7 @@ export default function Footer() {
{/* ── 2. Social links ── */}
<div className="col-span-1">
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
Follow Us
Kết nối
</h3>
<ul className="flex flex-col gap-3">
<li>
@@ -129,20 +129,20 @@ export default function Footer() {
{/* ── 3. WiFi card ── */}
<div className="col-span-1">
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
Free WiFi
WiFi Miễn Phí
</h3>
<div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4">
<div className="mb-3 flex items-center gap-2">
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
<span className="text-sm font-semibold">
Connect for free
Kết nối miễn phí
</span>
</div>
{/* Stacked label + value rows — no overflow risk */}
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs tracking-wide uppercase opacity-60">
Network name
Tên mạng
</span>
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)">
{SHOP_INFO.wifi.name}
@@ -150,7 +150,7 @@ export default function Footer() {
</div>
<div className="flex flex-col gap-1">
<span className="text-xs tracking-wide uppercase opacity-60">
Password
Mật khẩu
</span>
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)">
{SHOP_INFO.wifi.password}
@@ -170,9 +170,9 @@ export default function Footer() {
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
</span>
<span className="flex items-center gap-1">
Powered by{" "}
Đưc vận hành{" "}
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
Drinkool
bằng Drinkool
</span>
</div>
</div>
+11 -13
View File
@@ -2,7 +2,6 @@
import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants";
import { useManager } from "@/lib/manager-context";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -26,7 +25,6 @@ import { useRouter } from "next/navigation";
*/
export default function Header() {
const router = useRouter();
const { eatery } = useManager();
const { user, logout } = useAuth();
const handleAuthClick = () => {
@@ -49,7 +47,7 @@ export default function Header() {
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
<Image
src={SHOP_INFO.logo}
alt={`${eatery?.name} logo`}
alt={`Logo ${SHOP_INFO.name}`}
fill
className="object-contain transition-transform duration-200 group-hover:scale-105"
sizes="44px"
@@ -60,7 +58,7 @@ export default function Header() {
{/* Name + tagline */}
<div className="flex flex-col leading-tight">
<span className="text-base font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-lg">
{eatery?.name}
{SHOP_INFO.name}
</span>
<span className="hidden text-xs text-(--color-text-muted) md:block">
{SHOP_INFO.tagline}
@@ -74,11 +72,11 @@ export default function Header() {
/* Guest: sign-in button */
<button
onClick={handleAuthClick}
title="Sign in"
title="Đăng nhập"
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-right-to-bracket"></i>
<span className="hidden sm:inline">Sign in</span>
<span className="hidden sm:inline">Đăng nhập</span>
</button>
) : user.role === "manager" ? (
/* Manager: dashboard link + logout */
@@ -92,8 +90,8 @@ export default function Header() {
</Link>
<button
onClick={handleAuthClick}
title="Sign out"
aria-label="Sign out"
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"
>
<i className="fa-solid fa-right-from-bracket text-base"></i>
@@ -107,12 +105,12 @@ export default function Header() {
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"
>
<i className="fa-solid fa-calendar-check text-base"></i>
<span className="hidden sm:inline">My Shifts</span>
<span className="hidden sm:inline">Ca làm</span>
</Link>
<button
onClick={handleAuthClick}
title="Click to sign out"
aria-label="Sign out"
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 */}
@@ -126,14 +124,14 @@ export default function Header() {
/* Customer: phone icon + label */
<button
onClick={handleAuthClick}
title={`Customer - ${user.phone || ""} - Click to sign out`}
title={`Khách hàng - ${user.phone || ""} - Nhấn để đăng xuất`}
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary-light) px-4 py-2 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary) active:scale-95"
>
{/* Customer icon */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)">
<i className="fa-solid fa-user"></i>
</div>
<span className="hidden sm:inline">Customer</span>
<span className="hidden sm:inline">Khách hàng</span>
</button>
)}
</div>
-52
View File
@@ -1,52 +0,0 @@
import {
ApolloClient,
ApolloLink,
HttpLink,
InMemoryCache,
Observable,
} from "@apollo/client";
const responseWrapperLink = new ApolloLink((operation, forward) => {
return new Observable((observer) => {
const handle = forward(operation).subscribe({
next: (response) => {
// Kiểm tra nếu dữ liệu trả về bị "trần" (thiếu data property)
if (
response &&
!Object.prototype.hasOwnProperty.call(response, "data")
) {
// Khởi tạo một object mới theo chuẩn GraphQL
const formattedResponse = {
data: response,
errors: (response as any).errors,
};
observer.next(formattedResponse);
} else {
observer.next(response);
}
},
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
return () => {
if (handle) handle.unsubscribe();
};
});
});
export const cartClient = new ApolloClient({
link: ApolloLink.from([
responseWrapperLink,
new HttpLink({ uri: "/api/cart/graphql" }),
]),
cache: new InMemoryCache(),
});
export const eateryClient = new ApolloClient({
link: ApolloLink.from([
responseWrapperLink,
new HttpLink({ uri: "/api/eatery/graphql" }),
]),
cache: new InMemoryCache(),
});
+114 -35
View File
@@ -12,21 +12,95 @@ import type { User } from "./types";
interface AuthContextType {
user: User | null;
setUser: (user: User | null) => void;
isInitialized: boolean;
login: (
username: string,
password: string,
) => Promise<{ ok: boolean; status?: number }>;
login: (username: string, password: string) => boolean;
logout: () => void;
registerPhone: string | null;
setRegisterPhone: (phone: string | null) => void;
completeRegistration: (phone: string) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Mock user database
const MOCK_AUTH_DB = {
// Admin
admin: {
username: "admin",
password: "admin",
user: {
id: 1,
name: "Quản lý",
role: "manager" as const,
avatar: null,
phone: "0912345678",
},
},
// Staff (username and password are their names)
"Nguyễn Văn An": {
username: "Nguyễn Văn An",
password: "Nguyễn Văn An",
user: {
id: 2,
name: "Nguyễn Văn An",
role: "staff" as const,
avatar: null,
phone: "0901234567",
},
},
"Trần Thị Bình": {
username: "Trần Thị Bình",
password: "Trần Thị Bình",
user: {
id: 3,
name: "Trần Thị Bình",
role: "staff" as const,
avatar: null,
phone: "0902345678",
},
},
"Lê Văn Cường": {
username: "Lê Văn Cường",
password: "Lê Văn Cường",
user: {
id: 4,
name: "Lê Văn Cường",
role: "staff" as const,
avatar: null,
phone: "0903456789",
},
},
// Customers (username is phone number, password is custom)
"0987654321": {
username: "0987654321",
password: "user1",
user: {
id: 5,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0987654321",
},
},
"0976543210": {
username: "0976543210",
password: "user1",
user: {
id: 6,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0976543210",
},
},
};
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
// Load user from localStorage on mount
useEffect(() => {
const savedUser = localStorage.getItem("coffee-shop-user");
if (savedUser) {
@@ -36,36 +110,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error("Failed to parse saved user", e);
}
}
setIsInitialized(true);
}, []);
const login = async (
username: string,
password: string,
): Promise<{ ok: boolean; status?: number }> => {
const isPhone = /^0\d{9}$/.test(username.trim());
const role = isPhone ? "customer" : "manager";
const login = (username: string, password: string): boolean => {
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB];
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: username, password, role }),
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
return { ok: true };
} else {
console.error("Đăng nhập thất bại:", response.status);
return { ok: false, status: response.status };
}
} catch (error) {
console.error("Lỗi kết nối API:", error);
return { ok: false };
if (authEntry && authEntry.password === password) {
setUser(authEntry.user);
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user));
return true;
}
return false;
};
const logout = () => {
@@ -73,14 +129,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem("coffee-shop-user");
};
const completeRegistration = (phone: string) => {
// Create new customer account
const newUser: User = {
id: Date.now(),
name: "Khách hàng",
role: "customer",
avatar: null,
phone,
};
// Add to mock database (in real app, this would be API call)
(MOCK_AUTH_DB as any)[phone] = {
username: phone,
password: "user1", // Default password for new customers
user: newUser,
};
setUser(newUser);
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser));
setRegisterPhone(null);
};
return (
<AuthContext.Provider
value={{
user,
setUser,
isInitialized,
login,
logout,
registerPhone,
setRegisterPhone,
completeRegistration,
}}
>
{children}
+99 -183
View File
@@ -1,222 +1,138 @@
"use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import type { Product } from "@/lib/types";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { cartClient, eateryClient } from "./apollo-clients";
import {
CartEntity,
CartItemEntity,
addMenuItemMutation,
allEateriesQuery,
createCartMutation,
getCartQuery,
} from "./types";
interface CartContextValue {
items: CartItemEntity[];
totalItems: number;
totalPrice: number;
cart: CartEntity;
eateryId: string | undefined;
addToCart: (product: CartItemEntity) => void;
increaseQty: (id: string) => void;
decreaseQty: (id: string) => void;
removeFromCart: (id: string) => void;
setQuantity: (id: string, quantity: number) => void;
removeCart: () => void;
export interface CartItem {
id: number;
name: string;
description: string;
price: number;
quantity: number;
}
const CART_ID = "cartId";
interface CartContextValue {
items: CartItem[];
totalItems: number;
totalPrice: number;
addToCart: (product: Product) => void;
increaseQty: (id: number) => void;
decreaseQty: (id: number) => void;
removeFromCart: (id: number) => void;
setQuantity: (id: number, quantity: number) => void;
clearCart: () => void;
}
const STORAGE_KEY = "coffee-shop-cart";
const CartContext = createContext<CartContextValue | null>(null);
const GET_CART_ITEMS = gql`
query getCart($cartId: String!) {
getCart(cartId: $cartId) {
Id
userId
eateryId
items {
productId
quantity
priceAtTimeOfAdding
subTotal
}
totalAmount
paymentQrUrl
}
}
`;
const GET_EATERY = gql`
query GetEateryMenu {
allEateries {
id
}
}
`;
const CREATE_CART = gql`
mutation createCart($eateryId: String!) {
createCart(eateryId: $eateryId)
}
`;
const ADD_ITEM = gql`
mutation addItem(
$cartId: String!
$menuItemId: String!
$quantity: BigInteger!
) {
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
Id
userId
eateryId
items {
productId
quantity
priceAtTimeOfAdding
subTotal
}
totalAmount
paymentQrUrl
}
}
`;
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<CartEntity>(null!);
const [cartId, setCartId] = useState<string | null>(null);
const { data: eateryData } = useQuery<allEateriesQuery>(GET_EATERY, {
client: eateryClient,
});
const [createCart] = useMutation<createCartMutation>(CREATE_CART, {
client: cartClient,
});
const { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, {
client: cartClient,
variables: { cartId },
});
const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, {
client: cartClient,
});
const [items, setItems] = useState<CartItem[]>([]);
useEffect(() => {
const createCartFunc = async () => {
if (eateryData && eateryData.allEateries?.length > 0) {
try {
const firstEateryId = eateryData.allEateries[0].id;
const { data: mutationResult } = await createCart({
variables: { eateryId: firstEateryId },
});
const newCartId = mutationResult!.createCart;
if (newCartId) {
localStorage.setItem(CART_ID, newCartId);
setCartId(newCartId);
}
} catch (err) {
console.error("Lỗi khi tạo giỏ hàng:", err);
localStorage.removeItem(CART_ID);
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as CartItem[];
if (Array.isArray(parsed)) {
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
}
};
if (error) {
createCartFunc();
} else if (!cartId) {
const localCartId = localStorage.getItem(CART_ID);
if (localCartId) setCartId(localCartId);
else createCartFunc();
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}, [eateryData, createCart, data, loading, error]);
}, []);
useEffect(() => {
if (data?.getCart) setCart(data.getCart);
}, [data]);
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
const addToCart = async (product: CartItemEntity) => {
if (!cartId) return;
const addToCart = (product: Product) => {
setItems((prev) => {
const index = prev.findIndex((i) => i.id === product.id);
if (index === -1) {
return [
...prev,
{
id: product.id,
name: product.name,
description: product.description,
price: product.price,
quantity: 1,
},
];
}
const { data: result } = await addMenuItem({
variables: {
cartId,
menuItemId: product.productId!,
quantity: product.quantity,
},
const next = [...prev];
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
return next;
});
if (result) setCart(result.addItem);
};
const DELETE_CART = gql`
mutation deleteCart($cartId: String!) {
deleteCart(cartId: $cartId)
const increaseQty = (id: number) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
),
);
};
const decreaseQty = (id: number) => {
setItems((prev) =>
prev
.map((item) =>
item.id === id
? { ...item, quantity: Math.max(0, item.quantity - 1) }
: item,
)
.filter((item) => item.quantity > 0),
);
};
const removeFromCart = (id: number) => {
setItems((prev) => prev.filter((item) => item.id !== id));
};
const clearCart = () => setItems([]);
const setQuantity = (id: number, quantity: number) => {
const safeQty = Number.isFinite(quantity)
? Math.max(0, Math.floor(quantity))
: 0;
if (safeQty === 0) {
removeFromCart(id);
return;
}
`;
const [mutateDeleteCart] = useMutation(DELETE_CART, {
client: cartClient,
});
const removeCart = async () => {
localStorage.removeItem(CART_ID);
await mutateDeleteCart({ variables: { cartId } });
setCartId(null);
setCart(null!);
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, quantity: safeQty } : item,
),
);
};
const setQuantity = async (id: string, newQuantity: number) => {
if (!cartId) return;
const currentItem = cart.items.find((i) => i.productId == id);
const { data: result } = await addMenuItem({
variables: {
cartId,
menuItemId: id,
quantity: newQuantity - currentItem!.quantity,
},
});
if (result) setCart(result.addItem);
};
const removeFromCart = (id: string) => setQuantity(id, 0);
const increaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity + 1);
const decreaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity - 1);
const totalItems = useMemo(
() => cart?.items.reduce((sum, item) => sum + item.quantity, 0),
[cart],
() => items.reduce((sum, item) => sum + item.quantity, 0),
[items],
);
const totalPrice = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items],
);
const value = useMemo(
() => ({
items: cart?.items,
items,
totalItems,
totalPrice: cart?.totalAmount,
eateryId: cart?.eateryId,
totalPrice,
addToCart,
increaseQty,
decreaseQty,
removeFromCart,
setQuantity,
removeCart,
cart,
clearCart,
}),
[cart?.items, cart?.totalAmount, cart?.eateryId],
[items, totalItems, totalPrice],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+561 -51
View File
@@ -1,17 +1,23 @@
import type {
Combo,
Department,
MenuCategory,
Product,
ProductSalesStats,
RevenueDataPoint,
ShiftEntity,
ShiftSlot,
Shop,
ShopInfo,
SocialLinks,
User,
} from "./types";
// ===== SHOP INFORMATION =====
export const SHOP_INFO: ShopInfo = {
name: "Coffee Shop",
tagline: "Rich Flavors Moments of Relaxation",
tagline: "Hương vị đậm đà Khoảnh khắc thư giãn",
logo: "/imgs/logo.png",
address: "123 Nguyen Hue Street, District 1, Ho Chi Minh City",
address: "123 Đường Nguyn Huệ, Quận 1, TP. Hồ Chí Minh",
phone: "0901 234 567",
managerPhone: "0912 345 678",
email: "contact@coffeeshop.vn",
@@ -19,7 +25,7 @@ export const SHOP_INFO: ShopInfo = {
name: "CoffeeShop_Free",
password: "coffee2024",
},
openHours: "07:00 22:00 (Monday Sunday)",
openHours: "07:00 22:00 (Thứ 2 Chủ nhật)",
};
// ===== SOCIAL LINKS =====
@@ -29,6 +35,287 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/",
};
// ===== MENU CATEGORIES =====
// Each category has a unique FontAwesome icon representing the item type
export const MENU_CATEGORIES: MenuCategory[] = [
{ id: "all", name: "Tất cả", icon: "fa-solid fa-border-all" },
{ id: "cafe", name: "Cà Phê", icon: "fa-solid fa-mug-hot" },
{ id: "tra", name: "Trà", icon: "fa-solid fa-leaf" },
{ id: "sua-chua", name: "Sữa Chua", icon: "fa-solid fa-jar" },
{ id: "nuoc-ep", name: "Nước Ép", icon: "fa-solid fa-blender" },
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
{
id: "giai-khat",
name: "Giải Khát / Ăn Vặt",
icon: "fa-solid fa-ice-cream",
},
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
];
// ===== MOCK PRODUCTS =====
// Placeholder data replace with real API calls when backend is ready
export const MOCK_PRODUCTS: Product[] = [
{
id: 1,
name: "Cà Phê Đen",
category: "cafe",
price: 25000,
image: "/imgs/products/placeholder.jpg",
description:
"Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.",
available: true,
},
{
id: 2,
name: "Cà Phê Sữa",
category: "cafe",
price: 30000,
image: "/imgs/products/placeholder.jpg",
description:
"Cà phê sữa đặc thơm ngon, béo ngậy, kết hợp hoàn hảo giữa cà phê và sữa đặc.",
available: true,
},
{
id: 3,
name: "Bạc Xỉu",
category: "cafe",
price: 32000,
image: "/imgs/products/placeholder.jpg",
description:
"Bạc xỉu nhẹ nhàng, ít cà phê nhiều sữa, thích hợp cho người mới uống cà phê.",
available: true,
},
{
id: 4,
name: "Cà Phê Trứng",
category: "cafe",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description:
"Cà phê trứng đặc sản Hà Nội, lớp kem trứng mịn màng phủ trên nền cà phê đậm đà.",
available: true,
},
{
id: 5,
name: "Trà Đào Cam Sả",
category: "tra",
price: 35000,
image: "/imgs/products/placeholder.jpg",
description:
"Trà đào thơm mát kết hợp cam tươi và sả, thanh mát và giải nhiệt tuyệt vời.",
available: true,
},
{
id: 6,
name: "Trà Xanh Matcha",
category: "tra",
price: 40000,
image: "/imgs/products/placeholder.jpg",
description:
"Matcha Nhật Bản nguyên chất, vị đắng nhẹ đặc trưng, thơm mát và bổ dưỡng.",
available: true,
},
{
id: 7,
name: "Trà Vải Hoa Nhài",
category: "tra",
price: 38000,
image: "/imgs/products/placeholder.jpg",
description:
"Trà vải thanh ngọt kết hợp hương hoa nhài dịu dàng, thư giãn tâm hồn.",
available: true,
},
{
id: 8,
name: "Sữa Chua Trân Châu",
category: "sua-chua",
price: 38000,
image: "/imgs/products/placeholder.jpg",
description:
"Sữa chua mịn màng kết hợp trân châu đen dẻo dai, chua ngọt hài hòa.",
available: true,
},
{
id: 9,
name: "Sữa Chua Dâu",
category: "sua-chua",
price: 40000,
image: "/imgs/products/placeholder.jpg",
description:
"Sữa chua mát lạnh với dâu tươi ngọt chua, giàu vitamin và khoáng chất.",
available: true,
},
{
id: 10,
name: "Nước Ép Cam",
category: "nuoc-ep",
price: 35000,
image: "/imgs/products/placeholder.jpg",
description:
"Nước ép cam tươi nguyên chất, giàu vitamin C, tốt cho sức khỏe.",
available: true,
},
{
id: 11,
name: "Nước Ép Dưa Hấu",
category: "nuoc-ep",
price: 30000,
image: "/imgs/products/placeholder.jpg",
description:
"Nước ép dưa hấu mát lạnh, giải nhiệt tức thì trong những ngày hè oi bức.",
available: true,
},
{
id: 12,
name: "Latte Caramel",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description:
"Latte caramel ngọt ngào, thơm béo với lớp foam sữa mịn và sốt caramel.",
available: true,
},
{
id: 13,
name: "Latte Vanilla",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
description:
"Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.",
available: true,
},
{
id: 14,
name: "Bánh Mì Nướng Bơ",
category: "giai-khat",
price: 20000,
image: "/imgs/products/placeholder.jpg",
description:
"Bánh mì nướng giòn rụm, phết bơ thơm và mứt dâu, ăn kèm cà phê tuyệt vời.",
available: true,
},
{
id: 15,
name: "Bánh Flan",
category: "giai-khat",
price: 25000,
image: "/imgs/products/placeholder.jpg",
description:
"Bánh flan mềm mịn, ngọt ngào với lớp caramel vàng óng, tan chảy trong miệng.",
available: true,
},
{
id: 16,
name: "Trân Châu Đen",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description:
"Trân châu đen dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
available: true,
},
{
id: 17,
name: "Thạch Cà Phê",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description:
"Thạch cà phê mát lạnh, thêm hương vị đặc biệt cho đồ uống của bạn.",
available: true,
},
{
id: 18,
name: "Trân Châu Trắng",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
description:
"Trân châu trắng dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
available: true,
},
];
// ===== MOCK COMBOS =====
export const MOCK_COMBOS: Combo[] = [
{
id: 1,
name: "Combo Cà Phê Đôi",
description: "2 ly cà phê đen + 2 bánh mì nướng bơ, tiết kiệm 15%.",
price: 75000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 1, quantity: 2 },
{ productId: 14, quantity: 2 },
],
available: true,
},
{
id: 2,
name: "Combo Trà Sữa Nhóm",
description: "2 trà đào cam sả + 2 trà xanh matcha, dành cho nhóm bạn.",
price: 130000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 5, quantity: 2 },
{ productId: 6, quantity: 2 },
],
available: true,
},
{
id: 3,
name: "Combo Buổi Sáng",
description: "1 cà phê sữa + 1 bánh flan, khởi đầu ngày mới ngọt ngào.",
price: 48000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 2, quantity: 1 },
{ productId: 15, quantity: 1 },
],
available: false,
},
];
// ===== MOCK SHOPS (for Feed page) =====
export const MOCK_SHOPS: Shop[] = [
{
id: 1,
name: "The Coffee House",
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1554118811-1e0d58224f24?w=600&h=400&fit=crop",
},
{
id: 2,
name: "Highlands Coffee",
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1559305616-3f99cd43e353?w=600&h=400&fit=crop",
},
{
id: 3,
name: "Phúc Long Heritage",
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=600&h=400&fit=crop",
},
{
id: 4,
name: "Katinat Saigon Kafe",
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600&h=400&fit=crop",
},
{
id: 5,
name: "Trung Nguyên E-Coffee",
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
image:
"https://images.unsplash.com/photo-1453614512568-c4024d13c247?w=600&h=400&fit=crop",
},
];
// ===== MOCK FINANCIAL DATA =====
// Daily revenue for the last 30 days (current month)
@@ -67,34 +354,34 @@ export const MOCK_REVENUE_DAILY: RevenueDataPoint[] = [
// Weekly revenue (last 12 weeks)
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
{ label: "Jan/W1", revenue: 8200000, orders: 295 },
{ label: "Jan/W2", revenue: 9450000, orders: 340 },
{ label: "Jan/W3", revenue: 10100000, orders: 362 },
{ label: "Jan/W4", revenue: 8750000, orders: 315 },
{ label: "Feb/W1", revenue: 9200000, orders: 330 },
{ label: "Feb/W2", revenue: 10500000, orders: 378 },
{ label: "Feb/W3", revenue: 11200000, orders: 400 },
{ label: "Feb/W4", revenue: 9800000, orders: 352 },
{ label: "Mar/W1", revenue: 10400000, orders: 374 },
{ label: "Mar/W2", revenue: 11800000, orders: 424 },
{ label: "Mar/W3", revenue: 12500000, orders: 448 },
{ label: "Mar/W4", revenue: 10900000, orders: 392 },
{ label: "T1/W1", revenue: 8200000, orders: 295 },
{ label: "T1/W2", revenue: 9450000, orders: 340 },
{ label: "T1/W3", revenue: 10100000, orders: 362 },
{ label: "T1/W4", revenue: 8750000, orders: 315 },
{ label: "T2/W1", revenue: 9200000, orders: 330 },
{ label: "T2/W2", revenue: 10500000, orders: 378 },
{ label: "T2/W3", revenue: 11200000, orders: 400 },
{ label: "T2/W4", revenue: 9800000, orders: 352 },
{ label: "T3/W1", revenue: 10400000, orders: 374 },
{ label: "T3/W2", revenue: 11800000, orders: 424 },
{ label: "T3/W3", revenue: 12500000, orders: 448 },
{ label: "T3/W4", revenue: 10900000, orders: 392 },
];
// Monthly revenue (last 12 months)
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
{ label: "Apr/2025", revenue: 42000000, orders: 1512 },
{ label: "May/2025", revenue: 45500000, orders: 1638 },
{ label: "Jun/2025", revenue: 48000000, orders: 1728 },
{ label: "Jul/2025", revenue: 52000000, orders: 1872 },
{ label: "Aug/2025", revenue: 49500000, orders: 1782 },
{ label: "Sep/2025", revenue: 46800000, orders: 1685 },
{ label: "Oct/2025", revenue: 51200000, orders: 1843 },
{ label: "Nov/2025", revenue: 55000000, orders: 1980 },
{ label: "Dec/2025", revenue: 62000000, orders: 2232 },
{ label: "Jan/2026", revenue: 44000000, orders: 1584 },
{ label: "Feb/2026", revenue: 47500000, orders: 1710 },
{ label: "Mar/2026", revenue: 53500000, orders: 1926 },
{ label: "T4/2025", revenue: 42000000, orders: 1512 },
{ label: "T5/2025", revenue: 45500000, orders: 1638 },
{ label: "T6/2025", revenue: 48000000, orders: 1728 },
{ label: "T7/2025", revenue: 52000000, orders: 1872 },
{ label: "T8/2025", revenue: 49500000, orders: 1782 },
{ label: "T9/2025", revenue: 46800000, orders: 1685 },
{ label: "T10/2025", revenue: 51200000, orders: 1843 },
{ label: "T11/2025", revenue: 55000000, orders: 1980 },
{ label: "T12/2025", revenue: 62000000, orders: 2232 },
{ label: "T1/2026", revenue: 44000000, orders: 1584 },
{ label: "T2/2026", revenue: 47500000, orders: 1710 },
{ label: "T3/2026", revenue: 53500000, orders: 1926 },
];
// Yearly revenue (last 5 years)
@@ -106,13 +393,240 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
{ label: "2026", revenue: 180000000, orders: 6480 },
];
// Product sales statistics (with cost price for profit analysis)
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
{
productId: 12,
name: "Latte Caramel",
category: "latte",
unitsSold: 487,
revenue: 21915000,
costPrice: 18000,
sellingPrice: 45000,
profit: 13185000,
profitMargin: 60.2,
},
{
productId: 6,
name: "Trà Xanh Matcha",
category: "tra",
unitsSold: 412,
revenue: 16480000,
costPrice: 15000,
sellingPrice: 40000,
profit: 10300000,
profitMargin: 62.5,
},
{
productId: 5,
name: "Trà Đào Cam Sả",
category: "tra",
unitsSold: 398,
revenue: 13930000,
costPrice: 12000,
sellingPrice: 35000,
profit: 9153000,
profitMargin: 65.7,
},
{
productId: 13,
name: "Latte Vanilla",
category: "latte",
unitsSold: 356,
revenue: 16020000,
costPrice: 18000,
sellingPrice: 45000,
profit: 9612000,
profitMargin: 60.0,
},
{
productId: 4,
name: "Cà Phê Trứng",
category: "cafe",
unitsSold: 340,
revenue: 15300000,
costPrice: 16000,
sellingPrice: 45000,
profit: 9860000,
profitMargin: 64.4,
},
{
productId: 2,
name: "Cà Phê Sữa",
category: "cafe",
unitsSold: 325,
revenue: 9750000,
costPrice: 10000,
sellingPrice: 30000,
profit: 6500000,
profitMargin: 66.7,
},
{
productId: 3,
name: "Bạc Xỉu",
category: "cafe",
unitsSold: 298,
revenue: 9536000,
costPrice: 11000,
sellingPrice: 32000,
profit: 6259000,
profitMargin: 65.6,
},
{
productId: 1,
name: "Cà Phê Đen",
category: "cafe",
unitsSold: 285,
revenue: 7125000,
costPrice: 8000,
sellingPrice: 25000,
profit: 4845000,
profitMargin: 68.0,
},
{
productId: 9,
name: "Sữa Chua Dâu",
category: "sua-chua",
unitsSold: 267,
revenue: 10680000,
costPrice: 15000,
sellingPrice: 40000,
profit: 6675000,
profitMargin: 62.5,
},
{
productId: 10,
name: "Nước Ép Cam",
category: "nuoc-ep",
unitsSold: 241,
revenue: 8435000,
costPrice: 12000,
sellingPrice: 35000,
profit: 5543000,
profitMargin: 65.7,
},
{
productId: 8,
name: "Sữa Chua Trân Châu",
category: "sua-chua",
unitsSold: 228,
revenue: 8664000,
costPrice: 14000,
sellingPrice: 38000,
profit: 5472000,
profitMargin: 63.2,
},
{
productId: 7,
name: "Trà Vải Hoa Nhài",
category: "tra",
unitsSold: 215,
revenue: 8170000,
costPrice: 13000,
sellingPrice: 38000,
profit: 5375000,
profitMargin: 65.8,
},
{
productId: 15,
name: "Bánh Flan",
category: "giai-khat",
unitsSold: 198,
revenue: 4950000,
costPrice: 8000,
sellingPrice: 25000,
profit: 3366000,
profitMargin: 68.0,
},
{
productId: 11,
name: "Nước Ép Dưa Hấu",
category: "nuoc-ep",
unitsSold: 182,
revenue: 5460000,
costPrice: 10000,
sellingPrice: 30000,
profit: 3640000,
profitMargin: 66.7,
},
{
productId: 14,
name: "Bánh Mì Nướng Bơ",
category: "giai-khat",
unitsSold: 175,
revenue: 3500000,
costPrice: 6000,
sellingPrice: 20000,
profit: 2450000,
profitMargin: 70.0,
},
{
productId: 16,
name: "Trân Châu Đen",
category: "topping",
unitsSold: 456,
revenue: 4560000,
costPrice: 2000,
sellingPrice: 10000,
profit: 3648000,
profitMargin: 80.0,
},
{
productId: 17,
name: "Thạch Cà Phê",
category: "topping",
unitsSold: 389,
revenue: 3890000,
costPrice: 2000,
sellingPrice: 10000,
profit: 3112000,
profitMargin: 80.0,
},
{
productId: 18,
name: "Trân Châu Trắng",
category: "topping",
unitsSold: 342,
revenue: 3420000,
costPrice: 2000,
sellingPrice: 10000,
profit: 2736000,
profitMargin: 80.0,
},
];
// ===== MOCK USERS (for UI demo replace with real auth) =====
export const MOCK_USERS: Record<string, User> = {
manager: {
id: 1,
name: "Nguyễn Văn An",
role: "manager",
avatar: null,
},
staff: {
id: 2,
name: "Trần Thị Bình",
role: "staff",
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(): ShiftEntity[] {
const shifts: ShiftEntity[] = [];
const departments = ["waiter"];
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 },
@@ -121,9 +635,9 @@ function generateMockShifts(): ShiftEntity[] {
];
const staffPool = [
{ id: 2, name: "Alex Nguyen" },
{ id: 3, name: "Binh Tran" },
{ id: 4, name: "Cuong Le" },
{ 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
@@ -146,7 +660,7 @@ function generateMockShifts(): ShiftEntity[] {
// Determine registration status
const registered: { id: number; name: string }[] = [];
// let status: ShiftEntity["status"] = "available";
let status: ShiftSlot["status"] = "available";
// Past shifts (before April 10) are mostly registered
if (day < 10) {
@@ -155,37 +669,37 @@ function generateMockShifts(): ShiftEntity[] {
if (shiftCounter % 7 === 0) {
registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
}
// status = "registered";
status = "registered";
// Some past shifts have leave/absent
// if (shiftCounter % 11 === 0) status = "approved_leave";
// if (shiftCounter % 13 === 0) status = "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";
status = "registered";
}
}
// Future shifts: mostly available, some registered
else {
if (shiftCounter % 5 === 0) {
registered.push(staffPool[shiftCounter % staffPool.length]);
// status = "registered";
status = "registered";
}
}
shifts.push({
id: "shiftId",
date: new Date(dateStr),
id: shiftId,
date: dateStr,
startTime: slot.start,
endTime: slot.end,
// durationHours: slot.hours,
durationHours: slot.hours,
wage: slot.wage,
// department: dept,
department: dept,
maxStaff: dept === "bar" ? 3 : 2,
// registeredStaff: registered,
// status,
registeredStaff: registered,
status,
});
}
}
@@ -194,8 +708,4 @@ function generateMockShifts(): ShiftEntity[] {
return shifts;
}
export const MOCK_SHIFT_SLOTS: ShiftEntity[] = generateMockShifts();
export const DEPARTMENTS: Department[] = [
{ id: "waiter", name: "Bar Staff", icon: "fa-brands fa-jenkins" },
];
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
+101 -164
View File
@@ -1,212 +1,149 @@
"use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { ReactNode, createContext, useContext, useState } from "react";
import { eateryClient } from "./apollo-clients";
import {
EateryEntity,
type MenuItemEntity,
type addMenuItemMutation,
type allEateriesQuery,
deleteMenuItemMutation,
updateMenuItemMutation,
} from "./types";
import { MENU_CATEGORIES, MOCK_COMBOS, MOCK_PRODUCTS } from "./constants";
import type { Combo, ComboItem, MenuCategory, Product } from "./types";
// ─── Types ────────────────────────────────────────────────────────────────────
export type ManagerTab = "products" | "reviews";
export type ManagerTab = "products" | "combos" | "categories";
interface ManagerContextType {
// Data
products: MenuItemEntity[];
eateryId: string | null;
eatery?: EateryEntity;
products: Product[];
combos: Combo[];
categories: MenuCategory[];
// Active tab
activeTab: ManagerTab;
setActiveTab: (tab: ManagerTab) => void;
// MenuItemEntity actions
addProduct: (product: MenuItemEntity) => void;
updateProduct: (product: MenuItemEntity) => void;
deleteProduct: (id: string) => void;
toggleProductAvailability: (product: MenuItemEntity) => void;
// Product actions
addProduct: (product: Omit<Product, "id">) => void;
updateProduct: (product: Product) => void;
deleteProduct: (id: number) => void;
toggleProductAvailability: (id: number) => void;
// Combo actions
addCombo: (combo: Omit<Combo, "id">) => void;
updateCombo: (combo: Combo) => void;
deleteCombo: (id: number) => void;
toggleComboAvailability: (id: number) => void;
// Category actions
addCategory: (category: Omit<MenuCategory, "id">) => void;
updateCategory: (category: MenuCategory) => void;
deleteCategory: (id: string) => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const ManagerContext = createContext<ManagerContextType | undefined>(undefined);
// ___ Graphql __________________________________________________________________
const GET_EATERY_MENU = gql`
query GetEateryMenu {
allEateries {
id
name
menuItems {
id
name
imageUrl
available
description
price
}
}
}
`;
const ADD_MENU_ITEM = gql`
mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) {
id
name
imageUrl
available
description
price
}
}
`;
const UPDATE_MENU_ITEM = gql`
mutation updateMenuItem($menuItem: UpdateMenuItemInput!) {
updateMenuItem(menuItem: $menuItem) {
id
name
imageUrl
available
description
price
}
}
`;
const DELETE_MENU_ITEM = gql`
mutation deleteMenuItem($input: String!) {
deleteMenuItem(menuItemId: $input)
}
`;
// ─── Provider ─────────────────────────────────────────────────────────────────
export function ManagerProvider({ children }: { children: ReactNode }) {
const [products, setProducts] = useState<MenuItemEntity[]>([]);
const [eatery, setEatery] = useState<EateryEntity>();
const [products, setProducts] = useState<Product[]>(MOCK_PRODUCTS);
const [combos, setCombos] = useState<Combo[]>(MOCK_COMBOS);
// Filter out the "all" pseudo-category — managers manage real categories only
const [categories, setCategories] = useState<MenuCategory[]>(
MENU_CATEGORIES.filter((c) => c.id !== "all"),
);
const [activeTab, setActiveTab] = useState<ManagerTab>("products");
const { data } = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
client: eateryClient,
fetchPolicy: "network-only",
});
// ── Product actions ──────────────────────────────────────────────────────
const eateryId = data?.allEateries?.[0]?.id ?? null;
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
client: eateryClient,
});
const [mutateUpdateMenuItem] = useMutation<updateMenuItemMutation>(
UPDATE_MENU_ITEM,
{ client: eateryClient },
);
const [mutateDeleteMenuItem] = useMutation<deleteMenuItemMutation>(
DELETE_MENU_ITEM,
{ client: eateryClient },
);
useEffect(() => {
if (data?.allEateries?.[0]) {
setProducts(data.allEateries[0].menuItems);
setEatery(data.allEateries[0]);
}
}, [data]);
// ── MenuItemEntity actions ──────────────────────────────────────────────────────
const addProduct = async (product: MenuItemEntity) => {
const { data } = await mutateAddMenuItem({
variables: {
menuItem: product,
},
});
if (!data) return;
// addMenuItem backend does not persist imageUrl (constructor only maps name+price).
// If the caller provided an imageUrl, patch it immediately via updateMenuItem
// which uses MapStruct and correctly saves all fields.
if (product.imageUrl && data.addMenuItem?.id) {
const { data: updated } = await mutateUpdateMenuItem({
variables: {
menuItem: { id: data.addMenuItem.id, imageUrl: product.imageUrl },
},
});
if (updated) {
setProducts((prev) => [...prev, updated.updateMenuItem]);
return;
}
}
setProducts((prev) => [...prev, data.addMenuItem]);
const addProduct = (product: Omit<Product, "id">) => {
const newProduct: Product = {
...product,
id: Date.now(),
};
setProducts((prev) => [...prev, newProduct]);
};
const updateProduct = async (product: MenuItemEntity) => {
const { data } = await mutateUpdateMenuItem({
variables: {
menuItem: product,
},
});
if (data)
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
);
const updateProduct = (product: Product) => {
setProducts((prev) => prev.map((p) => (p.id === product.id ? product : p)));
};
const deleteProduct = async (id: string) => {
const { data } = await mutateDeleteMenuItem({ variables: { input: id } });
if (data)
setProducts((prev) =>
prev.filter((p) => !(p.id == id && data.deleteMenuItem)),
);
const deleteProduct = (id: number) => {
setProducts((prev) => prev.filter((p) => p.id !== id));
};
const toggleProductAvailability = async (product: MenuItemEntity) => {
const { data } = await mutateUpdateMenuItem({
variables: {
menuItem: { ...product, available: !product.available },
},
});
const toggleProductAvailability = (id: number) => {
setProducts((prev) =>
prev.map((p) =>
p.id === id ? { ...p, available: !(p.available ?? true) } : p,
),
);
};
if (data)
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
);
// ── Combo actions ────────────────────────────────────────────────────────
const addCombo = (combo: Omit<Combo, "id">) => {
const newCombo: Combo = { ...combo, id: Date.now() };
setCombos((prev) => [...prev, newCombo]);
};
const updateCombo = (combo: Combo) => {
setCombos((prev) => prev.map((c) => (c.id === combo.id ? combo : c)));
};
const deleteCombo = (id: number) => {
setCombos((prev) => prev.filter((c) => c.id !== id));
};
const toggleComboAvailability = (id: number) => {
setCombos((prev) =>
prev.map((c) => (c.id === id ? { ...c, available: !c.available } : c)),
);
};
// ── Category actions ─────────────────────────────────────────────────────
const addCategory = (category: Omit<MenuCategory, "id">) => {
const slug = category.name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const newCategory: MenuCategory = {
...category,
id: `${slug}-${Date.now()}`,
};
setCategories((prev) => [...prev, newCategory]);
};
const updateCategory = (category: MenuCategory) => {
setCategories((prev) =>
prev.map((c) => (c.id === category.id ? category : c)),
);
};
const deleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
};
return (
<ManagerContext.Provider
value={{
eatery,
products,
eateryId,
combos,
categories,
activeTab,
setActiveTab,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
addCategory,
updateCategory,
deleteCategory,
}}
>
{children}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { createContext, useContext, useState } from "react";
interface MenuContextType {
/** Currently selected category id */
activeCategory: string;
/** Update the active category */
setActiveCategory: (id: string) => void;
}
const MenuContext = createContext<MenuContextType>({
activeCategory: "all",
setActiveCategory: () => {},
});
/**
* Provides shared activeCategory state to both the Header (mobile scrollable menu)
* and the Navbar sidebar (md+), so both always reflect the same selection.
*/
export function MenuProvider({ children }: { children: React.ReactNode }) {
const [activeCategory, setActiveCategory] = useState("all");
return (
<MenuContext.Provider value={{ activeCategory, setActiveCategory }}>
{children}
</MenuContext.Provider>
);
}
/** Consume the shared menu state anywhere inside MenuProvider */
export function useMenu() {
return useContext(MenuContext);
}

Some files were not shown because too many files have changed in this diff Show More