Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e20bd33541 | |||
| 2376d57cbb | |||
| 7b4e46c4e4 | |||
| 3a5cfd0494 | |||
| 27bb95dea5 | |||
| 9f8695a870 | |||
| b37bf5d088 | |||
| 378e381454 | |||
| 8e9e48d9b4 | |||
| 43658ace21 | |||
| 9ed4889310 | |||
| dfcb1b09c0 | |||
| 877c7be84b | |||
| ae8134fd64 | |||
| edbf675997 | |||
| c2afb3d3b5 | |||
| 48fc033ffa | |||
| 8b3b8946ab | |||
| 46d366a662 |
@@ -14,7 +14,10 @@
|
||||
"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(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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
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
|
||||
// 3–7 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
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
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.
|
||||
@@ -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,9 +73,7 @@ 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
|
||||
|
||||
@@ -85,9 +83,8 @@ Then you get a rewritten prompt combining all your choices.
|
||||
|
||||
- **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
|
||||
@@ -110,3 +107,65 @@ 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 20–40%.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["esbenp.prettier-vscode"]
|
||||
"extensions": ["esbenp.prettier-vscode", "anthropic.claude-code"]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -22,9 +22,16 @@ jobs:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: gerlero/apt-install@v1
|
||||
with:
|
||||
packages: docker.io zip
|
||||
- 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
|
||||
|
||||
- name: Setup SSH Key
|
||||
run: |
|
||||
@@ -41,9 +48,21 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
version: 10.9.0
|
||||
run_install: false
|
||||
cache: true
|
||||
|
||||
- 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-
|
||||
|
||||
- name: Update dependencies
|
||||
run: pnpm update
|
||||
@@ -76,7 +95,6 @@ jobs:
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Prepare Docker Metadata
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
id: meta
|
||||
run: |
|
||||
# Lowercase Repository
|
||||
@@ -89,11 +107,10 @@ 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: true
|
||||
push: ${{ github.ref_name == 'main' }}
|
||||
tags: |
|
||||
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
||||
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -1,103 +1,12 @@
|
||||
# Coffee Shop Frontend - TODO
|
||||
# TODO - Add image upload field for manager add/update menu item
|
||||
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
## Pending Features (Future Work)
|
||||
|
||||
### Cart & Ordering
|
||||
|
||||
- [ ] Implement cart checkout flow (app/(main)/cart or modal)
|
||||
- [ ] Cart sidebar/modal with item list and total
|
||||
- [ ] Order submission API integration
|
||||
- [ ] Payment page implementation (app/(main)/payment)
|
||||
- [ ] Order history/tracking page
|
||||
- [ ] 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
|
||||
|
||||
- [ ] Manager dashboard page (app/(manager)/page.tsx)
|
||||
- [ ] Product management (add/edit/delete)
|
||||
- [ ] Category management
|
||||
- [ ] Order management & tracking
|
||||
- [ ] Sales analytics/dashboard
|
||||
- [ ] Inventory management
|
||||
|
||||
### 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 (replace placeholder.jpg)
|
||||
- [ ] Image upload for products
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
- [ ] ARIA labels audit
|
||||
- [ ] Unit tests for contexts
|
||||
- [ ] E2E tests for user flows
|
||||
- [ ] Accessibility audit (WCAG 2.1 AA)
|
||||
- [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
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"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 và 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { FeedLayout } from "@/components/templates/feed-layout";
|
||||
|
||||
export default function RootFeedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <FeedLayout>{children}</FeedLayout>;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { MainLayout } from "@/components/templates/main-layout";
|
||||
import { ManagerProvider } from "@/lib/manager-context";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MainLayout>{children}</MainLayout>;
|
||||
return (
|
||||
<MainLayout>
|
||||
<ManagerProvider>{children}</ManagerProvider>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -25,39 +25,12 @@ export default function LoginPage() {
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Đăng nhập vào hệ thống
|
||||
Enter your phone number to sign in
|
||||
</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 lý:{" "}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+75
-39
@@ -1,25 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { SearchBar } from "@/components/molecules/search-bar";
|
||||
import { CategorySidebar } from "@/components/organisms/navigation";
|
||||
import { ProductGrid } from "@/components/organisms/product-grid";
|
||||
import { MENU_CATEGORIES } from "@/lib/constants";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
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 { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* 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 { activeCategory, setActiveCategory } = useMenu();
|
||||
const GET_EATERY_COUNT = gql`
|
||||
{
|
||||
allEateries {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function StaffHomePage() {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -32,44 +31,81 @@ export default function Home() {
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearchQuery("");
|
||||
}, [activeCategory]);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const activeCategoryLabel =
|
||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { user, isInitialized } = useAuth();
|
||||
|
||||
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
|
||||
setIsSidebarOpen(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setIsSidebarOpen(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!isInitialized) return <div>Loading...</div>;
|
||||
|
||||
if (user?.role === "staff") return <StaffHomePage />;
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
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="Tìm kiếm món..."
|
||||
placeholder="Search items..."
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+46
-40
@@ -2,27 +2,33 @@
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard";
|
||||
import ReviewModal from "@/components/organisms/modals/ReviewModal";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { useState } from "react";
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
import { MenuItemEntity } from "@/lib/types";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
export const formatPrice = (value?: number) =>
|
||||
(value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
export default function PaymentPage() {
|
||||
const {
|
||||
items,
|
||||
totalPrice,
|
||||
eateryId,
|
||||
increaseQty,
|
||||
decreaseQty,
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
} = useCart();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const isCustomer = user?.role === "customer";
|
||||
const { products } = useManager();
|
||||
|
||||
const findProduct = (id: string): MenuItemEntity =>
|
||||
products.find((i) => i.id == id) ??
|
||||
({
|
||||
name: "Unknown product",
|
||||
description: "",
|
||||
price: 0,
|
||||
} as MenuItemEntity);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -32,13 +38,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">
|
||||
Trang thanh toán
|
||||
Payment
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
{items?.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-(--color-text-muted)">
|
||||
Chưa có sản phẩm nào trong giỏ hàng.
|
||||
Your cart is empty.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -46,63 +52,66 @@ export default function PaymentPage() {
|
||||
<thead>
|
||||
<tr className="bg-(--color-border-light)/40 text-left">
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Tên sản phẩm
|
||||
Product name
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Giá tiền
|
||||
Price
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Mô tả
|
||||
Description
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Số lượng
|
||||
Quantity
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-3 text-right font-semibold"
|
||||
>
|
||||
Xóa
|
||||
Delete
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
{items?.map(
|
||||
({
|
||||
productId: id,
|
||||
priceAtTimeOfAdding: price,
|
||||
quantity,
|
||||
}) => {
|
||||
const { name, description } = findProduct(id);
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-(--color-border-light)"
|
||||
key={id}
|
||||
className={`border-t border-(--color-border-light) ${quantity == 0 ? "hidden" : ""}`}
|
||||
>
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{item.name}
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
{formatPrice(price)}
|
||||
</td>
|
||||
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
|
||||
<p className="line-clamp-2">{item.description}</p>
|
||||
<p className="line-clamp-2">{description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decreaseQty(item.id)}
|
||||
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)"
|
||||
aria-label={`Giảm số lượng ${item.name}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
value={quantity}
|
||||
onChange={(e) =>
|
||||
setQuantity(item.id, Number(e.target.value))
|
||||
setQuantity(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)}
|
||||
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)"
|
||||
aria-label={`Tăng số lượng ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -110,17 +119,18 @@ export default function PaymentPage() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
onClick={() => removeFromCart(id)}
|
||||
variant="danger"
|
||||
size="md"
|
||||
style="payment"
|
||||
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
|
||||
>
|
||||
Xóa sản phẩm
|
||||
Delete product
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
},
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -130,16 +140,12 @@ export default function PaymentPage() {
|
||||
|
||||
<PaymentSummaryCard
|
||||
totalPrice={totalPrice}
|
||||
isCustomer={isCustomer}
|
||||
isCustomer={true}
|
||||
backHref="/"
|
||||
eateryId={eateryId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+147
-32
@@ -6,19 +6,48 @@ import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, useEffect, 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 { completeRegistration } = useAuth();
|
||||
const { setUser } = 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 => {
|
||||
@@ -28,43 +57,87 @@ export default function RegisterPage() {
|
||||
return phoneRegex.test(phoneNumber);
|
||||
};
|
||||
|
||||
const handlePhoneSubmit = (e: FormEvent) => {
|
||||
const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!phone.trim()) {
|
||||
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
|
||||
setErrors({ ...errors, phone: "Please enter your phone number" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setErrors({
|
||||
...errors,
|
||||
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
|
||||
phone: "Invalid phone number (e.g. 0987654321)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to OTP step
|
||||
setStep("otp");
|
||||
setIsLoading(true);
|
||||
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 = (e: FormEvent) => {
|
||||
const handleOtpSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
|
||||
setErrors({ ...errors, otp: "Please enter your OTP code" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (otp !== DEMO_OTP) {
|
||||
setErrors({ ...errors, otp: "Mã OTP không đúng" });
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setErrors({ phone: "", otp: "" });
|
||||
|
||||
// Complete registration
|
||||
completeRegistration(phone);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToPhone = () => {
|
||||
@@ -94,8 +167,8 @@ export default function RegisterPage() {
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
{step === "phone"
|
||||
? "Đăng ký tài khoản khách hàng"
|
||||
: "Xác thực số điện thoại"}
|
||||
? "Create a customer account"
|
||||
: "Verify your phone number"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +204,7 @@ export default function RegisterPage() {
|
||||
htmlFor="phone"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Số điện thoại
|
||||
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>
|
||||
@@ -144,7 +217,8 @@ export default function RegisterPage() {
|
||||
setErrors({ ...errors, phone: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
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)"} `}
|
||||
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 && (
|
||||
@@ -154,7 +228,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||
Nhập số điện thoại Việt Nam (10 số, bắt đầu bằng 0)
|
||||
Enter a Vietnamese phone number (10 digits, starting with 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -166,8 +240,16 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Tiếp tục
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to Login */}
|
||||
@@ -175,7 +257,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"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
@@ -188,7 +270,16 @@ 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>
|
||||
Mã OTP đã được gửi đến số <strong>{phone}</strong>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">
|
||||
Demo OTP:{" "}
|
||||
@@ -197,6 +288,12 @@ 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>
|
||||
@@ -204,7 +301,7 @@ export default function RegisterPage() {
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Mã OTP
|
||||
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>
|
||||
@@ -216,9 +313,10 @@ export default function RegisterPage() {
|
||||
setOtp(e.target.value);
|
||||
setErrors({ ...errors, otp: "" });
|
||||
}}
|
||||
placeholder="Nhập mã OTP"
|
||||
placeholder="Enter OTP code"
|
||||
maxLength={6}
|
||||
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)"} `}
|
||||
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 && (
|
||||
@@ -237,8 +335,16 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading || otpSending}
|
||||
>
|
||||
Hoàn tất đăng ký
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Complete registration"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back Button */}
|
||||
@@ -247,19 +353,28 @@ export default function RegisterPage() {
|
||||
onClick={handleBackToPhone}
|
||||
size="lg"
|
||||
style="login"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Thay đổi số điện thoại
|
||||
Change phone number
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP (disabled in demo) */}
|
||||
{/* Resend OTP */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="cursor-not-allowed text-sm text-(--color-text-muted)"
|
||||
onClick={sendOtp}
|
||||
disabled={otpSending || isLoading}
|
||||
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
|
||||
>
|
||||
Gửi lại mã OTP (60s)
|
||||
{otpSending ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
|
||||
Resending...
|
||||
</>
|
||||
) : (
|
||||
"Resend OTP code"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
"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 kê & 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]}
|
||||
</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 và nửa sau kỳ hiện tại
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+209
-56
@@ -1,43 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CategoriesTab,
|
||||
CombosTab,
|
||||
ProductsTab,
|
||||
} from "@/components/organisms/manager";
|
||||
import { ProductsTab, ReviewsTab } 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, combos, categories } =
|
||||
useManager();
|
||||
const { activeTab, setActiveTab, products } = useManager();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "products" as const,
|
||||
label: "Thực đơn",
|
||||
label: "Menu",
|
||||
icon: "fa-solid fa-utensils",
|
||||
count: products.length,
|
||||
},
|
||||
{
|
||||
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,
|
||||
id: "reviews" as const,
|
||||
label: "Reviews",
|
||||
icon: "fa-solid fa-star",
|
||||
count: null,
|
||||
},
|
||||
];
|
||||
|
||||
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 ── */}
|
||||
{/* ── Sidebar (lg+) ── */}
|
||||
<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)">
|
||||
@@ -51,7 +56,7 @@ 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">
|
||||
Quản lý thực đơn
|
||||
Menu Management
|
||||
</p>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
@@ -65,6 +70,7 @@ 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
|
||||
@@ -74,20 +80,21 @@ export default function ManagerPage() {
|
||||
>
|
||||
{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">
|
||||
Phân tích
|
||||
Analytics
|
||||
</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)"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Tài chính</span>
|
||||
<span className="flex-1 text-left">Finance</span>
|
||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||
Mới
|
||||
New
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
@@ -95,12 +102,24 @@ export default function ManagerPage() {
|
||||
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-calendar-days w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Ca làm</span>
|
||||
<span className="flex-1 text-left">Shifts</span>
|
||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||
Mới
|
||||
New
|
||||
</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">
|
||||
@@ -110,9 +129,9 @@ export default function ManagerPage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-semibold">
|
||||
{user?.name ?? "Quản lý"}
|
||||
{user?.name ?? "Manager"}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">Quản lý quán</p>
|
||||
<p className="text-xs text-(--color-text-muted)">Store Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2 px-1">
|
||||
@@ -121,14 +140,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>
|
||||
Trang chủ
|
||||
Home
|
||||
</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"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
Đăng xuất
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,24 +155,23 @@ 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 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ý"}
|
||||
<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"}
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Quản lý{" "}
|
||||
<p className="truncate text-xs text-(--color-text-muted)">
|
||||
{activeTab === "products"
|
||||
? "thực đơn"
|
||||
: activeTab === "combos"
|
||||
? "combo"
|
||||
: "danh mục"}{" "}
|
||||
của quán
|
||||
? "Manage menu items for your store"
|
||||
: "Customer reviews for your eatery"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex items-center gap-1 lg:hidden">
|
||||
{/* ── Actions: hidden on lg+ (sidebar handles nav there) ── */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 lg:hidden">
|
||||
{/* sm–lg: inline icon+label buttons */}
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -165,41 +183,176 @@ export default function ManagerPage() {
|
||||
}`}
|
||||
>
|
||||
<i className={tab.icon}></i>
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<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:bg-(--color-accent-light)/70"
|
||||
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 className="hidden sm:inline">Tài chính</span>
|
||||
<span>Finance</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
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-chart-line"></i>
|
||||
Thống kê tài chính
|
||||
<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>
|
||||
Trang chủ
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
<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"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
<span>Logout</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>
|
||||
</div>
|
||||
|
||||
{/* ── Desktop actions (lg+): sidebar handles nav, just show shortcut ── */}
|
||||
<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
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-5 md:p-8">
|
||||
{activeTab === "products" && <ProductsTab />}
|
||||
{activeTab === "combos" && <CombosTab />}
|
||||
{activeTab === "categories" && <CategoriesTab />}
|
||||
{activeTab === "reviews" && <ReviewsTab />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 { ShiftSlot } from "@/lib/types";
|
||||
import type { ShiftEntity } from "@/lib/types";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
@@ -50,240 +50,292 @@ export default function StaffSchedulePage() {
|
||||
goToPrevMonth,
|
||||
goToToday,
|
||||
getWeeklyBudget,
|
||||
shifts,
|
||||
} = useShift();
|
||||
|
||||
const [selectedShift, setSelectedShift] = useState<ShiftSlot | null>(null);
|
||||
const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDate, setCreateDate] = useState<string | undefined>();
|
||||
const [createDate, setCreateDate] = useState<Date | undefined>();
|
||||
|
||||
const isManager = user?.role === "manager";
|
||||
|
||||
const handleShiftClick = (shift: ShiftSlot) => {
|
||||
const handleShiftClick = (shift: ShiftEntity) => {
|
||||
setSelectedShift(shift);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateShift = (date: string) => {
|
||||
const handleCreateShift = (date: Date) => {
|
||||
setCreateDate(date);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: string) => {
|
||||
// In month view on desktop, clicking a date could open create modal for managers
|
||||
const handleDateSelect = (date: Date) => {
|
||||
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">
|
||||
{/* ── Sidebar (Desktop) ── */}
|
||||
<div className="flex min-h-screen bg-gray-50/50">
|
||||
{/* ── Sidebar (Desktop) ─────────────────────────────────────────────────── */}
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-(--color-border-light) bg-white shadow-sm lg:flex">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-3 border-b border-(--color-border-light) px-5 py-5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-primary)">
|
||||
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
|
||||
{/* 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-foreground text-sm font-bold">Lịch làm việc</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{isManager ? "Manager" : "Staff"}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-3">
|
||||
{/* 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 className="mb-1 px-3 pt-2 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
|
||||
View
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("week")}
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
|
||||
view === "week"
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
|
||||
? "bg-(--color-primary)/10 text-(--color-primary) shadow-sm"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-gray-50 hover:text-(--color-primary-dark)"
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-table-columns w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Theo tuần</span>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("month")}
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition-all ${
|
||||
view === "month"
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)"
|
||||
? "bg-(--color-primary)/10 text-(--color-primary) shadow-sm"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-gray-50 hover:text-(--color-primary-dark)"
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-calendar w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Theo tháng</span>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Quick nav */}
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Điều hướng
|
||||
{/* 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
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToToday}
|
||||
className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)"
|
||||
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)"
|
||||
>
|
||||
<i className="fa-solid fa-crosshairs w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Hôm nay</span>
|
||||
<span className="flex-1 text-left">Today</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Manager link */}
|
||||
{isManager && (
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Quản lý
|
||||
<div className="mt-2 pt-2">
|
||||
<p className="mb-1 px-3 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
|
||||
Manager
|
||||
</p>
|
||||
<Link
|
||||
href="/manager"
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
||||
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)"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
{/* 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-lg font-bold text-(--color-primary)">
|
||||
{weeklyBudget.toLocaleString("vi-VN")}
|
||||
<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
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-bold text-(--color-text-secondary)">
|
||||
{totalShiftsThisWeek}
|
||||
</p>
|
||||
<p className="text-[10px] text-(--color-text-muted)">shifts</p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<div className="border-t border-(--color-border-light) p-3">
|
||||
<div className="flex items-center gap-3 rounded-xl p-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
||||
<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">
|
||||
<i
|
||||
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
|
||||
></i>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-semibold">
|
||||
{user?.name ?? "Nhân viên"}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{isManager ? "Quản lý" : "Nhân viên"}
|
||||
{user?.name ?? "Staff"}
|
||||
</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-1 flex gap-2 px-1">
|
||||
<div className="mt-2 flex gap-2 px-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
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"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
Trang chủ
|
||||
<i className="fa-solid fa-house text-[10px]"></i>
|
||||
Home
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
Đăng xuất
|
||||
<i className="fa-solid fa-right-from-bracket text-[10px]"></i>
|
||||
Logout
|
||||
</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 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">
|
||||
<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">
|
||||
Đăng ký ca làm
|
||||
{isManager ? "Manage Shifts" : "Register Shifts"}
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{view === "week"
|
||||
? weekLabel
|
||||
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
|
||||
? `Week: ${weekLabel}`
|
||||
: `Month: ${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">
|
||||
{/* 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-medium transition ${
|
||||
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)"
|
||||
}`}
|
||||
>
|
||||
Tuần
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("month")}
|
||||
className={`cursor-pointer rounded-r-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
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)"
|
||||
}`}
|
||||
>
|
||||
Tháng
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{/* Desktop navigation arrows */}
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<button
|
||||
title="Về trước"
|
||||
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-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
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-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||
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)"
|
||||
>
|
||||
Hôm nay
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
title="Tiếp theo"
|
||||
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-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
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) */}
|
||||
{/* Create shift (manager, desktop) */}
|
||||
{isManager && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -291,17 +343,17 @@ export default function StaffSchedulePage() {
|
||||
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"
|
||||
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>
|
||||
Tạo ca
|
||||
Create shift
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile nav */}
|
||||
{/* Mobile navigation arrows */}
|
||||
<div className="flex items-center gap-1 md:hidden">
|
||||
<button
|
||||
title="Về trước"
|
||||
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)"
|
||||
@@ -309,7 +361,7 @@ export default function StaffSchedulePage() {
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
title="Tiếp theo"
|
||||
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)"
|
||||
@@ -318,11 +370,12 @@ export default function StaffSchedulePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
{/* Desktop views */}
|
||||
{/* Desktop */}
|
||||
<div className="hidden md:block">
|
||||
{view === "week" ? (
|
||||
<WeeklySchedule
|
||||
@@ -337,7 +390,7 @@ export default function StaffSchedulePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile view */}
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{view === "week" ? (
|
||||
<WeeklySchedule
|
||||
@@ -350,16 +403,16 @@ export default function StaffSchedulePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for manager */}
|
||||
{/* FAB for mobile manager */}
|
||||
{isManager && (
|
||||
<button
|
||||
title="Tạo ca"
|
||||
title="Create shift"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateDate(undefined);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
className="fixed right-4 bottom-4 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-lg transition hover:opacity-90 md:hidden"
|
||||
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"
|
||||
>
|
||||
<i className="fa-solid fa-plus text-lg"></i>
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { CartProvider } from "@/lib/cart-context";
|
||||
import { MenuProvider } from "@/lib/menu-context";
|
||||
|
||||
/**
|
||||
* Client-side providers wrapper.
|
||||
@@ -12,9 +11,7 @@ import { MenuProvider } from "@/lib/menu-context";
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MenuProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</MenuProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: "primary" | "secondary" | "success" | "danger" | "warning";
|
||||
size?: "sm" | "md";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as Badge } from "./Badge";
|
||||
export { default as PriceBadge } from "./PriceBadge";
|
||||
export type { BadgeProps } from "./Badge.types";
|
||||
export type { PriceBadgeProps } from "./PriceBadge";
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonProps } from "./Button.types";
|
||||
|
||||
export default function IconButton({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
icon,
|
||||
disabled = false,
|
||||
className = "",
|
||||
children,
|
||||
style: _style,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
|
||||
|
||||
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
primary:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
secondary:
|
||||
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
|
||||
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
|
||||
primaryNoBorder:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
bgWhite:
|
||||
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 w-8 text-sm",
|
||||
md: "h-10 w-10 text-base",
|
||||
lg: "h-12 w-12 text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,13 @@ export { Button } from "./buttons";
|
||||
export type { ButtonProps } from "./buttons";
|
||||
|
||||
// Inputs
|
||||
export { TextInput, SearchInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs";
|
||||
export { TextInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, 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";
|
||||
|
||||
@@ -13,17 +13,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"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,9 +1,3 @@
|
||||
export { default as TextInput } from "./TextInput";
|
||||
export { default as SearchInput } from "./SearchInput";
|
||||
export { default as Textarea } from "./Textarea";
|
||||
export type {
|
||||
TextInputProps,
|
||||
SearchInputProps,
|
||||
TextareaProps,
|
||||
LoginInputProps,
|
||||
} from "./Input.types";
|
||||
export type { TextInputProps, TextareaProps } from "./Input.types";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Product } from "@/lib/types";
|
||||
|
||||
export interface ProductCardProps {
|
||||
image: string;
|
||||
imageAlt?: string;
|
||||
@@ -21,5 +19,6 @@ export interface PaymentSummaryCardProps {
|
||||
totalPrice: number;
|
||||
isCustomer: boolean;
|
||||
backHref: string;
|
||||
eateryId?: string;
|
||||
onPay?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { formatPrice } from "@/app/(main)/payment/page";
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { ReviewModal } from "@/components/organisms/modals";
|
||||
import { cartClient } from "@/lib/apollo-clients";
|
||||
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";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
export default function PaymentSummaryCard({
|
||||
totalPrice,
|
||||
isCustomer = false,
|
||||
backHref,
|
||||
eateryId,
|
||||
}: PaymentSummaryCardProps) {
|
||||
const { cart, removeCart } = useCart();
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const [isQrModalOpen, setIsQrModalOpen] = useState(false);
|
||||
const handlePayment = () => {
|
||||
// UI-only: open review modal after "payment"
|
||||
if (isCustomer) {
|
||||
setIsReviewOpen(true);
|
||||
}
|
||||
@@ -23,10 +27,10 @@ export default function PaymentSummaryCard({
|
||||
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">Hóa đơn</h2>
|
||||
<h2 className="mb-4 text-lg font-bold">Bill</h2>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
|
||||
<span className="text-(--color-text-muted)">Tổng cộng</span>
|
||||
<span className="text-(--color-text-muted)">Total</span>
|
||||
<span className="text-xl font-bold text-(--color-primary)">
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
@@ -40,12 +44,14 @@ export default function PaymentSummaryCard({
|
||||
size="md"
|
||||
variant="primary"
|
||||
>
|
||||
Tiền mặt
|
||||
Cash
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={handlePayment}
|
||||
onClick={() => {
|
||||
setIsQrModalOpen(true);
|
||||
}}
|
||||
icon="fa-solid fa-qrcode"
|
||||
size="md"
|
||||
variant="secondary"
|
||||
@@ -53,22 +59,7 @@ export default function PaymentSummaryCard({
|
||||
QR Code
|
||||
</Button>
|
||||
|
||||
{isCustomer && (
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={handlePayment}
|
||||
icon="fa-solid fa-star"
|
||||
size="md"
|
||||
variant="primary"
|
||||
>
|
||||
Đánh giá
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={backHref || "/"}
|
||||
className={isCustomer ? "" : "col-span-2"}
|
||||
>
|
||||
<Link href={backHref || "/"} className={"col-span-2"}>
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={() => setIsReviewOpen(false)}
|
||||
@@ -77,7 +68,7 @@ export default function PaymentSummaryCard({
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
Quay về
|
||||
Return
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -85,8 +76,51 @@ export default function PaymentSummaryCard({
|
||||
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
onClose={() => {
|
||||
setIsReviewOpen(false);
|
||||
|
||||
removeCart();
|
||||
}}
|
||||
eateryId={eateryId}
|
||||
/>
|
||||
|
||||
{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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
Mua
|
||||
Buy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,62 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import type { ShiftSlot } from "@/lib/types";
|
||||
|
||||
import type { ShiftCardProps } from "./ShiftCard.types";
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
ShiftSlot["status"],
|
||||
{ bg: string; text: string; label: string }
|
||||
> = {
|
||||
available: {
|
||||
bg: "bg-blue-50 border-blue-200",
|
||||
text: "text-blue-700",
|
||||
label: "Còn trống",
|
||||
},
|
||||
registered: {
|
||||
bg: "bg-blue-100 border-blue-400",
|
||||
text: "text-blue-900",
|
||||
label: "Đã đăng ký",
|
||||
},
|
||||
approved_leave: {
|
||||
bg: "bg-purple-50 border-purple-300",
|
||||
text: "text-purple-700",
|
||||
label: "Nghỉ phép",
|
||||
},
|
||||
absent: {
|
||||
bg: "bg-red-50 border-red-300",
|
||||
text: "text-red-700",
|
||||
label: "Vắng mặt",
|
||||
},
|
||||
};
|
||||
|
||||
function formatWage(wage: number): string {
|
||||
if (wage >= 1000) {
|
||||
return `${(wage / 1000).toFixed(0)}k`;
|
||||
}
|
||||
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",
|
||||
},
|
||||
afternoon: {
|
||||
border: "border-l-amber-500",
|
||||
text: "text-amber-700",
|
||||
badge: "bg-amber-100 text-amber-700",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
evening: {
|
||||
border: "border-l-violet-500",
|
||||
text: "text-violet-700",
|
||||
badge: "bg-violet-100 text-violet-700",
|
||||
dot: "bg-violet-400",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ShiftCard({
|
||||
shift,
|
||||
compact = false,
|
||||
onClick,
|
||||
}: ShiftCardProps) {
|
||||
const style = STATUS_STYLES[shift.status];
|
||||
const registeredCount = shift.registeredStaff?.length ?? 0;
|
||||
const period = getShiftPeriod(shift.startTime);
|
||||
const s = PERIOD_STYLES[period];
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(shift)}
|
||||
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm ${style.bg} ${style.text}`}
|
||||
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}`}
|
||||
>
|
||||
<p className="font-semibold">
|
||||
<div className="px-2.5 py-2">
|
||||
<p className={`text-xs leading-tight font-bold ${s.text}`}>
|
||||
{shift.startTime}–{shift.endTime}
|
||||
</p>
|
||||
<p className="mt-0.5 opacity-75">
|
||||
{shift.durationHours}h · {formatWage(shift.wage)}
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -65,55 +77,46 @@ export default function ShiftCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(shift)}
|
||||
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md ${style.bg} ${style.text}`}
|
||||
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}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-bold">
|
||||
<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>
|
||||
<p className="mt-1 text-xs opacity-75">
|
||||
{shift.durationHours}h · {formatWage(shift.wage)} VND
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-(--color-text-muted)">
|
||||
{(shift.wage ?? 0).toLocaleString("vi-VN")} VND/ca
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||
shift.status === "available"
|
||||
? "bg-blue-200 text-blue-800"
|
||||
: shift.status === "registered"
|
||||
? "bg-blue-300 text-blue-900"
|
||||
: shift.status === "approved_leave"
|
||||
? "bg-purple-200 text-purple-800"
|
||||
: "bg-red-200 text-red-800"
|
||||
}`}
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${s.badge}`}
|
||||
>
|
||||
{style.label}
|
||||
{registeredCount}/{shift.maxStaff} staff
|
||||
</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})
|
||||
{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="mt-1 flex flex-wrap gap-1">
|
||||
{shift.registeredStaff.map((s) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shift.registeredStaff!.map((staff) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
|
||||
key={staff.id}
|
||||
className="rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
{s.name}
|
||||
{staff.staffId}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ShiftSlot } from "@/lib/types";
|
||||
import type { ShiftEntity } from "@/lib/types";
|
||||
|
||||
export interface ShiftCardProps {
|
||||
shift: ShiftSlot;
|
||||
shift: ShiftEntity;
|
||||
compact?: boolean;
|
||||
onClick?: (shift: ShiftSlot) => void;
|
||||
onClick?: (shift: ShiftEntity) => void;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Xem menu
|
||||
View menu
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
placeholder = "Tìm kiếm...",
|
||||
placeholder = "Search...",
|
||||
className = "",
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
@@ -17,14 +17,14 @@ export default function SearchBar({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label="Tìm kiếm món ăn"
|
||||
aria-label="Search items"
|
||||
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="Xóa tìm kiếm"
|
||||
aria-label="Xóa tìm kiếm"
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
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>
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
@@ -1,122 +1,131 @@
|
||||
"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 { SubmitEvent, useState } from "react";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "",
|
||||
});
|
||||
const [phone, setPhone] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ phone: "", general: "" });
|
||||
|
||||
const validate = (): boolean => {
|
||||
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.trim()) {
|
||||
setErrors({ phone: "Please enter your phone number", 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;
|
||||
if (!PHONE_REGEX.test(phone)) {
|
||||
setErrors({
|
||||
phone: "Invalid phone number (e.g. 0987654321)",
|
||||
general: "",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
const success = await login(username, password);
|
||||
setIsLoading(true);
|
||||
setErrors({ phone: "", general: "" });
|
||||
|
||||
if (success) {
|
||||
router.push("/");
|
||||
try {
|
||||
const res = await fetch("/api/sms_otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
|
||||
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({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "Tên đăng nhập hoặc mật khẩu không đúng",
|
||||
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>
|
||||
{/* Username Input */}
|
||||
<LoginInput
|
||||
label="Tên đăng nhập"
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
<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) => {
|
||||
setUsername(e.target.value);
|
||||
setErrors({ ...errors, username: "", general: "" });
|
||||
setPhone(e.target.value);
|
||||
setErrors({ phone: "", general: "" });
|
||||
}}
|
||||
errors={errors.username}
|
||||
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)"}`}
|
||||
/>
|
||||
{errors.username && (
|
||||
<ErrorMessageLogin message={errors.username} type="secondary" />
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<ErrorMessageLogin message={errors.phone} 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}
|
||||
>
|
||||
Đăng nhập
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
Đăng ký tài khoản
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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,16 +23,10 @@ export {
|
||||
StatusBadge,
|
||||
DeleteConfirm,
|
||||
ProductModal,
|
||||
CategoryModal,
|
||||
ComboModal,
|
||||
ProductsTab,
|
||||
CategoriesTab,
|
||||
CombosTab,
|
||||
} from "./manager";
|
||||
export type {
|
||||
ProductModalProps,
|
||||
CategoryModalProps,
|
||||
ComboModalProps,
|
||||
DeleteConfirmProps,
|
||||
StatusBadgeProps,
|
||||
} from "./manager";
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
"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)">
|
||||
Mô 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>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"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 có 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,9 +17,11 @@ 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">Xóa "{name}"?</h3>
|
||||
<h3 className="text-foreground text-base font-bold">
|
||||
Delete "{name}"?
|
||||
</h3>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Hành động này không thể hoàn tác.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -27,13 +29,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)"
|
||||
>
|
||||
Hủy
|
||||
Cancel
|
||||
</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"
|
||||
>
|
||||
Xóa
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import type { Combo, MenuCategory, Product } from "@/lib/types";
|
||||
import type { MenuItemEntity } from "@/lib/types";
|
||||
|
||||
export interface ReviewEntity {
|
||||
id?: string;
|
||||
reviewerId: string;
|
||||
eateryId: string;
|
||||
rating: number;
|
||||
comment?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ProductModalProps {
|
||||
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;
|
||||
product: MenuItemEntity | null;
|
||||
onSave: (p: MenuItemEntity) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { Product } from "@/lib/types";
|
||||
import type { MenuItemEntity } from "@/lib/types";
|
||||
import { 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<Omit<Product, "id">>({
|
||||
const [form, setForm] = useState<MenuItemEntity>({
|
||||
name: product?.name ?? "",
|
||||
category: product?.category ?? categories[0]?.id ?? "",
|
||||
price: product?.price ?? 0,
|
||||
image: product?.image ?? "/imgs/products/placeholder.jpg",
|
||||
imageUrl: product?.imageUrl ?? "",
|
||||
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 handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isEdit && product) {
|
||||
onSave({ ...form, id: product.id });
|
||||
} else {
|
||||
@@ -30,22 +75,29 @@ export default function ProductModal({
|
||||
}
|
||||
};
|
||||
|
||||
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 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);
|
||||
|
||||
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="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
|
||||
<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 ? "Chỉnh sửa món" : "Thêm món mới"}
|
||||
{isEdit ? "Edit item" : "Add new item"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Đóng"
|
||||
title="Close"
|
||||
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>
|
||||
@@ -55,7 +107,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)">
|
||||
Tên món <span className="text-red-500">*</span>
|
||||
Item name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
@@ -63,32 +115,13 @@ export default function ProductModal({
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className={inputCls}
|
||||
placeholder="Ví dụ: Cà Phê Đen"
|
||||
placeholder="e.g. Black Coffee"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Price (₫) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
@@ -103,11 +136,50 @@ export default function ProductModal({
|
||||
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">
|
||||
<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"
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||
Uploading image...
|
||||
</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)">
|
||||
Mô tả
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
@@ -116,19 +188,19 @@ export default function ProductModal({
|
||||
setForm({ ...form, description: e.target.value })
|
||||
}
|
||||
className={`${inputCls} resize-none`}
|
||||
placeholder="Mô tả ngắn về món..."
|
||||
placeholder="Short description..."
|
||||
/>
|
||||
</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-foreground text-sm font-medium">Status</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{form.available ? "Còn hàng" : "Tạm hết"}
|
||||
{form.available ? "In stock" : "Out of stock"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
title="Chuyển trạng thái"
|
||||
title="Toggle status"
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, available: !form.available })}
|
||||
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
|
||||
@@ -149,13 +221,14 @@ 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)"
|
||||
>
|
||||
Hủy
|
||||
Cancel
|
||||
</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"
|
||||
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"
|
||||
>
|
||||
{isEdit ? "Lưu thay đổi" : "Thêm món"}
|
||||
{isEdit ? "Save changes" : "Add item"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
import type { Product } from "@/lib/types";
|
||||
import type { MenuItemEntity } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
import DeleteConfirm from "./DeleteConfirm";
|
||||
@@ -12,28 +12,31 @@ 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<Product | null | "new">(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
|
||||
const [modalProduct, setModalProduct] = useState<
|
||||
MenuItemEntity | null | "new"
|
||||
>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MenuItemEntity>(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 (
|
||||
@@ -45,9 +48,6 @@ 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,12 @@ export default function ProductsTab() {
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Tìm kiếm món..."
|
||||
placeholder="Search items..."
|
||||
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="Xóa tìm kiếm"
|
||||
title="Clear search"
|
||||
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,20 +73,6 @@ 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"
|
||||
>
|
||||
<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) =>
|
||||
@@ -95,26 +81,26 @@ 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="Lọc theo trạng thái"
|
||||
title="Filter by status"
|
||||
>
|
||||
<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>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="available">In stock</option>
|
||||
<option value="unavailable">Out of stock</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
title="Thêm món"
|
||||
title="Add item"
|
||||
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">Thêm món</span>
|
||||
<span className="hidden sm:inline">Add item</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Hiển thị <strong className="text-foreground">{filtered.length}</strong>{" "}
|
||||
/ {products.length} món
|
||||
Showing <strong className="text-foreground">{filtered.length}</strong> /{" "}
|
||||
{products.length} items
|
||||
</p>
|
||||
|
||||
{/* Table */}
|
||||
@@ -123,19 +109,19 @@ export default function ProductsTab() {
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Tên món
|
||||
Image
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Danh mục
|
||||
Item name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||
Giá
|
||||
Price
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
|
||||
Trạng thái
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
|
||||
Thao tác
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -147,7 +133,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>
|
||||
Không tìm thấy món nào
|
||||
<span className="ml-3">No items found</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -156,6 +142,13 @@ 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>
|
||||
@@ -166,21 +159,13 @@ export default function ProductsTab() {
|
||||
)}
|
||||
</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.id)}
|
||||
title="Nhấn để đổi trạng thái"
|
||||
onClick={() => toggleProductAvailability(p)}
|
||||
title="Click to toggle status"
|
||||
className="cursor-pointer border-none bg-transparent"
|
||||
>
|
||||
<StatusBadge available={p.available ?? true} />
|
||||
@@ -190,14 +175,14 @@ export default function ProductsTab() {
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setModalProduct(p)}
|
||||
title="Chỉnh sửa"
|
||||
title="Edit"
|
||||
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="Xóa"
|
||||
title="Delete"
|
||||
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>
|
||||
@@ -214,10 +199,9 @@ export default function ProductsTab() {
|
||||
{modalProduct !== null && (
|
||||
<ProductModal
|
||||
product={modalProduct === "new" ? null : modalProduct}
|
||||
categories={categories}
|
||||
onSave={(data) => {
|
||||
onSave={(data: MenuItemEntity) => {
|
||||
if ("id" in data) {
|
||||
updateProduct(data as Product);
|
||||
updateProduct(data);
|
||||
} else {
|
||||
addProduct(data);
|
||||
}
|
||||
@@ -231,10 +215,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>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
|
||||
available ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`}
|
||||
/>
|
||||
{available ? "Còn hàng" : "Tạm hết"}
|
||||
{available ? "In stock" : "Out of stock"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
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 CategoriesTab } from "./CategoriesTab";
|
||||
export { default as CombosTab } from "./CombosTab";
|
||||
export { default as MenuItemsTab } from "./MenuItemsTab";
|
||||
export { default as ReviewsTab } from "./ReviewsTab";
|
||||
export type {
|
||||
ProductModalProps,
|
||||
CategoryModalProps,
|
||||
ComboModalProps,
|
||||
DeleteConfirmProps,
|
||||
StatusBadgeProps,
|
||||
ReviewEntity,
|
||||
} from "./Manager.types";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface ReviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
eateryId?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
|
||||
@@ -1,28 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Heading, Text, Textarea } from "@/components/atoms";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ReviewModalProps } from "./Modal.types";
|
||||
|
||||
export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
interface CreateReviewInput {
|
||||
reviewerId: string;
|
||||
eateryId: string;
|
||||
rating: number;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export default function ReviewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
eateryId,
|
||||
}: ReviewModalProps) {
|
||||
const { user } = useAuth();
|
||||
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);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
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 handleClose = () => {
|
||||
// Reset state when closing
|
||||
setRating(0);
|
||||
setHovered(0);
|
||||
setReview("");
|
||||
setSubmitted(false);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -49,13 +89,13 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
<i className="fa-solid fa-heart text-(--color-accent)"></i>
|
||||
</div>
|
||||
<Heading level={2} id="review-modal-title">
|
||||
Cảm ơn quý khách
|
||||
Thank you
|
||||
</Heading>
|
||||
<Text variant="body2" className="mt-2">
|
||||
Chúng tôi trân trọng đánh giá của bạn!
|
||||
We appreciate your feedback!
|
||||
</Text>
|
||||
<Button onClick={handleClose} variant="primary" className="mt-4">
|
||||
Đóng
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -65,21 +105,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
id="review-modal-title"
|
||||
className="text-foreground mb-1 text-xl font-bold"
|
||||
>
|
||||
Đánh giá của bạn
|
||||
Your Review
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-(--color-text-muted)">
|
||||
Hãy cho chúng tôi biết trải nghiệm của bạn hôm nay
|
||||
Tell us about your experience today
|
||||
</p>
|
||||
|
||||
{/* Star rating */}
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
|
||||
Mức độ hài lòng
|
||||
Satisfaction level
|
||||
</p>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Xếp hạng sao"
|
||||
aria-label="Star rating"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isActive = star <= (hovered || rating);
|
||||
@@ -90,7 +130,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHovered(star)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
aria-label={`${star} sao`}
|
||||
aria-label={`${star} star`}
|
||||
aria-pressed={rating === star}
|
||||
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
|
||||
>
|
||||
@@ -108,7 +148,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
{rating > 0 && (
|
||||
<p className="mt-1.5 text-xs text-(--color-text-muted)">
|
||||
{
|
||||
["", "Rất tệ", "Tệ", "Bình thường", "Tốt", "Xuất sắc"][
|
||||
["", "Very poor", "Poor", "Average", "Good", "Excellent"][
|
||||
rating
|
||||
]
|
||||
}
|
||||
@@ -120,14 +160,17 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
<div className="mb-6">
|
||||
<Textarea
|
||||
id="review-text"
|
||||
label="Nhận xét (tùy chọn)"
|
||||
label="Comment (optional)"
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
placeholder="Chia sẻ cảm nhận của bạn về đồ uống, dịch vụ..."
|
||||
placeholder="Share your thoughts on the drinks, service..."
|
||||
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
|
||||
@@ -136,17 +179,18 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
icon="fa-arrow-left"
|
||||
disabled={loading}
|
||||
>
|
||||
Quay lại
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0}
|
||||
disabled={rating === 0 || loading}
|
||||
className="flex-1"
|
||||
icon="fa-check"
|
||||
icon={loading ? "fa-spinner fa-spin" : "fa-check"}
|
||||
>
|
||||
Xác nhận
|
||||
{loading ? "Submitting..." : "Confirm"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
|
||||
import type { MenuCategory } from "@/lib/types";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import type React from "react";
|
||||
|
||||
import type { CategorySidebarProps } from "./Navigation.types";
|
||||
@@ -18,8 +17,6 @@ import type { CategorySidebarProps } from "./Navigation.types";
|
||||
export default function CategorySidebar({
|
||||
isOpen,
|
||||
onToggle,
|
||||
activeCategory = "all",
|
||||
onCategoryChange,
|
||||
}: CategorySidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
@@ -40,14 +37,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>
|
||||
Thực Đơn
|
||||
Menu
|
||||
</span>
|
||||
|
||||
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||
aria-label={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||
title={isOpen ? "Collapse menu" : "Expand menu"}
|
||||
aria-label={isOpen ? "Collapse menu" : "Expand 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
|
||||
@@ -58,40 +55,6 @@ 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,77 +2,54 @@
|
||||
|
||||
import { ProductCard } from "@/components/molecules/cards";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
import { useManager } from "@/lib/manager-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 = MOCK_PRODUCTS.filter((p) => {
|
||||
const filteredProducts = 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 && matchesCategory && matchesSearch;
|
||||
return isAvailable && 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((product) => (
|
||||
{filteredProducts.map(
|
||||
({ id, imageUrl, name, price, description }) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
image={product.image}
|
||||
imageAlt={product.name}
|
||||
productName={product.name}
|
||||
price={product.price}
|
||||
description={product.description}
|
||||
onBuy={() => addToCart(product)}
|
||||
key={id}
|
||||
image={toDisplayUrl(imageUrl)}
|
||||
imageAlt={name}
|
||||
productName={name}
|
||||
price={price}
|
||||
description={description}
|
||||
onBuy={() => addToCart({ productId: id!, quantity: 1 })}
|
||||
/>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state */
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import ShiftCard from "@/components/molecules/cards/ShiftCard";
|
||||
import { DEPARTMENTS } from "@/lib/constants";
|
||||
import { useShift } from "@/lib/shift-context";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
|
||||
|
||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
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",
|
||||
];
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
function formatDateKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
@@ -18,35 +31,15 @@ function formatDateISO(d: Date): string {
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const today = new Date(2026, 3, 10);
|
||||
return (
|
||||
d.getDate() === today.getDate() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getFullYear() === today.getFullYear()
|
||||
);
|
||||
return formatDateKey(d) === formatDateKey(today);
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
];
|
||||
|
||||
export default function MobileShiftView({
|
||||
onShiftClick,
|
||||
}: MobileShiftViewProps) {
|
||||
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
formatDateISO(new Date(2026, 3, 10)),
|
||||
);
|
||||
const { currentDate, getShiftsForDate, goToNextMonth, goToPrevMonth } =
|
||||
useShift();
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
@@ -59,67 +52,55 @@ 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 getDotColors = (date: Date): string[] => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||
const dots: string[] = [];
|
||||
if (dayShifts.some((s) => s.status === "available"))
|
||||
dots.push("bg-amber-400");
|
||||
if (dayShifts.some((s) => s.status === "registered"))
|
||||
dots.push("bg-green-500");
|
||||
if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||
dots.push("bg-purple-400");
|
||||
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
|
||||
return dots;
|
||||
};
|
||||
const selectedShifts = useMemo(
|
||||
() => getShiftsForDate(selectedDate),
|
||||
[selectedDate, getShiftsForDate],
|
||||
);
|
||||
|
||||
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];
|
||||
const dayOfWeek = DAY_HEADERS[(selectedDate.getDay() + 6) % 7];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Compact month calendar */}
|
||||
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||
{/* Month calendar card */}
|
||||
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
|
||||
{/* Month navigation */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<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">
|
||||
<button
|
||||
title="Trở lại tháng trước"
|
||||
title="Previous month"
|
||||
type="button"
|
||||
onClick={goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
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)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<h3 className="text-foreground text-sm font-bold">
|
||||
<h3 className="text-sm font-bold text-(--color-primary-dark)">
|
||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Trở lại tháng sau"
|
||||
title="Next month"
|
||||
type="button"
|
||||
onClick={goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
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)"
|
||||
>
|
||||
<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) => (
|
||||
{DAY_HEADERS.map((day, i) => (
|
||||
<div
|
||||
key={day}
|
||||
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"
|
||||
className={`py-1 text-center text-[10px] font-bold uppercase ${
|
||||
i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
@@ -129,117 +110,80 @@ export default function MobileShiftView({
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((date, i) => {
|
||||
if (!date) {
|
||||
return <div key={`empty-${i}`} className="p-1" />;
|
||||
}
|
||||
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);
|
||||
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(dateStr)}
|
||||
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
|
||||
selected ? "rounded-lg bg-(--color-primary)/10" : ""
|
||||
}`}
|
||||
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 ${
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition ${
|
||||
today
|
||||
? "bg-(--color-primary) font-bold text-white"
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: selected
|
||||
? "text-foreground font-bold"
|
||||
: "text-foreground"
|
||||
? "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 gap-0.5">
|
||||
{dots.slice(0, 3).map((color, j) => (
|
||||
<span key={j} className={`h-1 w-1 rounded-full ${color}`} />
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
Còn trống
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Đã ĐK</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
Nghỉ phép
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Vắng</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected day shifts */}
|
||||
<div>
|
||||
<h3 className="text-foreground mb-3 text-sm font-bold">
|
||||
{dayOfWeek}, {selectedDateObj.getDate()}/
|
||||
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
||||
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
||||
({selectedShifts.length} ca)
|
||||
</span>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedShifts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
||||
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Không có ca làm trong ngày này
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{DEPARTMENTS.map((dept) => {
|
||||
const deptShifts = selectedShifts.filter(
|
||||
(s) => s.department === dept.id,
|
||||
);
|
||||
if (deptShifts.length === 0) return null;
|
||||
return (
|
||||
<div key={dept.id}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<i
|
||||
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||
></i>
|
||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||
{dept.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{deptShifts.map((shift) => (
|
||||
<ShiftCard
|
||||
key={shift.id}
|
||||
shift={shift}
|
||||
onClick={onShiftClick}
|
||||
/>
|
||||
<div className="space-y-2.5">
|
||||
{selectedShifts.map((shift) => (
|
||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useMemo } from "react";
|
||||
|
||||
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
||||
|
||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
export 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, shifts } = useShift();
|
||||
const { currentDate, getShiftsForDate } = useShift();
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
@@ -35,50 +35,27 @@ 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)[] = [];
|
||||
|
||||
// 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++) {
|
||||
for (let i = 0; i < startOffset; i++) days.push(null);
|
||||
for (let d = 1; d <= lastDay.getDate(); d++)
|
||||
days.push(new Date(year, month, d));
|
||||
}
|
||||
|
||||
// Fill trailing empty cells to complete the grid
|
||||
while (days.length % 7 !== 0) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
while (days.length % 7 !== 0) days.push(null);
|
||||
return days;
|
||||
}, [currentDate]);
|
||||
|
||||
const getShiftSummary = (date: Date) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||
const available = dayShifts.filter((s) => s.status === "available").length;
|
||||
const registered = dayShifts.filter(
|
||||
(s) => s.status === "registered",
|
||||
).length;
|
||||
const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
|
||||
const absent = dayShifts.filter((s) => s.status === "absent").length;
|
||||
return { total: dayShifts.length, available, registered, leave, absent };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-(--color-border-light)">
|
||||
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) shadow-sm">
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 border-b border-(--color-border-light) bg-gray-50">
|
||||
{DAY_HEADERS.map((day) => (
|
||||
<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
|
||||
key={day}
|
||||
className="px-2 py-2.5 text-center text-xs font-semibold text-(--color-text-muted) uppercase"
|
||||
className={`px-2 py-3 text-center text-[11px] font-bold tracking-wider uppercase ${
|
||||
i >= 5 ? "text-rose-400" : "text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
@@ -86,74 +63,59 @@ export default function MonthlyCalendar({
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
<div className="grid grid-cols-7 bg-white">
|
||||
{calendarDays.map((date, i) => {
|
||||
if (!date) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="min-h-25 border-r border-b border-(--color-border-light) bg-gray-50/30"
|
||||
className="min-h-[100px] border-r border-b border-(--color-border-light) bg-gray-50/50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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?.(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" : ""
|
||||
}`}
|
||||
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" : ""}`}
|
||||
>
|
||||
{/* Date number */}
|
||||
<span
|
||||
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
|
||||
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-bold transition ${
|
||||
today
|
||||
? "bg-(--color-primary) font-bold text-white"
|
||||
: "text-foreground font-medium"
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: isWeekend
|
||||
? "text-rose-500"
|
||||
: "text-(--color-text-secondary) group-hover:text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
|
||||
{summary.total > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{summary.available > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
|
||||
<span className="text-[10px] text-blue-600">
|
||||
{summary.available} trống
|
||||
{/* 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>
|
||||
</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,8 +4,71 @@ 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,
|
||||
@@ -13,7 +76,7 @@ export default function ShiftCreateModal({
|
||||
}: ShiftCreateModalProps) {
|
||||
const { createShift } = useShift();
|
||||
|
||||
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
||||
const [date, setDate] = useState<Date>(new Date(defaultDate ?? "2026-04-10"));
|
||||
const [startTime, setStartTime] = useState("08:00");
|
||||
const [endTime, setEndTime] = useState("12:00");
|
||||
const [department, setDepartment] = useState("bar");
|
||||
@@ -23,7 +86,7 @@ export default function ShiftCreateModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDate(defaultDate ?? "2026-04-10");
|
||||
setDate(new Date(defaultDate ?? "2026-04-10"));
|
||||
}
|
||||
}, [defaultDate, isOpen]);
|
||||
|
||||
@@ -35,7 +98,7 @@ export default function ShiftCreateModal({
|
||||
|
||||
// Validate
|
||||
if (!date || !startTime || !endTime) {
|
||||
setError("Vui lòng điền đầy đủ thông tin.");
|
||||
setError("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,22 +108,20 @@ export default function ShiftCreateModal({
|
||||
const endMinutes = eh * 60 + em;
|
||||
|
||||
if (endMinutes <= startMinutes) {
|
||||
setError("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||
setError("End time must be after start time.");
|
||||
return;
|
||||
}
|
||||
|
||||
const durationHours = (endMinutes - startMinutes) / 60;
|
||||
const deptName =
|
||||
DEPARTMENTS.find((d) => d.id === department)?.name ?? department;
|
||||
|
||||
createShift({
|
||||
name: `${deptName} - ${formatDateISO(date)}`,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
durationHours,
|
||||
wage,
|
||||
department,
|
||||
maxStaff,
|
||||
registeredStaff: [],
|
||||
status: "available",
|
||||
});
|
||||
|
||||
onClose();
|
||||
@@ -77,10 +138,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">
|
||||
Tạo ca làm mới
|
||||
Create New Shift
|
||||
</h2>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Thêm khung giờ ca làm cho nhân viên
|
||||
Add a shift time slot for staff
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -98,13 +159,13 @@ export default function ShiftCreateModal({
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Ngày
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
title="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
value={formatDateISO(date)}
|
||||
onChange={(e) => setDate(new Date(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>
|
||||
@@ -113,26 +174,22 @@ 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)">
|
||||
Giờ bắt đầu
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
<TimeInput
|
||||
title="Start Time"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
|
||||
onChange={setStartTime}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Giờ kết thúc
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
<TimeInput
|
||||
title="End Time"
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
|
||||
onChange={setEndTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +197,7 @@ export default function ShiftCreateModal({
|
||||
{/* Department */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Bộ phận
|
||||
Department
|
||||
</label>
|
||||
<select
|
||||
title="Department"
|
||||
@@ -160,7 +217,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)">
|
||||
Số nhân viên tối đa
|
||||
Max staff
|
||||
</label>
|
||||
<input
|
||||
title="Max Staff"
|
||||
@@ -174,7 +231,7 @@ export default function ShiftCreateModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Lương ca (VND)
|
||||
Shift wage (VND)
|
||||
</label>
|
||||
<input
|
||||
title="Wage"
|
||||
@@ -203,14 +260,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>
|
||||
Tạo ca làm
|
||||
Create shift
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||
>
|
||||
Hủy
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { DEPARTMENTS } from "@/lib/constants";
|
||||
import { useShift } from "@/lib/shift-context";
|
||||
import { useState } from "react";
|
||||
import { useEffect, 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,
|
||||
@@ -19,23 +29,24 @@ 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
|
||||
? shift.registeredStaff.some((s) => s.id === user.id)
|
||||
? registeredStaff.some((s) => s.id === user.id)
|
||||
: false;
|
||||
const isFull = shift.registeredStaff.length >= shift.maxStaff;
|
||||
const isFull = registeredStaff.length >= shift.maxStaff;
|
||||
|
||||
const handleRegister = () => {
|
||||
const handleRegister = async () => {
|
||||
if (!user) return;
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
const result = registerShift(shift.id, user.id, user.name);
|
||||
const result = await registerShift(shift.id, user.id, user.name);
|
||||
if (result.success) {
|
||||
setSuccess("Đăng ký ca thành công!");
|
||||
setSuccess("Shift registered successfully!");
|
||||
setTimeout(onClose, 1200);
|
||||
} else {
|
||||
setError(result.error ?? "Có lỗi xảy ra.");
|
||||
setError(result.error ?? "An error occurred.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,25 +54,25 @@ export default function ShiftDetailModal({
|
||||
if (!user) return;
|
||||
setError(null);
|
||||
unregisterShift(shift.id, user.id);
|
||||
setSuccess("Đã hủy đăng ký ca.");
|
||||
setSuccess("Shift registration cancelled.");
|
||||
setTimeout(onClose, 1200);
|
||||
};
|
||||
|
||||
const handleManagerUnregister = (staffId: number) => {
|
||||
const handleManagerUnregister = (staffId: string) => {
|
||||
unregisterShift(shift.id, staffId);
|
||||
setSuccess("Đã xóa nhân viên khỏi ca.");
|
||||
setSuccess("Staff removed from shift.");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteShift(shift.id);
|
||||
const handleDelete = async () => {
|
||||
await deleteShift(shift.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const statusLabel = {
|
||||
available: "Còn trống",
|
||||
registered: "Đã đăng ký",
|
||||
approved_leave: "Nghỉ phép",
|
||||
absent: "Vắng mặt",
|
||||
available: "Available",
|
||||
registered: "Registered",
|
||||
approved_leave: "On leave",
|
||||
absent: "Absent",
|
||||
};
|
||||
|
||||
const statusColor = {
|
||||
@@ -71,6 +82,44 @@ 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 */}
|
||||
@@ -81,12 +130,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">
|
||||
Chi tiết ca làm
|
||||
Shift Details
|
||||
</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
|
||||
@@ -102,35 +151,34 @@ 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">
|
||||
Ngày
|
||||
Date
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm font-bold">
|
||||
{new Date(shift.date + "T00:00:00").toLocaleDateString(
|
||||
"vi-VN",
|
||||
{
|
||||
{parseShiftDate(
|
||||
shift.date as Date | string | undefined,
|
||||
)?.toLocaleDateString("vi-VN", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
}) ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 p-3">
|
||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||
Giờ làm
|
||||
Working hours
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm font-bold">
|
||||
{shift.startTime} – {shift.endTime}
|
||||
@@ -138,18 +186,16 @@ 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">
|
||||
Thời lượng
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm font-bold">
|
||||
{shift.durationHours} giờ
|
||||
Duration
|
||||
</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">
|
||||
Lương ca
|
||||
Shift wage
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-bold text-(--color-primary)">
|
||||
{shift.wage.toLocaleString("vi-VN")} VND
|
||||
{(shift.wage ?? 0).toLocaleString("vi-VN")} VND
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,16 +203,15 @@ export default function ShiftDetailModal({
|
||||
{/* Registered staff */}
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
||||
Nhân viên đã đăng ký ({shift.registeredStaff.length}/
|
||||
{shift.maxStaff})
|
||||
Registered staff ({registeredStaff.length}/{shift.maxStaff})
|
||||
</p>
|
||||
{shift.registeredStaff.length === 0 ? (
|
||||
{registeredStaff.length === 0 ? (
|
||||
<p className="text-xs text-(--color-text-muted) italic">
|
||||
Chưa có ai đăng ký
|
||||
No staff registered yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{shift.registeredStaff.map((staff) => (
|
||||
{registeredStaff.map((staff) => (
|
||||
<div
|
||||
key={staff.id}
|
||||
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
|
||||
@@ -176,7 +221,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">
|
||||
{staff.name}
|
||||
<StaffName staffId={staff.staffId} />
|
||||
</span>
|
||||
</div>
|
||||
{isManager && (
|
||||
@@ -186,7 +231,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>
|
||||
Xóa
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -212,17 +257,14 @@ export default function ShiftDetailModal({
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
|
||||
{!isRegistered &&
|
||||
!isFull &&
|
||||
shift.status !== "approved_leave" &&
|
||||
shift.status !== "absent" && (
|
||||
{!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>
|
||||
Đăng ký ca
|
||||
Register
|
||||
</button>
|
||||
)}
|
||||
{isRegistered && (
|
||||
@@ -232,7 +274,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>
|
||||
Hủy đăng ký
|
||||
Unregister
|
||||
</button>
|
||||
)}
|
||||
{isManager && (
|
||||
@@ -242,7 +284,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>
|
||||
Xóa ca
|
||||
Delete shift
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -250,7 +292,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"
|
||||
>
|
||||
Đóng
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { ShiftSlot } from "@/lib/types";
|
||||
import type { ShiftEntity } from "@/lib/types";
|
||||
|
||||
export interface WeeklyScheduleProps {
|
||||
onShiftClick: (shift: ShiftSlot) => void;
|
||||
onCreateShift?: (date: string) => void;
|
||||
onShiftClick: (shift: ShiftEntity) => void;
|
||||
onCreateShift?: (date: Date) => void;
|
||||
mobileCalendarHeader?: boolean;
|
||||
}
|
||||
|
||||
export interface MonthlyCalendarProps {
|
||||
onShiftClick: (shift: ShiftSlot) => void;
|
||||
onDateSelect?: (date: string) => void;
|
||||
onShiftClick: (shift: ShiftEntity) => void;
|
||||
onDateSelect?: (date: Date) => void;
|
||||
}
|
||||
|
||||
export interface MobileShiftViewProps {
|
||||
onShiftClick: (shift: ShiftSlot) => void;
|
||||
onShiftClick: (shift: ShiftEntity) => void;
|
||||
}
|
||||
|
||||
export interface ShiftDetailModalProps {
|
||||
shift: ShiftSlot | null;
|
||||
shift: ShiftEntity | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -24,5 +24,5 @@ export interface ShiftDetailModalProps {
|
||||
export interface ShiftCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
defaultDate?: string;
|
||||
defaultDate?: Date;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
||||
|
||||
const MONTH_NAMES_EN = [
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
@@ -22,22 +22,14 @@ const MONTH_NAMES_EN = [
|
||||
"December",
|
||||
];
|
||||
|
||||
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function formatDateShort(d: Date): string {
|
||||
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const today = new Date(2026, 3, 10);
|
||||
const today = new Date(Date.now());
|
||||
return (
|
||||
d.getDate() === today.getDate() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
@@ -59,98 +51,80 @@ export default function WeeklySchedule({
|
||||
} = useShift();
|
||||
const weekDates = getWeekDates();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
formatDateISO(weekDates[0] ?? currentDate),
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(
|
||||
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;
|
||||
const selectedDateRef = useMemo(() => {
|
||||
return (
|
||||
weekDates.find((d) => d === selectedDate) ?? weekDates[0] ?? currentDate
|
||||
);
|
||||
}, [selectedDate, weekDates, currentDate]);
|
||||
|
||||
const selectedDateStr = formatDateISO(selectedDateObj);
|
||||
// ── Mobile calendar header view ────────────────────────────────────────────
|
||||
|
||||
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">
|
||||
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="Tuần trước"
|
||||
title="Previous week"
|
||||
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"
|
||||
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-base font-bold text-(--color-primary-dark)">
|
||||
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
<h3 className="text-sm font-bold text-(--color-primary-dark)">
|
||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Tuần sau"
|
||||
title="Next week"
|
||||
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"
|
||||
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">
|
||||
<div className="grid grid-cols-7 gap-1 p-3">
|
||||
{weekDates.map((date, i) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const active = dateStr === selectedDateStr;
|
||||
const dots = statusDotsByDate[dateStr] ?? [];
|
||||
const active = date === selectedDateRef;
|
||||
const today = isToday(new Date(date));
|
||||
const shifts = getShiftsForDate(new Date(date));
|
||||
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"
|
||||
}`}
|
||||
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-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
|
||||
className={`text-[10px] font-semibold uppercase ${i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"}`}
|
||||
>
|
||||
{DAY_LABELS_EN[i]}
|
||||
{DAY_LABELS[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"
|
||||
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-pink-500"
|
||||
: "text-sky-500"
|
||||
? "text-rose-500"
|
||||
: "text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
{new Date(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 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>
|
||||
);
|
||||
@@ -158,27 +132,28 @@ export default function WeeklySchedule({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day shifts */}
|
||||
<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}
|
||||
<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>
|
||||
<div className="space-y-2">
|
||||
{deptShifts.map((shift) => (
|
||||
|
||||
{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}
|
||||
@@ -188,76 +163,94 @@ export default function WeeklySchedule({
|
||||
{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)"
|
||||
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)"
|
||||
>
|
||||
<i className="fa-solid fa-plus mr-1"></i>
|
||||
Thêm ca
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Add shift
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (mobileCalendarHeader) {
|
||||
return renderMobileDayView ?? null;
|
||||
}
|
||||
|
||||
// ── 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-225 border-collapse">
|
||||
<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 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
||||
Bộ phận
|
||||
<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) => (
|
||||
{weekDates.map((date, i) => {
|
||||
const today = isToday(new Date(date));
|
||||
return (
|
||||
<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)"
|
||||
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)"
|
||||
}`}
|
||||
>
|
||||
<span className="block uppercase">{DAY_LABELS[i]}</span>
|
||||
<span className="mt-0.5 block text-[11px] font-normal">
|
||||
{formatDateShort(date)}
|
||||
<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) => (
|
||||
<tr key={dept.id}>
|
||||
<td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
|
||||
{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 dateStr = formatDateISO(date);
|
||||
const shifts = getShiftsForDate(dateStr).filter(
|
||||
(s) => s.department === dept.id,
|
||||
);
|
||||
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-1.5 align-top ${
|
||||
isToday(date) ? "bg-(--color-primary)/5" : ""
|
||||
className={`border-r border-b border-(--color-border-light) p-2 align-top transition ${
|
||||
today ? "bg-(--color-primary)/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-h-20 flex-col gap-1">
|
||||
<div className="flex min-h-[88px] flex-col gap-1.5">
|
||||
{shifts.map((shift) => (
|
||||
<ShiftCard
|
||||
key={shift.id}
|
||||
@@ -269,11 +262,11 @@ export default function WeeklySchedule({
|
||||
{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)"
|
||||
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 mr-1"></i>
|
||||
Thêm ca
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,5 +278,6 @@ export default function WeeklySchedule({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
"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">Không tìm thấy quán nào phù hợp</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">
|
||||
// No shops found matching your search
|
||||
// </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,2 +1,2 @@
|
||||
export { default as ShopGrid } from "./ShopGrid";
|
||||
// export { default as ShopGrid } from "./ShopGrid";
|
||||
export type { ShopGridProps } from "./ShopGrid.types";
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as AuthLayout } from "./AuthLayout";
|
||||
export type { AuthLayoutProps } from "./AuthLayout.types";
|
||||
@@ -2,10 +2,6 @@
|
||||
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,6 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -12,7 +13,9 @@ export default function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Sticky top header */}
|
||||
<ManagerProvider>
|
||||
<Header />
|
||||
</ManagerProvider>
|
||||
|
||||
{/* 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 } = useAuth();
|
||||
const { user, isInitialized } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null && user.role !== "manager") {
|
||||
if (isInitialized && user !== null && user.role !== "manager") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [user, router]);
|
||||
}, [user, isInitialized, router]);
|
||||
|
||||
if (user === null) {
|
||||
if (!isInitialized || 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
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Cài đặt pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN npm install -g pnpm@10.9.0
|
||||
|
||||
# Copy file định nghĩa package để tận dụng cache của Docker
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
@@ -16,7 +16,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend-container
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.0
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.13
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
resources:
|
||||
|
||||
+9
-9
@@ -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>Địa chỉ: {SHOP_INFO.address}</span>
|
||||
<span>Address: {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)"
|
||||
>
|
||||
Số điện thoại: {SHOP_INFO.phone}
|
||||
Phone: {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">
|
||||
Kết nối
|
||||
Follow Us
|
||||
</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">
|
||||
WiFi Miễn Phí
|
||||
Free WiFi
|
||||
</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">
|
||||
Kết nối miễn phí
|
||||
Connect for free
|
||||
</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">
|
||||
Tên mạng
|
||||
Network name
|
||||
</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">
|
||||
Mật khẩu
|
||||
Password
|
||||
</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">
|
||||
Được vận hành{" "}
|
||||
Powered by{" "}
|
||||
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
|
||||
bằng Drinkool
|
||||
Drinkool
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-11
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -25,6 +26,7 @@ import { useRouter } from "next/navigation";
|
||||
*/
|
||||
export default function Header() {
|
||||
const router = useRouter();
|
||||
const { eatery } = useManager();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleAuthClick = () => {
|
||||
@@ -47,7 +49,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={`Logo ${SHOP_INFO.name}`}
|
||||
alt={`${eatery?.name} logo`}
|
||||
fill
|
||||
className="object-contain transition-transform duration-200 group-hover:scale-105"
|
||||
sizes="44px"
|
||||
@@ -58,7 +60,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">
|
||||
{SHOP_INFO.name}
|
||||
{eatery?.name}
|
||||
</span>
|
||||
<span className="hidden text-xs text-(--color-text-muted) md:block">
|
||||
{SHOP_INFO.tagline}
|
||||
@@ -72,11 +74,11 @@ export default function Header() {
|
||||
/* Guest: sign-in button */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Đăng nhập"
|
||||
title="Sign in"
|
||||
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">Đăng nhập</span>
|
||||
<span className="hidden sm:inline">Sign in</span>
|
||||
</button>
|
||||
) : user.role === "manager" ? (
|
||||
/* Manager: dashboard link + logout */
|
||||
@@ -90,8 +92,8 @@ export default function Header() {
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Đăng xuất"
|
||||
aria-label="Đăng xuất"
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
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>
|
||||
@@ -105,12 +107,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">Ca làm</span>
|
||||
<span className="hidden sm:inline">My Shifts</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Nhấn để đăng xuất"
|
||||
aria-label="Đăng xuất"
|
||||
title="Click to sign out"
|
||||
aria-label="Sign out"
|
||||
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 */}
|
||||
@@ -124,14 +126,14 @@ export default function Header() {
|
||||
/* Customer: phone icon + label */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title={`Khách hàng - ${user.phone || ""} - Nhấn để đăng xuất`}
|
||||
title={`Customer - ${user.phone || ""} - Click to sign out`}
|
||||
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">Khách hàng</span>
|
||||
<span className="hidden sm:inline">Customer</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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(),
|
||||
});
|
||||
+24
-118
@@ -12,95 +12,21 @@ import type { User } from "./types";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
setUser: (user: User | null) => void;
|
||||
isInitialized: boolean;
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<{ ok: boolean; status?: number }>;
|
||||
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 [registerPhone, setRegisterPhone] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("coffee-shop-user");
|
||||
if (savedUser) {
|
||||
@@ -110,32 +36,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.error("Failed to parse saved user", e);
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const login = async (phone: string, password: string): Promise<boolean> => {
|
||||
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";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/customer/login", {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ phone, password }),
|
||||
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 true;
|
||||
return { ok: true };
|
||||
} else {
|
||||
console.error("Đăng nhập thất bại:", response.statusText);
|
||||
return false;
|
||||
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 false;
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,37 +73,14 @@ 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}
|
||||
|
||||
+185
-97
@@ -1,134 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import type { Product } from "@/lib/types";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
import { cartClient, eateryClient } from "./apollo-clients";
|
||||
import {
|
||||
CartEntity,
|
||||
CartItemEntity,
|
||||
addMenuItemMutation,
|
||||
allEateriesQuery,
|
||||
createCartMutation,
|
||||
getCartQuery,
|
||||
} from "./types";
|
||||
|
||||
interface CartContextValue {
|
||||
items: CartItem[];
|
||||
items: CartItemEntity[];
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "coffee-shop-cart";
|
||||
const CART_ID = "cartId";
|
||||
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 [items, setItems] = useState<CartItem[]>([]);
|
||||
const [cart, setCart] = useState<CartEntity>(null!);
|
||||
const [cartId, setCartId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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));
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
|
||||
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 next = [...prev];
|
||||
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
|
||||
return next;
|
||||
const { data: eateryData } = useQuery<allEateriesQuery>(GET_EATERY, {
|
||||
client: eateryClient,
|
||||
});
|
||||
};
|
||||
|
||||
const increaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
const [createCart] = useMutation<createCartMutation>(CREATE_CART, {
|
||||
client: cartClient,
|
||||
});
|
||||
|
||||
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 { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, {
|
||||
client: cartClient,
|
||||
variables: { cartId },
|
||||
});
|
||||
|
||||
const removeFromCart = (id: number) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, {
|
||||
client: cartClient,
|
||||
});
|
||||
|
||||
const setQuantity = (id: number, quantity: number) => {
|
||||
const safeQty = Number.isFinite(quantity)
|
||||
? Math.max(0, Math.floor(quantity))
|
||||
: 0;
|
||||
if (safeQty === 0) {
|
||||
removeFromCart(id);
|
||||
return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: safeQty } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
createCartFunc();
|
||||
} else if (!cartId) {
|
||||
const localCartId = localStorage.getItem(CART_ID);
|
||||
if (localCartId) setCartId(localCartId);
|
||||
else createCartFunc();
|
||||
}
|
||||
}, [eateryData, createCart, data, loading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.getCart) setCart(data.getCart);
|
||||
}, [data]);
|
||||
|
||||
const addToCart = async (product: CartItemEntity) => {
|
||||
if (!cartId) return;
|
||||
|
||||
const { data: result } = await addMenuItem({
|
||||
variables: {
|
||||
cartId,
|
||||
menuItemId: product.productId!,
|
||||
quantity: product.quantity,
|
||||
},
|
||||
});
|
||||
|
||||
if (result) setCart(result.addItem);
|
||||
};
|
||||
|
||||
const DELETE_CART = gql`
|
||||
mutation deleteCart($cartId: String!) {
|
||||
deleteCart(cartId: $cartId)
|
||||
}
|
||||
`;
|
||||
|
||||
const [mutateDeleteCart] = useMutation(DELETE_CART, {
|
||||
client: cartClient,
|
||||
});
|
||||
|
||||
const removeCart = async () => {
|
||||
localStorage.removeItem(CART_ID);
|
||||
|
||||
await mutateDeleteCart({ variables: { cartId } });
|
||||
|
||||
setCartId(null);
|
||||
setCart(null!);
|
||||
};
|
||||
|
||||
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(
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
||||
[items],
|
||||
() => cart?.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[cart],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
items,
|
||||
items: cart?.items,
|
||||
totalItems,
|
||||
totalPrice,
|
||||
totalPrice: cart?.totalAmount,
|
||||
eateryId: cart?.eateryId,
|
||||
addToCart,
|
||||
increaseQty,
|
||||
decreaseQty,
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
removeCart,
|
||||
cart,
|
||||
}),
|
||||
[items, totalItems, totalPrice],
|
||||
[cart?.items, cart?.totalAmount, cart?.eateryId],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
|
||||
+51
-561
@@ -1,23 +1,17 @@
|
||||
import type {
|
||||
Combo,
|
||||
Department,
|
||||
MenuCategory,
|
||||
Product,
|
||||
ProductSalesStats,
|
||||
RevenueDataPoint,
|
||||
ShiftSlot,
|
||||
Shop,
|
||||
ShiftEntity,
|
||||
ShopInfo,
|
||||
SocialLinks,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
// ===== SHOP INFORMATION =====
|
||||
export const SHOP_INFO: ShopInfo = {
|
||||
name: "Coffee Shop",
|
||||
tagline: "Hương vị đậm đà – Khoảnh khắc thư giãn",
|
||||
tagline: "Rich Flavors – Moments of Relaxation",
|
||||
logo: "/imgs/logo.png",
|
||||
address: "123 Đường Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
|
||||
address: "123 Nguyen Hue Street, District 1, Ho Chi Minh City",
|
||||
phone: "0901 234 567",
|
||||
managerPhone: "0912 345 678",
|
||||
email: "contact@coffeeshop.vn",
|
||||
@@ -25,7 +19,7 @@ export const SHOP_INFO: ShopInfo = {
|
||||
name: "CoffeeShop_Free",
|
||||
password: "coffee2024",
|
||||
},
|
||||
openHours: "07:00 – 22:00 (Thứ 2 – Chủ nhật)",
|
||||
openHours: "07:00 – 22:00 (Monday – Sunday)",
|
||||
};
|
||||
|
||||
// ===== SOCIAL LINKS =====
|
||||
@@ -35,287 +29,6 @@ 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)
|
||||
@@ -354,34 +67,34 @@ export const MOCK_REVENUE_DAILY: RevenueDataPoint[] = [
|
||||
|
||||
// Weekly revenue (last 12 weeks)
|
||||
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// Monthly revenue (last 12 months)
|
||||
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// Yearly revenue (last 5 years)
|
||||
@@ -393,240 +106,13 @@ 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(): ShiftSlot[] {
|
||||
const shifts: ShiftSlot[] = [];
|
||||
const departments = ["bar", "kitchen", "cashier", "janitor"];
|
||||
function generateMockShifts(): ShiftEntity[] {
|
||||
const shifts: ShiftEntity[] = [];
|
||||
const departments = ["waiter"];
|
||||
const timeSlots = [
|
||||
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 },
|
||||
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 },
|
||||
@@ -635,9 +121,9 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
];
|
||||
|
||||
const staffPool = [
|
||||
{ id: 2, name: "Nguyễn Văn An" },
|
||||
{ id: 3, name: "Trần Thị Bình" },
|
||||
{ id: 4, name: "Lê Văn Cường" },
|
||||
{ id: 2, name: "Alex Nguyen" },
|
||||
{ id: 3, name: "Binh Tran" },
|
||||
{ id: 4, name: "Cuong Le" },
|
||||
];
|
||||
|
||||
// Generate shifts from April 6 to April 26, 2026
|
||||
@@ -660,7 +146,7 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
|
||||
// Determine registration status
|
||||
const registered: { id: number; name: string }[] = [];
|
||||
let status: ShiftSlot["status"] = "available";
|
||||
// let status: ShiftEntity["status"] = "available";
|
||||
|
||||
// Past shifts (before April 10) are mostly registered
|
||||
if (day < 10) {
|
||||
@@ -669,37 +155,37 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
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: dateStr,
|
||||
id: "shiftId",
|
||||
date: new 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -708,4 +194,8 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
return shifts;
|
||||
}
|
||||
|
||||
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
|
||||
export const MOCK_SHIFT_SLOTS: ShiftEntity[] = generateMockShifts();
|
||||
|
||||
export const DEPARTMENTS: Department[] = [
|
||||
{ id: "waiter", name: "Bar Staff", icon: "fa-brands fa-jenkins" },
|
||||
];
|
||||
|
||||
+158
-95
@@ -1,149 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext, useContext, useState } from "react";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MENU_CATEGORIES, MOCK_COMBOS, MOCK_PRODUCTS } from "./constants";
|
||||
import type { Combo, ComboItem, MenuCategory, Product } from "./types";
|
||||
import { eateryClient } from "./apollo-clients";
|
||||
import {
|
||||
EateryEntity,
|
||||
type MenuItemEntity,
|
||||
type addMenuItemMutation,
|
||||
type allEateriesQuery,
|
||||
deleteMenuItemMutation,
|
||||
updateMenuItemMutation,
|
||||
} from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ManagerTab = "products" | "combos" | "categories";
|
||||
export type ManagerTab = "products" | "reviews";
|
||||
|
||||
interface ManagerContextType {
|
||||
// Data
|
||||
products: Product[];
|
||||
combos: Combo[];
|
||||
categories: MenuCategory[];
|
||||
products: MenuItemEntity[];
|
||||
eateryId: string | null;
|
||||
eatery?: EateryEntity;
|
||||
|
||||
// Active tab
|
||||
activeTab: ManagerTab;
|
||||
setActiveTab: (tab: ManagerTab) => 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;
|
||||
// MenuItemEntity actions
|
||||
addProduct: (product: MenuItemEntity) => void;
|
||||
updateProduct: (product: MenuItemEntity) => void;
|
||||
deleteProduct: (id: string) => void;
|
||||
toggleProductAvailability: (product: MenuItemEntity) => 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<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 [products, setProducts] = useState<MenuItemEntity[]>([]);
|
||||
const [eatery, setEatery] = useState<EateryEntity>();
|
||||
const [activeTab, setActiveTab] = useState<ManagerTab>("products");
|
||||
|
||||
// ── Product actions ──────────────────────────────────────────────────────
|
||||
const { data } = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
|
||||
client: eateryClient,
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const addProduct = (product: Omit<Product, "id">) => {
|
||||
const newProduct: Product = {
|
||||
...product,
|
||||
id: Date.now(),
|
||||
};
|
||||
setProducts((prev) => [...prev, newProduct]);
|
||||
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 updateProduct = (product: Product) => {
|
||||
setProducts((prev) => prev.map((p) => (p.id === product.id ? product : p)));
|
||||
};
|
||||
const updateProduct = async (product: MenuItemEntity) => {
|
||||
const { data } = await mutateUpdateMenuItem({
|
||||
variables: {
|
||||
menuItem: product,
|
||||
},
|
||||
});
|
||||
|
||||
const deleteProduct = (id: number) => {
|
||||
setProducts((prev) => prev.filter((p) => p.id !== id));
|
||||
};
|
||||
|
||||
const toggleProductAvailability = (id: number) => {
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id ? { ...p, available: !(p.available ?? true) } : p,
|
||||
),
|
||||
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
|
||||
);
|
||||
};
|
||||
|
||||
// ── Combo actions ────────────────────────────────────────────────────────
|
||||
const deleteProduct = async (id: string) => {
|
||||
const { data } = await mutateDeleteMenuItem({ variables: { input: id } });
|
||||
|
||||
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)),
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.filter((p) => !(p.id == id && data.deleteMenuItem)),
|
||||
);
|
||||
};
|
||||
|
||||
// ── Category actions ─────────────────────────────────────────────────────
|
||||
const toggleProductAvailability = async (product: MenuItemEntity) => {
|
||||
const { data } = await mutateUpdateMenuItem({
|
||||
variables: {
|
||||
menuItem: { ...product, available: !product.available },
|
||||
},
|
||||
});
|
||||
|
||||
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)),
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteCategory = (id: string) => {
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerContext.Provider
|
||||
value={{
|
||||
eatery,
|
||||
products,
|
||||
combos,
|
||||
categories,
|
||||
eateryId,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
addProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
toggleProductAvailability,
|
||||
addCombo,
|
||||
updateCombo,
|
||||
deleteCombo,
|
||||
toggleComboAvailability,
|
||||
addCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"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);
|
||||
}
|
||||
+200
-64
@@ -1,15 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MOCK_SHIFT_SLOTS } from "./constants";
|
||||
import type { ShiftSlot, ShiftStatus } from "./types";
|
||||
import { eateryClient } from "./apollo-clients";
|
||||
import {
|
||||
type ShiftEntity,
|
||||
type allShiftsQuery,
|
||||
createShiftMutation,
|
||||
} from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,7 +24,7 @@ export type ScheduleView = "week" | "month";
|
||||
|
||||
interface ShiftContextType {
|
||||
// Data
|
||||
shifts: ShiftSlot[];
|
||||
shifts: ShiftEntity[];
|
||||
view: ScheduleView;
|
||||
setView: (view: ScheduleView) => void;
|
||||
|
||||
@@ -32,23 +39,22 @@ interface ShiftContextType {
|
||||
// Shift actions
|
||||
registerShift: (
|
||||
shiftId: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
staffName: string,
|
||||
) => { success: boolean; error?: string };
|
||||
unregisterShift: (shiftId: string, staffId: number) => void;
|
||||
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
||||
updateShift: (shift: ShiftSlot) => void;
|
||||
deleteShift: (shiftId: string) => void;
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
unregisterShift: (shiftId: string, staffId: string) => void;
|
||||
createShift: (shift: Omit<ShiftEntity, "id">) => void;
|
||||
deleteShift: (shiftId: string) => Promise<void>;
|
||||
|
||||
// Helpers
|
||||
getShiftsForDate: (date: string) => ShiftSlot[];
|
||||
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
||||
getShiftsForDate: (date: Date) => ShiftEntity[];
|
||||
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
|
||||
getWeekDates: () => Date[];
|
||||
hasConflict: (
|
||||
date: string,
|
||||
date: Date | string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
excludeShiftId?: string,
|
||||
) => boolean;
|
||||
getWeeklyBudget: () => number;
|
||||
@@ -76,6 +82,21 @@ function formatDate(d: Date): string {
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Dùng local date parts thay vì toISOString() để tránh lệch timezone UTC+7
|
||||
function toLocalDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
// Normalize Date or ISO string to "YYYY-MM-DD" for safe comparison
|
||||
function dateKey(d: Date | string | undefined): string {
|
||||
if (!d) return "";
|
||||
if (d instanceof Date) return formatDate(d);
|
||||
return (d as string).slice(0, 10);
|
||||
}
|
||||
|
||||
function timeToMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
@@ -94,13 +115,84 @@ function timesOverlap(
|
||||
return s1 < e2 && s2 < e1;
|
||||
}
|
||||
|
||||
// ___ GraphQL __________________________________________________________________
|
||||
|
||||
const GET_ALL_SHIFTS = gql`
|
||||
query allShifts {
|
||||
allShifts {
|
||||
id
|
||||
date
|
||||
startTime
|
||||
endTime
|
||||
maxStaff
|
||||
wage
|
||||
registeredStaff {
|
||||
id
|
||||
staffId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CREATE_SHIFT = gql`
|
||||
mutation createShift($shiftInput: CreateShiftInput!) {
|
||||
createShift(shiftInput: $shiftInput) {
|
||||
id
|
||||
date
|
||||
startTime
|
||||
endTime
|
||||
maxStaff
|
||||
wage
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DELETE_SHIFT = gql`
|
||||
mutation deleteShift($id: String!) {
|
||||
deleteShift(id: $id)
|
||||
}
|
||||
`;
|
||||
|
||||
const REGISTER_SHIFT = gql`
|
||||
mutation registerShift($RegisterShiftInput: RegisterShiftInput) {
|
||||
registerShift(registration: $RegisterShiftInput) {
|
||||
staffId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
const [shifts, setShifts] = useState<ShiftSlot[]>(MOCK_SHIFT_SLOTS);
|
||||
const [shifts, setShifts] = useState<ShiftEntity[]>([]);
|
||||
const [view, setView] = useState<ScheduleView>("week");
|
||||
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
|
||||
|
||||
const { data, refetch } = useQuery<allShiftsQuery>(GET_ALL_SHIFTS, {
|
||||
client: eateryClient,
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const [mutateCreateShift] = useMutation<createShiftMutation>(CREATE_SHIFT, {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
const [mutateDeleteShift] = useMutation<{ deleteShift: boolean }>(
|
||||
DELETE_SHIFT,
|
||||
{
|
||||
client: eateryClient,
|
||||
},
|
||||
);
|
||||
|
||||
const [mutateRegisterShift] = useMutation<
|
||||
{ registerShift: { staffId: string } },
|
||||
{ RegisterShiftInput: { shiftId: string } }
|
||||
>(REGISTER_SHIFT, { client: eateryClient });
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setShifts(data.allShifts);
|
||||
}, [data]);
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────────
|
||||
|
||||
const goToNextWeek = useCallback(() => {
|
||||
@@ -151,37 +243,40 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
}, [currentDate]);
|
||||
|
||||
const getShiftsForDate = useCallback(
|
||||
(date: string): ShiftSlot[] => {
|
||||
return shifts.filter((s) => s.date === date);
|
||||
(date: Date): ShiftEntity[] => {
|
||||
const key = formatDate(date);
|
||||
return shifts.filter((s) => dateKey(s.date) === key);
|
||||
},
|
||||
[shifts],
|
||||
);
|
||||
|
||||
const getShiftsForWeek = useCallback(
|
||||
(weekStart: Date): ShiftSlot[] => {
|
||||
(weekStart: Date): ShiftEntity[] => {
|
||||
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(weekStart);
|
||||
d.setDate(weekStart.getDate() + i);
|
||||
return formatDate(d);
|
||||
return d;
|
||||
});
|
||||
return shifts.filter((s) => dates.includes(s.date));
|
||||
const keys = new Set(dates.map(formatDate));
|
||||
return shifts.filter((s) => keys.has(dateKey(s.date)));
|
||||
},
|
||||
[shifts],
|
||||
);
|
||||
|
||||
const hasConflict = useCallback(
|
||||
(
|
||||
date: string,
|
||||
date: Date | string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
excludeShiftId?: string,
|
||||
): boolean => {
|
||||
const key = dateKey(date);
|
||||
return shifts.some(
|
||||
(s) =>
|
||||
s.date === date &&
|
||||
dateKey(s.date) === key &&
|
||||
s.id !== excludeShiftId &&
|
||||
s.registeredStaff.some((rs) => rs.id === staffId) &&
|
||||
(s.registeredStaff ?? []).some((rs) => rs.id === staffId) &&
|
||||
timesOverlap(startTime, endTime, s.startTime, s.endTime),
|
||||
);
|
||||
},
|
||||
@@ -189,32 +284,40 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
const getWeeklyBudget = useCallback((): number => {
|
||||
const weekDates = getWeekDates().map(formatDate);
|
||||
const weekDates = getWeekDates();
|
||||
const weekKeys = new Set(weekDates.map(formatDate));
|
||||
return shifts
|
||||
.filter((s) => weekDates.includes(s.date) && s.registeredStaff.length > 0)
|
||||
.reduce((sum, s) => sum + s.wage * s.registeredStaff.length, 0);
|
||||
.filter(
|
||||
(s) =>
|
||||
weekKeys.has(dateKey(s.date)) && (s.registeredStaff ?? []).length > 0,
|
||||
)
|
||||
.reduce(
|
||||
(sum, s) => sum + (s.wage ?? 0) * (s.registeredStaff ?? []).length,
|
||||
0,
|
||||
);
|
||||
}, [shifts, getWeekDates]);
|
||||
|
||||
// ── Shift actions ───────────────────────────────────────────────────────
|
||||
|
||||
const registerShift = useCallback(
|
||||
(
|
||||
async (
|
||||
shiftId: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
staffName: string,
|
||||
): { success: boolean; error?: string } => {
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const shift = shifts.find((s) => s.id === shiftId);
|
||||
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
|
||||
|
||||
if (shift.registeredStaff.length >= shift.maxStaff) {
|
||||
if ((shift.registeredStaff ?? []).length >= shift.maxStaff) {
|
||||
return { success: false, error: "Ca làm đã đủ người." };
|
||||
}
|
||||
|
||||
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
|
||||
if ((shift.registeredStaff ?? []).some((rs) => rs.id === staffId)) {
|
||||
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
|
||||
}
|
||||
|
||||
if (
|
||||
shift.date &&
|
||||
hasConflict(
|
||||
shift.date,
|
||||
shift.startTime,
|
||||
@@ -229,56 +332,90 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}
|
||||
|
||||
setShifts((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === shiftId
|
||||
? {
|
||||
...s,
|
||||
registeredStaff: [
|
||||
...s.registeredStaff,
|
||||
{ id: staffId, name: staffName },
|
||||
],
|
||||
status: "registered" as ShiftStatus,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await mutateRegisterShift({
|
||||
variables: { RegisterShiftInput: { shiftId } },
|
||||
});
|
||||
refetch();
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Đăng ký thất bại, thử lại sau.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
[shifts, hasConflict],
|
||||
[shifts, hasConflict, mutateRegisterShift, refetch],
|
||||
);
|
||||
|
||||
const unregisterShift = useCallback((shiftId: string, staffId: number) => {
|
||||
const unregisterShift = useCallback((shiftId: string, staffId: string) => {
|
||||
setShifts((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== shiftId) return s;
|
||||
const updated = s.registeredStaff.filter((rs) => rs.id !== staffId);
|
||||
const updated = (s.registeredStaff ?? []).filter(
|
||||
(rs) => rs.id !== staffId,
|
||||
);
|
||||
return {
|
||||
...s,
|
||||
registeredStaff: updated,
|
||||
status:
|
||||
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => {
|
||||
const newShift: ShiftSlot = {
|
||||
...shift,
|
||||
id: `shift_${Date.now()}`,
|
||||
};
|
||||
setShifts((prev) => [...prev, newShift]);
|
||||
}, []);
|
||||
const createShift = useCallback(
|
||||
async (shift: Omit<ShiftEntity, "id">) => {
|
||||
try {
|
||||
const { data } = await mutateCreateShift({
|
||||
variables: {
|
||||
shiftInput: {
|
||||
date: toLocalDateISO(shift.date ?? new Date()),
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
maxStaff: shift.maxStaff,
|
||||
wage: shift.wage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updateShift = useCallback((shift: ShiftSlot) => {
|
||||
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
||||
}, []);
|
||||
if (data) {
|
||||
// Optimistic: dùng date từ server nếu có, fallback sang local
|
||||
const serverDate = data.createShift.date
|
||||
? new Date(data.createShift.date as unknown as string)
|
||||
: shift.date;
|
||||
setShifts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...data.createShift,
|
||||
date: serverDate,
|
||||
wage: data.createShift.wage ?? shift.wage,
|
||||
registeredStaff: [],
|
||||
},
|
||||
]);
|
||||
// Refetch để đồng bộ với server
|
||||
refetch();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[createShift] mutation failed:", err);
|
||||
}
|
||||
},
|
||||
[mutateCreateShift, refetch],
|
||||
);
|
||||
|
||||
const deleteShift = useCallback((shiftId: string) => {
|
||||
const deleteShift = useCallback(
|
||||
async (shiftId: string) => {
|
||||
try {
|
||||
const { data } = await mutateDeleteShift({
|
||||
variables: { id: shiftId },
|
||||
});
|
||||
if (data?.deleteShift) {
|
||||
setShifts((prev) => prev.filter((s) => s.id !== shiftId));
|
||||
}, []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[deleteShift] mutation failed:", err);
|
||||
}
|
||||
},
|
||||
[mutateDeleteShift],
|
||||
);
|
||||
|
||||
return (
|
||||
<ShiftContext.Provider
|
||||
@@ -295,7 +432,6 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
registerShift,
|
||||
unregisterShift,
|
||||
createShift,
|
||||
updateShift,
|
||||
deleteShift,
|
||||
getShiftsForDate,
|
||||
getShiftsForWeek,
|
||||
|
||||
+85
-48
@@ -2,31 +2,13 @@
|
||||
export type UserRole = "manager" | "staff" | "customer";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar: string | null;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
// ===== MENU TYPES =====
|
||||
export interface MenuCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // FontAwesome class e.g. "fa-solid fa-mug-hot"
|
||||
}
|
||||
|
||||
// ===== PRODUCT TYPES =====
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string; // matches MenuCategory.id
|
||||
price: number;
|
||||
image: string;
|
||||
description: string;
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
// ===== SHOP INFO TYPES =====
|
||||
export interface WifiInfo {
|
||||
name: string;
|
||||
@@ -99,22 +81,6 @@ export interface FinancialSummary {
|
||||
profitComparison: PeriodComparison;
|
||||
}
|
||||
|
||||
// ===== COMBO TYPES =====
|
||||
export interface ComboItem {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Combo {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
image: string;
|
||||
items: ComboItem[]; // list of products + quantities in this combo
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// ===== SHIFT / SCHEDULE TYPES =====
|
||||
export type ShiftStatus =
|
||||
| "available"
|
||||
@@ -122,26 +88,97 @@ export type ShiftStatus =
|
||||
| "approved_leave"
|
||||
| "absent";
|
||||
|
||||
export interface RegisteredStaff {
|
||||
id: number;
|
||||
name: string;
|
||||
export interface ShiftRegistrationEntity {
|
||||
id: string;
|
||||
staffId: string;
|
||||
shift: ShiftEntity;
|
||||
}
|
||||
|
||||
export interface ShiftSlot {
|
||||
export interface ShiftEntity {
|
||||
id: string;
|
||||
date: string; // ISO date string "YYYY-MM-DD"
|
||||
startTime: string; // "HH:mm"
|
||||
endTime: string; // "HH:mm"
|
||||
durationHours: number;
|
||||
wage: number; // VND per shift
|
||||
department: string;
|
||||
name?: string;
|
||||
date?: Date;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
wage?: number;
|
||||
maxStaff: number;
|
||||
registeredStaff: RegisteredStaff[];
|
||||
status: ShiftStatus;
|
||||
registeredStaff?: ShiftRegistrationEntity[];
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // FontAwesome class
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface MenuItemEntity {
|
||||
id?: string;
|
||||
name: string;
|
||||
price: number;
|
||||
eatery?: EateryEntity;
|
||||
imageUrl: string;
|
||||
available: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface EateryEntity {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
name: string;
|
||||
menuItems: MenuItemEntity[];
|
||||
shifts: ShiftEntity[];
|
||||
}
|
||||
|
||||
export interface allEateriesQuery {
|
||||
allEateries: EateryEntity[];
|
||||
}
|
||||
|
||||
export interface addMenuItemMutation {
|
||||
addMenuItem: MenuItemEntity;
|
||||
}
|
||||
|
||||
export interface updateMenuItemMutation {
|
||||
updateMenuItem: MenuItemEntity;
|
||||
}
|
||||
|
||||
export interface deleteMenuItemMutation {
|
||||
deleteMenuItem: boolean;
|
||||
}
|
||||
|
||||
export interface CartItemEntity {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
priceAtTimeOfAdding?: number;
|
||||
}
|
||||
|
||||
export interface CartEntity {
|
||||
Id: string;
|
||||
eateryId: string;
|
||||
items: CartItemEntity[];
|
||||
totalAmount: number;
|
||||
paymentQrUrl: string;
|
||||
}
|
||||
|
||||
export interface getCartQuery {
|
||||
getCart: CartEntity;
|
||||
}
|
||||
|
||||
export interface createCartMutation {
|
||||
createCart: string;
|
||||
}
|
||||
|
||||
export interface addMenuItemMutation {
|
||||
addItem: CartEntity;
|
||||
}
|
||||
|
||||
export interface allRegistrationsQuery {
|
||||
allRegistrations: ShiftRegistrationEntity[];
|
||||
}
|
||||
|
||||
export interface allShiftsQuery {
|
||||
allShifts: ShiftEntity[];
|
||||
}
|
||||
|
||||
export interface createShiftMutation {
|
||||
createShift: ShiftEntity;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const gatewayUrl = "http://172.17.0.1:32080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${gatewayUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+238
-106
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20.19.37",
|
||||
"@apollo/client": "^4.1.9",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@types/node": "^20.19.39",
|
||||
"@types/react": "^19.2.14",
|
||||
"graphql": "^16.14.0",
|
||||
"next": "16.1.7",
|
||||
"nextjs": "^0.0.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,10 +28,10 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.4",
|
||||
"semantic-release": "^25.0.3"
|
||||
}
|
||||
},
|
||||
@@ -95,6 +96,43 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@apollo/client": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.1.9.tgz",
|
||||
"integrity": "sha512-qfpkQD51tdU/7iAR6aLb4w9o/L7I475DluWHRb61U/3Q0AH29nNOxOBHjBbWDdf16ncPOoQuxne1sEs2NjqBFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@wry/caches": "^1.0.0",
|
||||
"@wry/equality": "^0.5.6",
|
||||
"@wry/trie": "^0.5.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"optimism": "^0.18.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphql": "^16.0.0",
|
||||
"graphql-ws": "^5.5.5 || ^6.0.3",
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
|
||||
"rxjs": "^7.3.0",
|
||||
"subscriptions-transport-ws": "^0.9.0 || ^0.11.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"graphql-ws": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"subscriptions-transport-ws": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -598,6 +636,15 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@graphql-typed-document-node/core": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
|
||||
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -2119,47 +2166,47 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"enhanced-resolve": "^5.21.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
|
||||
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
|
||||
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.0",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.0",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
|
||||
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2173,9 +2220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
|
||||
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2189,9 +2236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
|
||||
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2205,9 +2252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
|
||||
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2221,9 +2268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
|
||||
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
|
||||
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2237,9 +2284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
|
||||
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2253,9 +2300,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
|
||||
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2269,9 +2316,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
|
||||
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2285,9 +2332,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
|
||||
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2301,9 +2348,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
|
||||
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
|
||||
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -2318,10 +2365,10 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.8.1",
|
||||
"@emnapi/runtime": "^1.8.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@emnapi/core": "^1.10.0",
|
||||
"@emnapi/runtime": "^1.10.0",
|
||||
"@emnapi/wasi-threads": "^1.2.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
@@ -2330,9 +2377,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2346,9 +2393,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
|
||||
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2362,16 +2409,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz",
|
||||
"integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz",
|
||||
"integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.2.2",
|
||||
"@tailwindcss/oxide": "4.2.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "4.2.2"
|
||||
"@tailwindcss/node": "4.3.0",
|
||||
"@tailwindcss/oxide": "4.3.0",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trivago/prettier-plugin-sort-imports": {
|
||||
@@ -2495,9 +2542,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -3090,6 +3137,54 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@wry/caches": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz",
|
||||
"integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@wry/context": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz",
|
||||
"integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@wry/equality": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz",
|
||||
"integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@wry/trie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz",
|
||||
"integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -4864,13 +4959,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
||||
"version": "5.21.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||
"integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -6715,6 +6810,30 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphql": {
|
||||
"version": "16.14.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz",
|
||||
"integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphql-tag": {
|
||||
"version": "2.12.6",
|
||||
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz",
|
||||
"integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
@@ -8825,15 +8944,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/nextjs": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nextjs/-/nextjs-0.0.3.tgz",
|
||||
"integrity": "sha512-mYbDUo4/sRAZ8TqK63PCpYnFiLg7BICG/ot9+guOrUKd4/Fo71ZmEQ41IZbH6nqbQvG7SXTBuofJXAIWfNho0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.21"
|
||||
}
|
||||
},
|
||||
"node_modules/nocache": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz",
|
||||
@@ -11083,6 +11193,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/optimism": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
|
||||
"integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@wry/caches": "^1.0.0",
|
||||
"@wry/context": "^0.7.0",
|
||||
"@wry/trie": "^0.5.0",
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -11573,9 +11695,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -11611,9 +11733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -11671,9 +11793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
|
||||
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.4.tgz",
|
||||
"integrity": "sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -12228,6 +12350,16 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -13383,15 +13515,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -11,14 +11,16 @@
|
||||
"release": "semantic-release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20.19.39",
|
||||
"@apollo/client": "^4.1.9",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/node": "^20.19.41",
|
||||
"@types/react": "^19.2.14",
|
||||
"graphql": "^16.14.0",
|
||||
"next": "16.1.7",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,7 +34,7 @@
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.4",
|
||||
"semantic-release": "^25.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+515
-424
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user