Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b37bf5d088 | |||
| 378e381454 | |||
| 8e9e48d9b4 | |||
| 43658ace21 | |||
| 9ed4889310 | |||
| dfcb1b09c0 | |||
| 877c7be84b | |||
| ae8134fd64 | |||
| edbf675997 |
@@ -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,119 @@
|
||||
---
|
||||
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,81 @@
|
||||
---
|
||||
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,112 +1,132 @@
|
||||
---
|
||||
name: prompt-optimizer
|
||||
description:
|
||||
Help users rewrite and improve AI/LLM prompts by adding specificity, context,
|
||||
and constraints. Trigger this skill whenever users ask to improve, rewrite,
|
||||
optimize, or refine prompts for AI models. Focus on making prompts clearer,
|
||||
more specific, and more likely to produce better AI results. Present
|
||||
suggestions interactively so users can choose which improvements to apply.
|
||||
description: "Help users rewrite and improve AI/LLM prompts by adding specificity, context, and constraints. Trigger this skill whenever users ask to improve, rewrite, optimize, or refine prompts for AI models. Focus on making prompts clearer, more specific, and more likely to produce better AI results. Present suggestions interactively so users can choose which improvements to apply."
|
||||
---
|
||||
|
||||
|
||||
# Prompt Optimizer
|
||||
|
||||
|
||||
A beginner-friendly skill for improving AI/LLM prompts to get better results.
|
||||
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
This skill helps you rewrite prompts to work better with AI models like Claude.
|
||||
Instead of just giving you a rewritten prompt, it shows you specific improvement
|
||||
suggestions that you can choose to apply or skip.
|
||||
|
||||
|
||||
This skill helps you rewrite prompts to work better with AI models like Claude. Instead of just giving you a rewritten prompt, it shows you specific improvement suggestions that you can choose to apply or skip.
|
||||
|
||||
## Key Improvements
|
||||
|
||||
|
||||
When optimizing a prompt, focus on three main areas:
|
||||
|
||||
|
||||
### 1. **Specificity** — Making the Request Clear
|
||||
|
||||
Good prompts are specific about what you want. Vague prompts get vague results.
|
||||
|
||||
|
||||
**Example improvements:**
|
||||
|
||||
- Add details about format: "Give me a bullet list of 5 items" instead of "tell
|
||||
me about X"
|
||||
- Add details about format: "Give me a bullet list of 5 items" instead of "tell me about X"
|
||||
- Be clear about length: "Write 200 words" instead of "Write something short"
|
||||
- Define who the audience is: "Explain this for a 10-year-old" or "Use technical
|
||||
language"
|
||||
|
||||
- Define who the audience is: "Explain this for a 10-year-old" or "Use technical language"
|
||||
### 2. **Context** — Giving the AI Background Information
|
||||
|
||||
More context helps the AI make better decisions.
|
||||
|
||||
|
||||
**Example improvements:**
|
||||
|
||||
- Explain the goal: "I'm writing a resume, so focus on professional language"
|
||||
- Share constraints: "We only have $500 budget" or "It needs to work on mobile"
|
||||
- Provide background: "I already know Python but not JavaScript"
|
||||
|
||||
### 3. **Constraints** — Setting Boundaries
|
||||
|
||||
Constraints prevent unwanted outputs.
|
||||
|
||||
|
||||
**Example improvements:**
|
||||
|
||||
- Set length limits: "Keep it under 100 words"
|
||||
- Specify format: "Use JSON format" or "Write as a numbered list"
|
||||
- Define tone: "Be casual and friendly, not formal"
|
||||
- Say what NOT to include: "Don't use technical jargon"
|
||||
|
||||
## How to Use This Skill
|
||||
|
||||
|
||||
1. **Share your prompt** — Give me the original prompt you want to improve
|
||||
2. **Review suggestions** — I'll show you specific improvements in each area
|
||||
3. **Choose what you like** — Pick which suggestions to apply
|
||||
4. **Get the final version** — I'll rewrite your prompt with your chosen
|
||||
improvements
|
||||
|
||||
4. **Get the final version** — I'll rewrite your prompt with your chosen improvements
|
||||
## Interactive Selection Process
|
||||
|
||||
|
||||
When you use this skill, you'll see:
|
||||
|
||||
|
||||
- **Original prompt** — Your starting point
|
||||
- **Improvement suggestions** — Specific changes grouped by category
|
||||
(Specificity, Context, Constraints)
|
||||
- **Preview examples** — What each change would look like with the improvement
|
||||
applied
|
||||
- **Your choices** — You pick which suggestions help most (you can apply all,
|
||||
some, or none)
|
||||
|
||||
- **Improvement suggestions** — Specific changes grouped by category (Specificity, Context, Constraints)
|
||||
- **Preview examples** — What each change would look like with the improvement applied
|
||||
- **Your choices** — You pick which suggestions help most (you can apply all, some, or none)
|
||||
Then you get a rewritten prompt combining all your choices.
|
||||
|
||||
|
||||
### Example Interaction
|
||||
|
||||
|
||||
**Original:** "Write me a blog post"
|
||||
|
||||
|
||||
**Suggestions I might offer:**
|
||||
|
||||
- **Specificity**: Add a topic (e.g., "about sustainable living")
|
||||
- **Context**: Explain your goal (e.g., "to build authority on my website")
|
||||
- **Constraints**: Set a word count (e.g., "800-1000 words")
|
||||
|
||||
**Your choice:** "I want all three — add topic, goal, and word count"
|
||||
|
||||
**Final rewritten prompt:** "Write an 800-1000 word blog post about sustainable
|
||||
living for my website. The goal is to establish my authority on eco-friendly
|
||||
practices. Target an audience of people interested in reducing their carbon
|
||||
footprint. Include 3-4 practical tips they can implement immediately, and end
|
||||
with a call-to-action encouraging them to sign up for my newsletter."
|
||||
|
||||
|
||||
**Final rewritten prompt:**
|
||||
"Write an 800-1000 word blog post about sustainable living for my website. The goal is to establish my authority on eco-friendly practices. Target an audience of people interested in reducing their carbon footprint. Include 3-4 practical tips they can implement immediately, and end with a call-to-action encouraging them to sign up for my newsletter."
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
|
||||
- **Start simple** — Even small improvements help
|
||||
- **Focus on your goal** — What outcome do you want?
|
||||
- **Add one constraint at a time** — Too many rules can be confusing
|
||||
- **Test and iterate** — Try the new prompt and see if results improve
|
||||
|
||||
## What Makes a Good Prompt
|
||||
|
||||
|
||||
A prompt becomes "good" when:
|
||||
|
||||
- The AI understands exactly what you want ✓
|
||||
- You've given enough context to explain why ✓
|
||||
- You've set boundaries to prevent bad outputs ✓
|
||||
- Someone else could read it and understand your intent ✓
|
||||
---
|
||||
|
||||
## 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"]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
version: 10.9.0
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -95,7 +95,6 @@ jobs:
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Prepare Docker Metadata
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
id: meta
|
||||
run: |
|
||||
# Lowercase Repository
|
||||
@@ -108,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,10 @@
|
||||
# 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">
|
||||
Explore Shops
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
||||
Find and choose your favorite shop
|
||||
</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"
|
||||
>
|
||||
Clear filters
|
||||
</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>Filter shops</span>
|
||||
</div>
|
||||
|
||||
{/* Name search */}
|
||||
<SearchBar
|
||||
value={searchName}
|
||||
onChange={setSearchName}
|
||||
onClear={() => setSearchName("")}
|
||||
placeholder="Search by shop name..."
|
||||
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="Search by address..."
|
||||
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="Clear address 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export default function LoginOtpPage() {
|
||||
|
||||
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");
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function LoginPasswordPage() {
|
||||
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);
|
||||
@@ -21,11 +22,12 @@ export default function LoginPasswordPage() {
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || storedRole !== "manager") {
|
||||
if (!storedPhone || (storedRole !== "manager" && storedRole !== "staff")) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
setRole(storedRole);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
@@ -43,16 +45,18 @@ export default function LoginPasswordPage() {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, password, role: "manager" }),
|
||||
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("/manager");
|
||||
router.push(role === "staff" ? "/staff/schedule" : "/manager");
|
||||
} else {
|
||||
const STATUS_ERROR_MAP: Record<number, string> = {
|
||||
400: "Incorrect login details, please try again",
|
||||
@@ -68,7 +72,10 @@ export default function LoginPasswordPage() {
|
||||
setErrors({ password: "", general: msg });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ password: "", general: "Unable to connect, please try again" });
|
||||
setErrors({
|
||||
password: "",
|
||||
general: "Unable to connect, please try again",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -134,7 +141,9 @@ export default function LoginPasswordPage() {
|
||||
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>
|
||||
<i
|
||||
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
|
||||
@@ -14,13 +14,24 @@ export default function ManagerSignupPage() {
|
||||
|
||||
const [pageState, setPageState] = useState<PageState>("checking");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", phone: "", password: "", eateryName: "" });
|
||||
const [errors, setErrors] = useState({ name: "", phone: "", password: "", eateryName: "", submit: "" });
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
eateryName: "",
|
||||
});
|
||||
const [errors, setErrors] = useState({
|
||||
name: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
eateryName: "",
|
||||
submit: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/manager/signup")
|
||||
.then((res) => {
|
||||
setPageState("available")
|
||||
setPageState("available");
|
||||
// if (res.ok) ;
|
||||
// else if (res.status === 403) setPageState("closed");
|
||||
// else setPageState("error");
|
||||
@@ -28,21 +39,32 @@ export default function ManagerSignupPage() {
|
||||
.catch(() => setPageState("error"));
|
||||
}, []);
|
||||
|
||||
const validatePhone = (phone: string) => /^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
|
||||
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 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: "", submit: "" };
|
||||
const next = {
|
||||
name: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
eateryName: "",
|
||||
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)";
|
||||
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";
|
||||
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";
|
||||
setErrors(next);
|
||||
return !next.name && !next.phone && !next.password && !next.eateryName;
|
||||
};
|
||||
@@ -67,7 +89,10 @@ export default function ManagerSignupPage() {
|
||||
ExistedUser: "Phone number already registered",
|
||||
InvalidPhoneNumber: "Invalid phone number",
|
||||
};
|
||||
setErrors({ ...errors, submit: errorMap[errorCode] ?? "An error occurred, please try again" });
|
||||
setErrors({
|
||||
...errors,
|
||||
submit: errorMap[errorCode] ?? "An error occurred, please try again",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setErrors({ ...errors, submit: "Unable to connect, please try again" });
|
||||
@@ -82,10 +107,21 @@ export default function ManagerSignupPage() {
|
||||
{/* 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 />
|
||||
<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>
|
||||
<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 */}
|
||||
@@ -103,14 +139,19 @@ export default function ManagerSignupPage() {
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -122,13 +163,36 @@ export default function ManagerSignupPage() {
|
||||
<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)">Không thể kết nối</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">Không thể kiểm tra trạng thái đăng ký. Vui lòng thử lại.</p>
|
||||
<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")); }}>
|
||||
Thử lại
|
||||
<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">Quay lại đăng nhập</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-(--color-primary) underline"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -136,17 +200,50 @@ export default function ManagerSignupPage() {
|
||||
{pageState === "available" && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{[
|
||||
{ id: "name", label: "Họ tên", icon: "fa-user", placeholder: "Nguyễn Văn A", field: "name" as const, type: "text" },
|
||||
{ id: "phone", label: "Số điện thoại", icon: "fa-phone", placeholder: "0987654321", field: "phone" as const, type: "tel" },
|
||||
{ id: "password", label: "Mật khẩu", icon: "fa-lock", placeholder: "Ít nhất 6 ký tự", field: "password" as const, type: "password" },
|
||||
{ id: "eateryName", label: "Tên nhà hàng", icon: "fa-store", placeholder: "Coffee & More", field: "eateryName" as const, type: "text" },
|
||||
{
|
||||
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",
|
||||
},
|
||||
].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
|
||||
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>
|
||||
<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}
|
||||
@@ -154,7 +251,7 @@ export default function ManagerSignupPage() {
|
||||
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 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)"}`}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 pl-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[field] ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors[field] && (
|
||||
@@ -174,18 +271,26 @@ export default function ManagerSignupPage() {
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg" disabled={isLoading}>
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử lý...</>
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>Processing...
|
||||
</>
|
||||
) : (
|
||||
"Đăng ký"
|
||||
"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"
|
||||
>
|
||||
Đã có tài khoản? Đăng nhập
|
||||
Already have an account? Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+74
-38
@@ -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 { useAuth } from "@/lib/auth-context";
|
||||
import { eateryClient } from "@/lib/apollo-clients";
|
||||
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,40 +31,77 @@ 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="min-w-0 w-full 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 ?? "All";
|
||||
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("")}
|
||||
|
||||
+82
-66
@@ -2,26 +2,35 @@
|
||||
|
||||
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 { products } = useManager();
|
||||
|
||||
const findProduct = (id: string): MenuItemEntity =>
|
||||
products.find((i) => i.id == id) ??
|
||||
({
|
||||
name: "Unknown product",
|
||||
description: "",
|
||||
price: 0,
|
||||
} as MenuItemEntity);
|
||||
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const isCustomer = user?.role === "customer";
|
||||
|
||||
return (
|
||||
@@ -32,13 +41,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">
|
||||
@@ -66,61 +75,71 @@ export default function PaymentPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-(--color-border-light)"
|
||||
>
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
</td>
|
||||
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
|
||||
<p className="line-clamp-2">{item.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decreaseQty(item.id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Giảm số lượng ${item.name}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
setQuantity(item.id, Number(e.target.value))
|
||||
}
|
||||
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
||||
title="Nhập số lượng"
|
||||
/>
|
||||
<button
|
||||
onClick={() => increaseQty(item.id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Tăng số lượng ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
variant="danger"
|
||||
size="md"
|
||||
style="payment"
|
||||
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
|
||||
{items?.map(
|
||||
({
|
||||
productId: id,
|
||||
priceAtTimeOfAdding: price,
|
||||
quantity,
|
||||
}) => {
|
||||
const { name, description } = findProduct(id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
className={`border-t border-(--color-border-light) ${quantity == 0 ? "hidden" : ""}`}
|
||||
>
|
||||
Delete product
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-(--color-primary)">
|
||||
{formatPrice(price)}
|
||||
</td>
|
||||
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
|
||||
<p className="line-clamp-2">{description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decreaseQty(id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Decrease quantity of ${name}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(e) =>
|
||||
setQuantity(id, Number(e.target.value))
|
||||
}
|
||||
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
||||
title="Enter quantity"
|
||||
/>
|
||||
<button
|
||||
onClick={() => increaseQty(id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Increase quantity of ${name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
onClick={() => removeFromCart(id)}
|
||||
variant="danger"
|
||||
size="md"
|
||||
style="payment"
|
||||
aria-label={`Remove ${name} from cart`}
|
||||
>
|
||||
Delete product
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -132,14 +151,11 @@ export default function PaymentPage() {
|
||||
totalPrice={totalPrice}
|
||||
isCustomer={isCustomer}
|
||||
backHref="/"
|
||||
eateryId={eateryId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ export default function RegisterPage() {
|
||||
ExistedUser: "Phone number already registered",
|
||||
InvalidPhoneNumber: "Invalid phone number",
|
||||
};
|
||||
const msg = phoneErrorMap[errorCode] ?? "An error occurred, please try again";
|
||||
const msg =
|
||||
phoneErrorMap[errorCode] ?? "An error occurred, please try again";
|
||||
setErrors({ phone: msg, otp: "" });
|
||||
}
|
||||
} catch {
|
||||
@@ -364,7 +365,7 @@ export default function RegisterPage() {
|
||||
type="button"
|
||||
onClick={sendOtp}
|
||||
disabled={otpSending || isLoading}
|
||||
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
|
||||
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
|
||||
>
|
||||
{otpSending ? (
|
||||
<>
|
||||
|
||||
@@ -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: "By day",
|
||||
week: "By week",
|
||||
month: "By month",
|
||||
year: "By year",
|
||||
};
|
||||
|
||||
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 = "Category:",
|
||||
}: {
|
||||
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="Category"
|
||||
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">All</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">
|
||||
Financial Statistics & Analytics
|
||||
</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="Select period"
|
||||
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">
|
||||
Overview
|
||||
</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="Total Revenue"
|
||||
value={formatCurrency(totalRevenue)}
|
||||
subtitle={PERIOD_LABELS[period]}
|
||||
change={revComp.change}
|
||||
changePercent={revComp.changePercent}
|
||||
isPositive={revComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-receipt"
|
||||
title="Total Orders"
|
||||
value={totalOrders.toLocaleString()}
|
||||
subtitle="Total orders in period"
|
||||
change={ordComp.change}
|
||||
changePercent={ordComp.changePercent}
|
||||
isPositive={ordComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-circle-dollar-to-slot"
|
||||
title="Total Profit"
|
||||
value={formatCurrency(totalProfit)}
|
||||
subtitle="Estimated from sales data"
|
||||
change={proComp.change}
|
||||
changePercent={proComp.changePercent}
|
||||
isPositive={proComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-basket-shopping"
|
||||
title="Avg. Order Value"
|
||||
value={formatCurrency(avgOrderValue)}
|
||||
subtitle="Revenue / number of orders"
|
||||
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>
|
||||
Revenue Chart
|
||||
</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)">
|
||||
Revenue over time — {PERIOD_LABELS[period]}
|
||||
</p>
|
||||
<LineChart data={revenueData} height={220} />
|
||||
</>
|
||||
)}
|
||||
{activeChart === "bar" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Comparing revenue of the first and second half of the current period
|
||||
</p>
|
||||
<BarChart
|
||||
current={barCurrent}
|
||||
previous={barPrevious}
|
||||
height={220}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeChart === "pie" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Revenue share by product category
|
||||
</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 best-selling products
|
||||
</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} cups
|
||||
</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>
|
||||
Detailed product analytics
|
||||
</h2>
|
||||
<CategorySelect
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
label="Filter category:"
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Click column headers to sort. Showing {filteredSales.length}{" "}
|
||||
products.
|
||||
</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)">
|
||||
Total revenue:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary)">
|
||||
{formatCurrencyFull(filteredRevenue)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Total profit:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrencyFull(filteredProfit)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Total units:{" "}
|
||||
</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{filteredUnits.toLocaleString()} cups
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Avg. profit margin:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-yellow-700">
|
||||
{avgMargin.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"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 pl-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[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>
|
||||
);
|
||||
}
|
||||
+202
-61
@@ -1,19 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CategoriesTab,
|
||||
CombosTab,
|
||||
MenuItemsTab,
|
||||
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 = [
|
||||
{
|
||||
@@ -23,28 +20,29 @@ export default function ManagerPage() {
|
||||
count: products.length,
|
||||
},
|
||||
{
|
||||
id: "combos" as const,
|
||||
label: "Combo",
|
||||
icon: "fa-solid fa-layer-group",
|
||||
count: combos.length,
|
||||
},
|
||||
{
|
||||
id: "categories" as const,
|
||||
label: "Categories",
|
||||
icon: "fa-solid fa-tags",
|
||||
count: categories.length,
|
||||
},
|
||||
{
|
||||
id: "menu-items" as const,
|
||||
label: "Menu",
|
||||
icon: "fa-solid fa-bowl-food",
|
||||
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)">
|
||||
@@ -110,6 +108,18 @@ export default function ManagerPage() {
|
||||
</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">
|
||||
@@ -145,50 +155,176 @@ 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">
|
||||
<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)">
|
||||
Manage{" "}
|
||||
<p className="truncate text-xs text-(--color-text-muted)">
|
||||
{activeTab === "products"
|
||||
? "menu items"
|
||||
: activeTab === "combos"
|
||||
? "combos"
|
||||
: activeTab === "categories"
|
||||
? "categories"
|
||||
: "menu"}{" "}
|
||||
for your store
|
||||
? "Manage menu items for your store"
|
||||
: "Customer reviews for your eatery"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex items-center gap-1 lg:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
}`}
|
||||
{/* ── 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}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
<i className={tab.icon}></i>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
|
||||
>
|
||||
<i className={tab.icon}></i>
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
<span>Finance</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/staff/schedule"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-days"></i>
|
||||
<span>Shifts</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/manager/create-staff"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-user-plus"></i>
|
||||
<span>Create Staff</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
<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>
|
||||
))}
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
<span className="hidden sm:inline">Finance</span>
|
||||
</Link>
|
||||
</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 right-0 top-full 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 */}
|
||||
{/* ── Desktop actions (lg+): sidebar handles nav, just show shortcut ── */}
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
@@ -197,6 +333,13 @@ export default function ManagerPage() {
|
||||
<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"
|
||||
@@ -209,9 +352,7 @@ export default function ManagerPage() {
|
||||
|
||||
<main className="flex-1 p-5 md:p-8">
|
||||
{activeTab === "products" && <ProductsTab />}
|
||||
{activeTab === "combos" && <CombosTab />}
|
||||
{activeTab === "categories" && <CategoriesTab />}
|
||||
{activeTab === "menu-items" && <MenuItemsTab />}
|
||||
{activeTab === "reviews" && <ReviewsTab />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+201
-187
@@ -7,23 +7,13 @@ 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 = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
@@ -41,70 +31,71 @@ function formatDateShort(d: Date): string {
|
||||
export default function StaffSchedulePage() {
|
||||
const { user, logout } = useAuth();
|
||||
const {
|
||||
view,
|
||||
setView,
|
||||
currentDate,
|
||||
goToNextWeek,
|
||||
goToPrevWeek,
|
||||
goToNextMonth,
|
||||
goToPrevMonth,
|
||||
goToToday,
|
||||
getWeeklyBudget,
|
||||
view, setView, currentDate,
|
||||
goToNextWeek, goToPrevWeek,
|
||||
goToNextMonth, 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-bold">Work Schedule</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{isManager ? "Manager" : "Staff"}
|
||||
</p>
|
||||
{/* Brand — gradient accent */}
|
||||
<div className="bg-gradient-to-br from-(--color-primary) to-(--color-primary-dark) px-5 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20 shadow-sm backdrop-blur-sm">
|
||||
<i className="fa-solid fa-calendar-days text-base text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">Work Schedule</p>
|
||||
<p className="text-xs text-white/70">
|
||||
{isManager ? "Manager View" : "Staff View"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-3">
|
||||
{/* View toggle */}
|
||||
<p className="mb-1 px-3 pt-2 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
|
||||
View
|
||||
</p>
|
||||
<button
|
||||
@@ -112,35 +103,41 @@ export default function StaffSchedulePage() {
|
||||
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>
|
||||
<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>
|
||||
<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">
|
||||
{/* 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">Today</span>
|
||||
@@ -149,57 +146,80 @@ export default function StaffSchedulePage() {
|
||||
|
||||
{/* 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">
|
||||
Management
|
||||
<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">
|
||||
Weekly Budget
|
||||
{/* Stats cards */}
|
||||
<div className="mt-3 space-y-2 border-t border-(--color-border-light) pt-3">
|
||||
{isManager && (
|
||||
<div className="rounded-xl bg-gradient-to-br from-(--color-primary)/10 to-(--color-primary)/5 p-3">
|
||||
<p className="text-[10px] font-bold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Weekly budget
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-bold text-(--color-primary)">
|
||||
{weeklyBudget >= 1_000_000
|
||||
? `${(weeklyBudget / 1_000_000).toFixed(1)}M`
|
||||
: weeklyBudget.toLocaleString("vi-VN")}
|
||||
</p>
|
||||
<p className="text-[10px] text-(--color-text-muted)">VND</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl bg-gray-50 p-3">
|
||||
<p className="text-[10px] font-bold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Shifts this week
|
||||
</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-text-secondary)">
|
||||
{totalShiftsThisWeek}
|
||||
</p>
|
||||
<p className="text-[10px] text-(--color-text-muted)">VND</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)">
|
||||
<i
|
||||
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
|
||||
></i>
|
||||
<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 ?? "Staff"}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
<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"}
|
||||
</p>
|
||||
</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>
|
||||
<i className="fa-solid fa-house text-[10px]"></i>
|
||||
Home
|
||||
</Link>
|
||||
<button
|
||||
@@ -207,122 +227,122 @@ export default function StaffSchedulePage() {
|
||||
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>
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="text-foreground text-base font-bold md:text-lg">
|
||||
Register Shift
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{view === "week"
|
||||
? weekLabel
|
||||
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle (mobile) */}
|
||||
<div className="flex items-center rounded-xl border border-(--color-border-light) lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("week")}
|
||||
className={`cursor-pointer rounded-l-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
view === "week"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
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 ${
|
||||
view === "month"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
<header className="sticky top-0 z-40 border-b border-(--color-border-light) bg-white/95 px-4 py-3 shadow-sm backdrop-blur-sm md:px-6 md:py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-foreground text-base font-bold md:text-lg">
|
||||
{isManager ? "Manage Shifts" : "Register Shifts"}
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{view === "week"
|
||||
? `Week: ${weekLabel}`
|
||||
: `Month: ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<button
|
||||
title="Previous"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToToday}
|
||||
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
title="Next"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mobile view toggle */}
|
||||
<div className="flex items-center overflow-hidden rounded-xl border border-(--color-border-light) lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("week")}
|
||||
className={`cursor-pointer rounded-l-xl border-none px-3 py-2 text-xs font-semibold transition ${
|
||||
view === "week"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("month")}
|
||||
className={`cursor-pointer rounded-r-xl border-none px-3 py-2 text-xs font-semibold transition ${
|
||||
view === "month"
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create shift button (manager only) */}
|
||||
{isManager && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateDate(undefined);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Create Shift
|
||||
</button>
|
||||
)}
|
||||
{/* Desktop navigation arrows */}
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<button
|
||||
title="Previous"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-white text-(--color-text-muted) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToToday}
|
||||
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-white px-3 py-1.5 text-xs font-semibold text-(--color-text-secondary) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
title="Next"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-white text-(--color-text-muted) transition hover:border-(--color-primary)/30 hover:bg-(--color-primary)/5 hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile nav */}
|
||||
<div className="flex items-center gap-1 md:hidden">
|
||||
<button
|
||||
title="Previous"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
title="Next"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
{/* Create shift (manager, desktop) */}
|
||||
{isManager && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreateDate(undefined); setCreateOpen(true); }}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:opacity-90 md:flex"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Create shift
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile navigation arrows */}
|
||||
<div className="flex items-center gap-1 md:hidden">
|
||||
<button
|
||||
title="Previous"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
title="Next"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</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 +357,7 @@ export default function StaffSchedulePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile view */}
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{view === "week" ? (
|
||||
<WeeklySchedule
|
||||
@@ -350,16 +370,13 @@ export default function StaffSchedulePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for manager */}
|
||||
{/* FAB for mobile manager */}
|
||||
{isManager && (
|
||||
<button
|
||||
title="Create Shift"
|
||||
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"
|
||||
onClick={() => { setCreateDate(undefined); setCreateOpen(true); }}
|
||||
className="fixed right-4 bottom-6 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-xl transition hover:opacity-90 md:hidden"
|
||||
>
|
||||
<i className="fa-solid fa-plus text-lg"></i>
|
||||
</button>
|
||||
@@ -371,10 +388,7 @@ export default function StaffSchedulePage() {
|
||||
<ShiftDetailModal
|
||||
shift={selectedShift}
|
||||
isOpen={detailOpen}
|
||||
onClose={() => {
|
||||
setDetailOpen(false);
|
||||
setSelectedShift(null);
|
||||
}}
|
||||
onClose={() => { setDetailOpen(false); setSelectedShift(null); }}
|
||||
/>
|
||||
<ShiftCreateModal
|
||||
isOpen={createOpen}
|
||||
|
||||
+1
-4
@@ -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>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import { formatPrice } from "@/app/(main)/payment/page";
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { ReviewModal } from "@/components/organisms/modals";
|
||||
import Link from "next/link";
|
||||
@@ -5,13 +6,11 @@ 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 [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const handlePayment = () => {
|
||||
@@ -86,6 +85,7 @@ export default function PaymentSummaryCard({
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
eateryId={eateryId}
|
||||
/>
|
||||
</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"
|
||||
|
||||
@@ -1,62 +1,64 @@
|
||||
"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");
|
||||
}
|
||||
|
||||
export default function ShiftCard({
|
||||
shift,
|
||||
compact = false,
|
||||
onClick,
|
||||
}: ShiftCardProps) {
|
||||
const style = STATUS_STYLES[shift.status];
|
||||
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 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">
|
||||
{shift.startTime} – {shift.endTime}
|
||||
</p>
|
||||
<p className="mt-0.5 opacity-75">
|
||||
{shift.durationHours}h · {formatWage(shift.wage)}
|
||||
</p>
|
||||
<div className="px-2.5 py-2">
|
||||
<p className={`text-xs font-bold leading-tight ${s.text}`}>
|
||||
{shift.startTime}–{shift.endTime}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center justify-between gap-1">
|
||||
<p className="text-[10px] text-(--color-text-muted)">{formatWage(shift.wage ?? 0)}đ</p>
|
||||
<span className={`rounded-full px-1.5 py-px text-[9px] font-semibold ${s.badge}`}>
|
||||
{registeredCount}/{shift.maxStaff}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -65,55 +67,44 @@ 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">
|
||||
{shift.startTime} – {shift.endTime}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-75">
|
||||
{shift.durationHours}h · {formatWage(shift.wage)} VND
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||
shift.status === "available"
|
||||
? "bg-blue-200 text-blue-800"
|
||||
: shift.status === "registered"
|
||||
? "bg-blue-300 text-blue-900"
|
||||
: shift.status === "approved_leave"
|
||||
? "bg-purple-200 text-purple-800"
|
||||
: "bg-red-200 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{shift.registeredStaff.length > 0 && (
|
||||
<div className="mt-2 border-t border-current/10 pt-2">
|
||||
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
|
||||
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{shift.registeredStaff.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
|
||||
<p className={`text-base font-bold ${s.text}`}>
|
||||
{shift.startTime} – {shift.endTime}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-(--color-text-muted)">
|
||||
{(shift.wage ?? 0).toLocaleString("vi-VN")} VND/ca
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${s.badge}`}>
|
||||
{registeredCount}/{shift.maxStaff} staff
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{registeredCount > 0 && (
|
||||
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||
<p className="mb-2 text-[10px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Registered staff
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shift.registeredStaff!.map((staff) => (
|
||||
<span
|
||||
key={staff.id}
|
||||
className="rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
{staff.staffId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -21,7 +21,10 @@ export default function LoginForm() {
|
||||
return false;
|
||||
}
|
||||
if (!PHONE_REGEX.test(phone)) {
|
||||
setErrors({ phone: "Invalid phone number (e.g. 0987654321)", general: "" });
|
||||
setErrors({
|
||||
phone: "Invalid phone number (e.g. 0987654321)",
|
||||
general: "",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -41,16 +44,19 @@ export default function LoginForm() {
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
|
||||
const role = (await res.text().catch(() => "")).trim();
|
||||
const role = (await res.text().catch(() => "")).trim().toLowerCase();
|
||||
|
||||
if (res.ok && (role === "customer" || role === "manager")) {
|
||||
if (res.ok && (role === "customer" || role === "manager" || role === "staff")) {
|
||||
sessionStorage.setItem("login_phone", phone);
|
||||
sessionStorage.setItem("login_role", role);
|
||||
router.push(role === "manager" ? "/login/password" : "/login/otp");
|
||||
router.push(role === "customer" ? "/login/otp" : "/login/password");
|
||||
} else if (res.status === 404) {
|
||||
setErrors({ phone: "", general: "Phone number not registered" });
|
||||
} else {
|
||||
setErrors({ phone: "", general: "An error occurred, please try again" });
|
||||
setErrors({
|
||||
phone: "",
|
||||
general: "An error occurred, please try again",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setErrors({ phone: "", general: "Unable to connect, please try again" });
|
||||
|
||||
@@ -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>{" "}
|
||||
categories
|
||||
</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">Add category</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} items</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => setModalCategory(cat)}
|
||||
title="Edit"
|
||||
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="Delete"
|
||||
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">No categories yet</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 ? "Edit category" : "Add new category"}
|
||||
</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)">
|
||||
Category name <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="e.g. Coffee"
|
||||
/>
|
||||
</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,9 @@ export default function DeleteConfirm({
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<i className="fa-solid fa-trash-can text-xl text-red-500"></i>
|
||||
</div>
|
||||
<h3 className="text-foreground text-base font-bold">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 +27,13 @@ export default function DeleteConfirm({
|
||||
onClick={onClose}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||
>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { eateryClient } from "@/lib/apollo-clients";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { addManagerMenuItem, gqlFetch } from "@/lib/graphql";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
MenuItemEntity,
|
||||
addMenuItemMutation,
|
||||
allEateriesQuery,
|
||||
} from "@/lib/types";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
const GET_EATERY_MENU = gql`
|
||||
query GetEateryMenu {
|
||||
allEateries {
|
||||
id
|
||||
menuItems {
|
||||
id
|
||||
name
|
||||
price
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Eatery {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
}
|
||||
const ADD_MENU_ITEM = gql`
|
||||
mutation addMenuItem($menuItem: AddMenuItemInput!) {
|
||||
addMenuItem(menuItem: $menuItem) {
|
||||
id
|
||||
name
|
||||
price
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
@@ -21,10 +40,20 @@ function formatPrice(price: number) {
|
||||
|
||||
export default function MenuItemsTab() {
|
||||
const { user } = useAuth();
|
||||
const [eateryId, setEateryId] = useState<string | null>(null);
|
||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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("");
|
||||
@@ -32,57 +61,36 @@ export default function MenuItemsTab() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const loadEateryId = useCallback(async (): Promise<string | null> => {
|
||||
if (!user) return null;
|
||||
const data = await gqlFetch<{ allEateries: Eatery[] }>(
|
||||
"{ allEateries { id ownerId } }",
|
||||
);
|
||||
return (
|
||||
data.allEateries.find((e) => e.ownerId === String(user.id))?.id ?? null
|
||||
);
|
||||
}, [user]);
|
||||
|
||||
const loadMenuItems = useCallback(async (id: string): Promise<MenuItem[]> => {
|
||||
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
|
||||
`query($eateryId: String!) {
|
||||
menuItemsByEatery(eateryId: $eateryId) { id name price }
|
||||
}`,
|
||||
{ eateryId: id },
|
||||
);
|
||||
return data.menuItemsByEatery ?? [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const id = await loadEateryId();
|
||||
setEateryId(id);
|
||||
if (id) setMenuItems(await loadMenuItems(id));
|
||||
else setMenuItems([]);
|
||||
} catch {
|
||||
setError("Không thể tải dữ liệu menu. Kiểm tra kết nối backend.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
if (data?.allEateries?.[0]) {
|
||||
setMenuItems(data.allEateries[0].menuItems);
|
||||
}
|
||||
init();
|
||||
}, [loadEateryId, loadMenuItems]);
|
||||
}, [data]);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim() || !newPrice) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
const added = await addManagerMenuItem(newName.trim(), parseFloat(newPrice));
|
||||
if (added) setMenuItems((prev) => [...prev, added]);
|
||||
setNewName("");
|
||||
setNewPrice("");
|
||||
setShowForm(false);
|
||||
} catch {
|
||||
setSubmitError("Không thể thêm món. Vui lòng thử lại.");
|
||||
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);
|
||||
}
|
||||
@@ -95,39 +103,22 @@ export default function MenuItemsTab() {
|
||||
setNewPrice("");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (gqlLoading && menuItems.length === 0)
|
||||
return <div className="py-16 text-center">Đang tải menu...</div>;
|
||||
if (gqlError)
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner mr-2 animate-spin"></i>
|
||||
Đang tải menu...
|
||||
<div className="py-16 text-center text-red-500">
|
||||
Lỗi: {gqlError.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation mb-2 text-2xl"></i>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eateryId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
|
||||
<p className="text-sm">Quán của bạn chưa được khởi tạo trong hệ thống.</p>
|
||||
</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
|
||||
<strong className="text-foreground">{menuItems.length}</strong> món
|
||||
trong menu
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
@@ -206,7 +197,7 @@ export default function MenuItemsTab() {
|
||||
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 outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -222,7 +213,7 @@ export default function MenuItemsTab() {
|
||||
required
|
||||
min={0}
|
||||
step={500}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -237,7 +228,7 @@ export default function MenuItemsTab() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForm}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
|
||||
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>
|
||||
|
||||
@@ -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,51 +115,71 @@ 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>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={form.price}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, price: Number(e.target.value) })
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="25000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Mô tả
|
||||
Price (₫) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={form.price}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, price: Number(e.target.value) })
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="25000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Item image
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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)">
|
||||
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,154 @@
|
||||
"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;
|
||||
|
||||
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)">
|
||||
{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 italic text-(--color-text-muted)">
|
||||
No comment provided.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +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 = () => {
|
||||
setSubmitted(true);
|
||||
const handleSubmit = async () => {
|
||||
if (!user?.id || !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();
|
||||
};
|
||||
|
||||
@@ -128,6 +168,11 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
/>
|
||||
</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 +181,18 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
icon="fa-arrow-left"
|
||||
disabled={loading}
|
||||
>
|
||||
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"}
|
||||
>
|
||||
Confirm
|
||||
{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,8 +2,13 @@
|
||||
|
||||
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";
|
||||
|
||||
function toDisplayUrl(filename: string) {
|
||||
if (!filename) return "/imgs/products/placeholder.jpg";
|
||||
if (filename.startsWith("/") || filename.startsWith("http")) return filename;
|
||||
return `/api/file/${filename}`;
|
||||
}
|
||||
|
||||
import type { ProductGridProps } from "./ProductGrid.types";
|
||||
|
||||
@@ -11,68 +16,40 @@ 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) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
image={product.image}
|
||||
imageAlt={product.name}
|
||||
productName={product.name}
|
||||
price={product.price}
|
||||
description={product.description}
|
||||
onBuy={() => addToCart(product)}
|
||||
/>
|
||||
))}
|
||||
{filteredProducts.map(
|
||||
({ id, imageUrl, name, price, description }) => (
|
||||
<ProductCard
|
||||
key={id}
|
||||
image={toDisplayUrl(imageUrl)}
|
||||
imageAlt={name}
|
||||
productName={name}
|
||||
price={price}
|
||||
description={description}
|
||||
onBuy={() => addToCart({ productId: id!, quantity: 1 })}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state */
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import ShiftCard from "@/components/molecules/cards/ShiftCard";
|
||||
import { DEPARTMENTS } from "@/lib/constants";
|
||||
import { useShift } from "@/lib/shift-context";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
|
||||
|
||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
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 +21,12 @@ 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 = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
export default function MobileShiftView({
|
||||
onShiftClick,
|
||||
}: MobileShiftViewProps) {
|
||||
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
formatDateISO(new Date(2026, 3, 10)),
|
||||
);
|
||||
export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps) {
|
||||
const { currentDate, getShiftsForDate, goToNextMonth, goToPrevMonth } = useShift();
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
@@ -59,186 +39,126 @@ 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++) {
|
||||
days.push(new Date(year, month, 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="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="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>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="mb-1 grid grid-cols-7">
|
||||
{DAY_HEADERS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((date, i) => {
|
||||
if (!date) {
|
||||
return <div key={`empty-${i}`} className="p-1" />;
|
||||
}
|
||||
|
||||
const dateStr = formatDateISO(date);
|
||||
const today = isToday(date);
|
||||
const selected = dateStr === selectedDate;
|
||||
const dots = getDotColors(date);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => setSelectedDate(dateStr)}
|
||||
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
|
||||
selected ? "rounded-lg bg-(--color-primary)/10" : ""
|
||||
<div className="p-3">
|
||||
{/* Day headers */}
|
||||
<div className="mb-1 grid grid-cols-7">
|
||||
{DAY_HEADERS.map((day, i) => (
|
||||
<div
|
||||
key={day}
|
||||
className={`py-1 text-center text-[10px] font-bold uppercase ${
|
||||
i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs ${
|
||||
today
|
||||
? "bg-(--color-primary) font-bold text-white"
|
||||
: selected
|
||||
? "text-foreground font-bold"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
<div className="mt-0.5 flex gap-0.5">
|
||||
{dots.slice(0, 3).map((color, j) => (
|
||||
<span key={j} className={`h-1 w-1 rounded-full ${color}`} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</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)">
|
||||
Available
|
||||
</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)">Registered</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)">
|
||||
On leave
|
||||
</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)">Absent</span>
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((date, i) => {
|
||||
if (!date) return <div key={`empty-${i}`} className="p-1" />;
|
||||
|
||||
const today = isToday(date);
|
||||
const selected = formatDateKey(date) === formatDateKey(selectedDate);
|
||||
const dayShifts = getShiftsForDate(date);
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => setSelectedDate(date)}
|
||||
className="flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition"
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition ${
|
||||
today
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: selected
|
||||
? "bg-(--color-primary)/15 text-(--color-primary) ring-2 ring-(--color-primary)/30"
|
||||
: isWeekend
|
||||
? "text-rose-500"
|
||||
: "text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
<div className="mt-0.5 flex min-h-2 items-center justify-center gap-px">
|
||||
{dayShifts.length > 0 && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)/60" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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} shift{selectedShifts.length !== 1 ? "s" : ""})
|
||||
<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>
|
||||
</h3>
|
||||
</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)">
|
||||
No shifts for this day
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2.5">
|
||||
{selectedShifts.map((shift) => (
|
||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
||||
|
||||
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");
|
||||
@@ -23,11 +23,8 @@ function isToday(d: Date): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonthlyCalendar({
|
||||
onShiftClick,
|
||||
onDateSelect,
|
||||
}: MonthlyCalendarProps) {
|
||||
const { currentDate, shifts } = useShift();
|
||||
export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyCalendarProps) {
|
||||
const { currentDate, getShiftsForDate } = useShift();
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
@@ -35,50 +32,26 @@ 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++) {
|
||||
days.push(new Date(year, month, d));
|
||||
}
|
||||
|
||||
// Fill trailing empty cells to complete the grid
|
||||
while (days.length % 7 !== 0) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
for (let i = 0; i < startOffset; i++) days.push(null);
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) days.push(new Date(year, month, d));
|
||||
while (days.length % 7 !== 0) days.push(null);
|
||||
return days;
|
||||
}, [currentDate]);
|
||||
|
||||
const 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,75 +59,60 @@ 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} available
|
||||
</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} registered
|
||||
</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} on leave
|
||||
</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} absent
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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,59 @@ 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="select-none text-sm text-(--color-text-muted)">:</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="ml-1 mr-2 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 +64,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 +74,7 @@ export default function ShiftCreateModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDate(defaultDate ?? "2026-04-10");
|
||||
setDate(new Date(defaultDate ?? "2026-04-10"));
|
||||
}
|
||||
}, [defaultDate, isOpen]);
|
||||
|
||||
@@ -49,18 +100,16 @@ export default function ShiftCreateModal({
|
||||
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();
|
||||
@@ -103,8 +152,8 @@ export default function ShiftCreateModal({
|
||||
<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>
|
||||
@@ -115,24 +164,20 @@ export default function ShiftCreateModal({
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
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)">
|
||||
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 +185,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 +205,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 +219,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 +248,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>
|
||||
|
||||
@@ -7,6 +7,16 @@ import { useState } from "react";
|
||||
|
||||
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
|
||||
|
||||
function parseShiftDate(date: Date | string | undefined): Date | null {
|
||||
if (!date) return null;
|
||||
// Date object → dùng thẳng
|
||||
if (date instanceof Date) return isNaN(date.getTime()) ? null : date;
|
||||
// ISO string hoặc "YYYY-MM-DD" → chỉ lấy 10 ký tự đầu tránh lệch timezone
|
||||
const [y, m, d] = (date as string).slice(0, 10).split("-").map(Number);
|
||||
if (!y || !m || !d) return null;
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export default function ShiftDetailModal({
|
||||
shift,
|
||||
isOpen,
|
||||
@@ -19,23 +29,22 @@ 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 isRegistered = user
|
||||
? shift.registeredStaff.some((s) => s.id === user.id)
|
||||
: false;
|
||||
const isFull = shift.registeredStaff.length >= shift.maxStaff;
|
||||
const registeredStaff = shift.registeredStaff ?? [];
|
||||
const isRegistered = user ? registeredStaff.some((s) => s.id === user.id) : false;
|
||||
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 +52,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 = {
|
||||
@@ -81,12 +90,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,22 +111,22 @@ 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(
|
||||
{parseShiftDate(shift.date as Date | string | undefined)?.toLocaleDateString(
|
||||
"vi-VN",
|
||||
{
|
||||
weekday: "long",
|
||||
@@ -125,12 +134,12 @@ export default function ShiftDetailModal({
|
||||
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 +147,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 +164,16 @@ 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}/
|
||||
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 +183,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}
|
||||
{staff.staffId}
|
||||
</span>
|
||||
</div>
|
||||
{isManager && (
|
||||
@@ -186,7 +193,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,19 +219,16 @@ 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" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegister}
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||
Đăng ký ca
|
||||
</button>
|
||||
)}
|
||||
{!isManager && !isRegistered && !isFull && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegister}
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||
Register
|
||||
</button>
|
||||
)}
|
||||
{isRegistered && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -232,7 +236,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 +246,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 +254,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,37 +7,19 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
||||
|
||||
const MONTH_NAMES_EN = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function formatDateShort(d: Date): string {
|
||||
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const today = new Date(2026, 3, 10);
|
||||
const today = new Date(Date.now());
|
||||
return (
|
||||
d.getDate() === today.getDate() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
@@ -50,240 +32,207 @@ export default function WeeklySchedule({
|
||||
onCreateShift,
|
||||
mobileCalendarHeader = false,
|
||||
}: WeeklyScheduleProps) {
|
||||
const {
|
||||
currentDate,
|
||||
getWeekDates,
|
||||
getShiftsForDate,
|
||||
goToNextWeek,
|
||||
goToPrevWeek,
|
||||
} = useShift();
|
||||
const { currentDate, getWeekDates, getShiftsForDate, goToNextWeek, goToPrevWeek } = useShift();
|
||||
const weekDates = getWeekDates();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
formatDateISO(weekDates[0] ?? currentDate),
|
||||
);
|
||||
const [selectedDate, setSelectedDate] = useState<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);
|
||||
|
||||
const renderMobileDayView = mobileCalendarHeader && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
title="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"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<h3 className="text-base font-bold text-(--color-primary-dark)">
|
||||
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="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"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{weekDates.map((date, i) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const active = dateStr === selectedDateStr;
|
||||
const dots = statusDotsByDate[dateStr] ?? [];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(dateStr)}
|
||||
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
|
||||
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
|
||||
>
|
||||
{DAY_LABELS_EN[i]}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
active || isToday(date)
|
||||
? "bg-indigo-500 text-white"
|
||||
: i >= 5
|
||||
? "text-pink-500"
|
||||
: "text-sky-500"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
<div className="flex min-h-2 items-center gap-0.5">
|
||||
{dots.map((dot, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`h-1.5 w-1.5 rounded-full ${dot}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{DEPARTMENTS.map((dept) => {
|
||||
const deptShifts = getShiftsForDate(selectedDateStr).filter(
|
||||
(s) => s.department === dept.id,
|
||||
);
|
||||
if (deptShifts.length === 0 && !onCreateShift) return null;
|
||||
return (
|
||||
<div
|
||||
key={dept.id}
|
||||
className="rounded-xl border border-(--color-border-light) bg-white p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<i
|
||||
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||
></i>
|
||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||
{dept.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{deptShifts.map((shift) => (
|
||||
<ShiftCard
|
||||
key={shift.id}
|
||||
shift={shift}
|
||||
onClick={onShiftClick}
|
||||
/>
|
||||
))}
|
||||
{onCreateShift && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreateShift(selectedDateStr)}
|
||||
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus mr-1"></i>
|
||||
Add shift
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// ── Mobile calendar header view ────────────────────────────────────────────
|
||||
|
||||
if (mobileCalendarHeader) {
|
||||
return renderMobileDayView ?? null;
|
||||
const dayShifts = getShiftsForDate(new Date(selectedDateRef));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Week strip */}
|
||||
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) bg-gradient-to-r from-(--color-primary)/5 to-transparent px-4 py-3">
|
||||
<button
|
||||
title="Previous week"
|
||||
type="button"
|
||||
onClick={goToPrevWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/80 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<h3 className="text-sm font-bold text-(--color-primary-dark)">
|
||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Next week"
|
||||
type="button"
|
||||
onClick={goToNextWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-white/80 text-(--color-text-muted) shadow-sm transition hover:bg-white hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 p-3">
|
||||
{weekDates.map((date, i) => {
|
||||
const active = date === selectedDateRef;
|
||||
const today = isToday(new Date(date));
|
||||
const shifts = getShiftsForDate(new Date(date));
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(date)}
|
||||
className="flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none bg-transparent py-1.5 transition"
|
||||
>
|
||||
<span className={`text-[10px] font-semibold uppercase ${i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"}`}>
|
||||
{DAY_LABELS[i]}
|
||||
</span>
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold transition ${
|
||||
today
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: active
|
||||
? "bg-(--color-primary)/15 text-(--color-primary)"
|
||||
: i >= 5
|
||||
? "text-rose-500"
|
||||
: "text-(--color-text-secondary)"
|
||||
}`}>
|
||||
{new Date(date).getDate()}
|
||||
</span>
|
||||
<div className="flex min-h-2 items-center gap-px">
|
||||
{shifts.length > 0 && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)/60" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day shifts */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3 className="text-sm font-bold text-(--color-primary-dark)">
|
||||
{DAY_LABELS[(new Date(selectedDateRef).getDay() + 6) % 7]},{" "}
|
||||
{formatDateShort(new Date(selectedDateRef))}
|
||||
</h3>
|
||||
<span className="rounded-full bg-(--color-primary)/10 px-2.5 py-1 text-xs font-semibold text-(--color-primary)">
|
||||
{dayShifts.length} shifts
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{dayShifts.length === 0 && !onCreateShift ? (
|
||||
<div className="rounded-2xl border border-dashed border-(--color-border-light) py-10 text-center">
|
||||
<i className="fa-regular fa-calendar-xmark mb-2 text-3xl text-gray-200"></i>
|
||||
<p className="text-sm text-(--color-text-muted)">No shifts today</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{dayShifts.map((shift) => (
|
||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
||||
))}
|
||||
{onCreateShift && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreateShift(selectedDateRef)}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-(--color-border-light) bg-transparent py-3 text-sm font-medium text-(--color-text-muted) transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Add shift
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Desktop table view ──────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-225 border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
||||
Department
|
||||
</th>
|
||||
{weekDates.map((date, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
|
||||
isToday(date)
|
||||
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
|
||||
: "bg-gray-50 font-semibold text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
<span className="block uppercase">{DAY_LABELS[i]}</span>
|
||||
<span className="mt-0.5 block text-[11px] font-normal">
|
||||
{formatDateShort(date)}
|
||||
</span>
|
||||
<div className="overflow-hidden rounded-2xl border border-(--color-border-light) shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[800px] border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50/80 px-4 py-3 text-left text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Department
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DEPARTMENTS.map((dept) => (
|
||||
<tr key={dept.id}>
|
||||
<td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
<i
|
||||
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||
></i>
|
||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||
{dept.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{weekDates.map((date, i) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const shifts = getShiftsForDate(dateStr).filter(
|
||||
(s) => s.department === dept.id,
|
||||
);
|
||||
const today = isToday(new Date(date));
|
||||
return (
|
||||
<td
|
||||
<th
|
||||
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) px-3 py-3 text-center text-xs transition ${
|
||||
today
|
||||
? "bg-gradient-to-b from-(--color-primary)/15 to-(--color-primary)/5 font-bold text-(--color-primary)"
|
||||
: "bg-gray-50/80 font-semibold text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-h-20 flex-col gap-1">
|
||||
{shifts.map((shift) => (
|
||||
<ShiftCard
|
||||
key={shift.id}
|
||||
shift={shift}
|
||||
compact
|
||||
onClick={onShiftClick}
|
||||
/>
|
||||
))}
|
||||
{onCreateShift && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreateShift(dateStr)}
|
||||
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus mr-1"></i>
|
||||
Add shift
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wider">
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DEPARTMENTS.map((dept, deptIdx) => (
|
||||
<tr key={dept.id} className={deptIdx % 2 === 0 ? "bg-white" : "bg-gray-50/40"}>
|
||||
<td className="border-r border-b border-(--color-border-light) px-4 py-3 align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-(--color-primary)/10">
|
||||
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||
{dept.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{weekDates.map((date, i) => {
|
||||
const today = isToday(new Date(date));
|
||||
const shifts = getShiftsForDate(new Date(date));
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`border-r border-b border-(--color-border-light) p-2 align-top transition ${
|
||||
today ? "bg-(--color-primary)/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-h-[88px] flex-col gap-1.5">
|
||||
{shifts.map((shift) => (
|
||||
<ShiftCard key={shift.id} shift={shift} compact onClick={onShiftClick} />
|
||||
))}
|
||||
{onCreateShift && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreateShift(date)}
|
||||
className="mt-auto flex cursor-pointer items-center justify-center gap-1 rounded-lg border border-dashed border-gray-200 bg-transparent py-1.5 text-[10px] font-medium text-gray-300 transition hover:border-(--color-primary)/50 hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">No shops found matching your search</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// if (filtered.length === 0) {
|
||||
// return (
|
||||
// <div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||
// <i className="fa-solid fa-store text-5xl opacity-30"></i>
|
||||
// <p className="text-base font-medium">
|
||||
// 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
-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.3
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.9
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
resources:
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ export default function Header() {
|
||||
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={`Logo ${SHOP_INFO.name}`}
|
||||
alt={`${SHOP_INFO.name} logo`}
|
||||
fill
|
||||
className="object-contain transition-transform duration-200 group-hover:scale-105"
|
||||
sizes="44px"
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -14,7 +14,10 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
isInitialized: boolean;
|
||||
login: (username: string, password: string) => Promise<{ ok: boolean; status?: number }>;
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<{ ok: boolean; status?: number }>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -36,7 +39,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<{ ok: boolean; status?: number }> => {
|
||||
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";
|
||||
|
||||
|
||||
+154
-90
@@ -1,134 +1,198 @@
|
||||
"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;
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const { data: eateryData } = useQuery<allEateriesQuery>(GET_EATERY, {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
const [createCart] = useMutation<createCartMutation>(CREATE_CART, {
|
||||
client: cartClient,
|
||||
});
|
||||
|
||||
const { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, {
|
||||
client: cartClient,
|
||||
variables: { cartId },
|
||||
});
|
||||
|
||||
const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, {
|
||||
client: cartClient,
|
||||
});
|
||||
|
||||
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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
createCartFunc();
|
||||
} else if (!cartId) {
|
||||
const localCartId = localStorage.getItem(CART_ID);
|
||||
if (localCartId) setCartId(localCartId);
|
||||
else createCartFunc();
|
||||
}
|
||||
}, []);
|
||||
}, [eateryData, createCart, data, loading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
if (data?.getCart) setCart(data.getCart);
|
||||
}, [data]);
|
||||
|
||||
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 addToCart = async (product: CartItemEntity) => {
|
||||
if (!cartId) return;
|
||||
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
|
||||
return next;
|
||||
const { data: result } = await addMenuItem({
|
||||
variables: {
|
||||
cartId,
|
||||
menuItemId: product.productId!,
|
||||
quantity: product.quantity,
|
||||
},
|
||||
});
|
||||
|
||||
if (result) setCart(result.addItem);
|
||||
};
|
||||
|
||||
const increaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
|
||||
),
|
||||
);
|
||||
const setQuantity = async (id: string, newQuantity: number) => {
|
||||
if (!cartId) return;
|
||||
|
||||
const currentItem = cart.items.find((i) => i.productId == id);
|
||||
|
||||
const { data: result } = await addMenuItem({
|
||||
variables: {
|
||||
cartId,
|
||||
menuItemId: id,
|
||||
quantity: newQuantity - currentItem!.quantity,
|
||||
},
|
||||
});
|
||||
|
||||
if (result) setCart(result.addItem);
|
||||
};
|
||||
|
||||
const decreaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, quantity: Math.max(0, item.quantity - 1) }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => item.quantity > 0),
|
||||
);
|
||||
};
|
||||
const removeFromCart = (id: string) => setQuantity(id, 0);
|
||||
|
||||
const removeFromCart = (id: number) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
const increaseQty = (id: string) =>
|
||||
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity + 1);
|
||||
|
||||
const setQuantity = (id: number, quantity: number) => {
|
||||
const safeQty = Number.isFinite(quantity)
|
||||
? Math.max(0, Math.floor(quantity))
|
||||
: 0;
|
||||
if (safeQty === 0) {
|
||||
removeFromCart(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: safeQty } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
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,
|
||||
}),
|
||||
[items, totalItems, totalPrice],
|
||||
[cart?.items, cart?.totalAmount, cart?.eateryId],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
|
||||
+21
-515
@@ -1,15 +1,9 @@
|
||||
import type {
|
||||
Combo,
|
||||
Department,
|
||||
MenuCategory,
|
||||
Product,
|
||||
ProductSalesStats,
|
||||
RevenueDataPoint,
|
||||
ShiftSlot,
|
||||
Shop,
|
||||
ShiftEntity,
|
||||
ShopInfo,
|
||||
SocialLinks,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
// ===== SHOP INFORMATION =====
|
||||
@@ -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: "All", icon: "fa-solid fa-border-all" },
|
||||
{ id: "cafe", name: "Coffee", icon: "fa-solid fa-mug-hot" },
|
||||
{ id: "tra", name: "Tea", icon: "fa-solid fa-leaf" },
|
||||
{ id: "sua-chua", name: "Yogurt", icon: "fa-solid fa-jar" },
|
||||
{ id: "nuoc-ep", name: "Juice", icon: "fa-solid fa-blender" },
|
||||
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
|
||||
{
|
||||
id: "giai-khat",
|
||||
name: "Refreshments / Snacks",
|
||||
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: "Black Coffee",
|
||||
category: "cafe",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Traditional Vietnamese black coffee, rich and bold, brewed by hand with a phin filter.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Milk Coffee",
|
||||
category: "cafe",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Aromatic condensed milk coffee, rich and creamy — a perfect blend of strong coffee and sweet condensed milk.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "White Coffee",
|
||||
category: "cafe",
|
||||
price: 32000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Light and mild, with less coffee and more milk — perfect for those just starting to enjoy coffee.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Egg Coffee",
|
||||
category: "cafe",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"A Hanoi specialty — smooth, velvety egg cream layered over a bold coffee base.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Peach Orange Lemongrass Tea",
|
||||
category: "tra",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Fragrant peach tea with fresh orange and lemongrass — refreshing and wonderfully cooling.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Matcha Green Tea",
|
||||
category: "tra",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Pure Japanese matcha with a signature light bitterness — aromatic, refreshing, and nutritious.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Lychee Jasmine Tea",
|
||||
category: "tra",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Sweet lychee tea delicately infused with jasmine blossom — a calming and soulful sip.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Yogurt with Tapioca Pearls",
|
||||
category: "sua-chua",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Smooth, creamy yogurt paired with chewy black tapioca pearls — a perfectly balanced sweet and tangy treat.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Strawberry Yogurt",
|
||||
category: "sua-chua",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Chilled yogurt with fresh sweet-tart strawberries, packed with vitamins and minerals.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "Fresh Orange Juice",
|
||||
category: "nuoc-ep",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"100% freshly squeezed orange juice, rich in vitamin C and great for your health.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: "Watermelon Juice",
|
||||
category: "nuoc-ep",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Ice-cold watermelon juice — instantly refreshing on hot summer days.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "Caramel Latte",
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Sweet and indulgent caramel latte with a velvety milk foam topping and a drizzle of caramel sauce.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: "Vanilla Latte",
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: "Toasted Butter Bread",
|
||||
category: "giai-khat",
|
||||
price: 20000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Crispy toasted bread spread with fragrant butter and strawberry jam — the perfect coffee companion.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: "Caramel Flan",
|
||||
category: "giai-khat",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Silky smooth flan with a golden caramel topping that melts in your mouth.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
name: "Black Tapioca Pearls",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Chewy black tapioca pearls — add them to any drink for extra flavor and texture.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
name: "Coffee Jelly",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Cool coffee jelly that adds a distinctive flavor boost to your drink.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
name: "White Tapioca Pearls",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK COMBOS =====
|
||||
export const MOCK_COMBOS: Combo[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Double Coffee Combo",
|
||||
description: "2 black coffees + 2 toasted butter breads, save 15%.",
|
||||
price: 75000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
{ productId: 1, quantity: 2 },
|
||||
{ productId: 14, quantity: 2 },
|
||||
],
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Group Tea Combo",
|
||||
description: "2 peach orange lemongrass teas + 2 matcha green teas, perfect for a group.",
|
||||
price: 130000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
{ productId: 5, quantity: 2 },
|
||||
{ productId: 6, quantity: 2 },
|
||||
],
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Morning Combo",
|
||||
description: "1 milk coffee + 1 caramel flan — start your day on a sweet note.",
|
||||
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)
|
||||
@@ -393,224 +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: "Caramel Latte",
|
||||
category: "latte",
|
||||
unitsSold: 487,
|
||||
revenue: 21915000,
|
||||
costPrice: 18000,
|
||||
sellingPrice: 45000,
|
||||
profit: 13185000,
|
||||
profitMargin: 60.2,
|
||||
},
|
||||
{
|
||||
productId: 6,
|
||||
name: "Matcha Green Tea",
|
||||
category: "tra",
|
||||
unitsSold: 412,
|
||||
revenue: 16480000,
|
||||
costPrice: 15000,
|
||||
sellingPrice: 40000,
|
||||
profit: 10300000,
|
||||
profitMargin: 62.5,
|
||||
},
|
||||
{
|
||||
productId: 5,
|
||||
name: "Peach Orange Lemongrass Tea",
|
||||
category: "tra",
|
||||
unitsSold: 398,
|
||||
revenue: 13930000,
|
||||
costPrice: 12000,
|
||||
sellingPrice: 35000,
|
||||
profit: 9153000,
|
||||
profitMargin: 65.7,
|
||||
},
|
||||
{
|
||||
productId: 13,
|
||||
name: "Vanilla Latte",
|
||||
category: "latte",
|
||||
unitsSold: 356,
|
||||
revenue: 16020000,
|
||||
costPrice: 18000,
|
||||
sellingPrice: 45000,
|
||||
profit: 9612000,
|
||||
profitMargin: 60.0,
|
||||
},
|
||||
{
|
||||
productId: 4,
|
||||
name: "Egg Coffee",
|
||||
category: "cafe",
|
||||
unitsSold: 340,
|
||||
revenue: 15300000,
|
||||
costPrice: 16000,
|
||||
sellingPrice: 45000,
|
||||
profit: 9860000,
|
||||
profitMargin: 64.4,
|
||||
},
|
||||
{
|
||||
productId: 2,
|
||||
name: "Milk Coffee",
|
||||
category: "cafe",
|
||||
unitsSold: 325,
|
||||
revenue: 9750000,
|
||||
costPrice: 10000,
|
||||
sellingPrice: 30000,
|
||||
profit: 6500000,
|
||||
profitMargin: 66.7,
|
||||
},
|
||||
{
|
||||
productId: 3,
|
||||
name: "White Coffee",
|
||||
category: "cafe",
|
||||
unitsSold: 298,
|
||||
revenue: 9536000,
|
||||
costPrice: 11000,
|
||||
sellingPrice: 32000,
|
||||
profit: 6259000,
|
||||
profitMargin: 65.6,
|
||||
},
|
||||
{
|
||||
productId: 1,
|
||||
name: "Black Coffee",
|
||||
category: "cafe",
|
||||
unitsSold: 285,
|
||||
revenue: 7125000,
|
||||
costPrice: 8000,
|
||||
sellingPrice: 25000,
|
||||
profit: 4845000,
|
||||
profitMargin: 68.0,
|
||||
},
|
||||
{
|
||||
productId: 9,
|
||||
name: "Strawberry Yogurt",
|
||||
category: "sua-chua",
|
||||
unitsSold: 267,
|
||||
revenue: 10680000,
|
||||
costPrice: 15000,
|
||||
sellingPrice: 40000,
|
||||
profit: 6675000,
|
||||
profitMargin: 62.5,
|
||||
},
|
||||
{
|
||||
productId: 10,
|
||||
name: "Fresh Orange Juice",
|
||||
category: "nuoc-ep",
|
||||
unitsSold: 241,
|
||||
revenue: 8435000,
|
||||
costPrice: 12000,
|
||||
sellingPrice: 35000,
|
||||
profit: 5543000,
|
||||
profitMargin: 65.7,
|
||||
},
|
||||
{
|
||||
productId: 8,
|
||||
name: "Yogurt with Tapioca Pearls",
|
||||
category: "sua-chua",
|
||||
unitsSold: 228,
|
||||
revenue: 8664000,
|
||||
costPrice: 14000,
|
||||
sellingPrice: 38000,
|
||||
profit: 5472000,
|
||||
profitMargin: 63.2,
|
||||
},
|
||||
{
|
||||
productId: 7,
|
||||
name: "Lychee Jasmine Tea",
|
||||
category: "tra",
|
||||
unitsSold: 215,
|
||||
revenue: 8170000,
|
||||
costPrice: 13000,
|
||||
sellingPrice: 38000,
|
||||
profit: 5375000,
|
||||
profitMargin: 65.8,
|
||||
},
|
||||
{
|
||||
productId: 15,
|
||||
name: "Caramel Flan",
|
||||
category: "giai-khat",
|
||||
unitsSold: 198,
|
||||
revenue: 4950000,
|
||||
costPrice: 8000,
|
||||
sellingPrice: 25000,
|
||||
profit: 3366000,
|
||||
profitMargin: 68.0,
|
||||
},
|
||||
{
|
||||
productId: 11,
|
||||
name: "Watermelon Juice",
|
||||
category: "nuoc-ep",
|
||||
unitsSold: 182,
|
||||
revenue: 5460000,
|
||||
costPrice: 10000,
|
||||
sellingPrice: 30000,
|
||||
profit: 3640000,
|
||||
profitMargin: 66.7,
|
||||
},
|
||||
{
|
||||
productId: 14,
|
||||
name: "Toasted Butter Bread",
|
||||
category: "giai-khat",
|
||||
unitsSold: 175,
|
||||
revenue: 3500000,
|
||||
costPrice: 6000,
|
||||
sellingPrice: 20000,
|
||||
profit: 2450000,
|
||||
profitMargin: 70.0,
|
||||
},
|
||||
{
|
||||
productId: 16,
|
||||
name: "Black Tapioca Pearls",
|
||||
category: "topping",
|
||||
unitsSold: 456,
|
||||
revenue: 4560000,
|
||||
costPrice: 2000,
|
||||
sellingPrice: 10000,
|
||||
profit: 3648000,
|
||||
profitMargin: 80.0,
|
||||
},
|
||||
{
|
||||
productId: 17,
|
||||
name: "Coffee Jelly",
|
||||
category: "topping",
|
||||
unitsSold: 389,
|
||||
revenue: 3890000,
|
||||
costPrice: 2000,
|
||||
sellingPrice: 10000,
|
||||
profit: 3112000,
|
||||
profitMargin: 80.0,
|
||||
},
|
||||
{
|
||||
productId: 18,
|
||||
name: "White Tapioca Pearls",
|
||||
category: "topping",
|
||||
unitsSold: 342,
|
||||
revenue: 3420000,
|
||||
costPrice: 2000,
|
||||
sellingPrice: 10000,
|
||||
profit: 2736000,
|
||||
profitMargin: 80.0,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 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 },
|
||||
@@ -644,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) {
|
||||
@@ -653,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -692,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" },
|
||||
];
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Eatery } from "./types";
|
||||
|
||||
export async function gqlFetch<T = Record<string, unknown>>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/eatery/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function addManagerMenuItem(
|
||||
name: string,
|
||||
price: number,
|
||||
): Promise<{ id: string; name: string; price: number } | null> {
|
||||
const data = await gqlFetch<{
|
||||
addMenuItem: { id: string; name: string; price: number } | null;
|
||||
}>(
|
||||
`mutation($name: String!, $price: Float!) {
|
||||
addMenuItem(name: $name, price: $price) { id name price }
|
||||
}`,
|
||||
{ name, price },
|
||||
);
|
||||
return data.addMenuItem;
|
||||
}
|
||||
|
||||
export async function fetchManagerEatery(): Promise<Eatery | null> {
|
||||
const data = await gqlFetch<{ eateriesByOwner: Eatery | null }>(`
|
||||
query {
|
||||
eateriesByOwner {
|
||||
id
|
||||
ownerId
|
||||
name
|
||||
isVerified
|
||||
}
|
||||
}
|
||||
`);
|
||||
return data.eateriesByOwner;
|
||||
}
|
||||
+158
-101
@@ -1,149 +1,206 @@
|
||||
"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 {
|
||||
type MenuItemEntity,
|
||||
type addMenuItemMutation,
|
||||
type allEateriesQuery,
|
||||
deleteMenuItemMutation,
|
||||
updateMenuItemMutation,
|
||||
} from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
|
||||
export type ManagerTab = "products" | "reviews";
|
||||
|
||||
interface ManagerContextType {
|
||||
// Data
|
||||
products: Product[];
|
||||
combos: Combo[];
|
||||
categories: MenuCategory[];
|
||||
products: MenuItemEntity[];
|
||||
eateryId: string | null;
|
||||
|
||||
// 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
|
||||
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 [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);
|
||||
}
|
||||
}, [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,
|
||||
},
|
||||
});
|
||||
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteProduct = (id: number) => {
|
||||
setProducts((prev) => prev.filter((p) => p.id !== id));
|
||||
const deleteProduct = async (id: string) => {
|
||||
const { data } = await mutateDeleteMenuItem({ variables: { input: id } });
|
||||
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.filter((p) => !(p.id == id && data.deleteMenuItem)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleProductAvailability = (id: number) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id ? { ...p, available: !(p.available ?? true) } : p,
|
||||
),
|
||||
);
|
||||
};
|
||||
const toggleProductAvailability = async (product: MenuItemEntity) => {
|
||||
const { data } = await mutateUpdateMenuItem({
|
||||
variables: {
|
||||
menuItem: { ...product, available: !product.available },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Combo actions ────────────────────────────────────────────────────────
|
||||
|
||||
const addCombo = (combo: Omit<Combo, "id">) => {
|
||||
const newCombo: Combo = { ...combo, id: Date.now() };
|
||||
setCombos((prev) => [...prev, newCombo]);
|
||||
};
|
||||
|
||||
const updateCombo = (combo: Combo) => {
|
||||
setCombos((prev) => prev.map((c) => (c.id === combo.id ? combo : c)));
|
||||
};
|
||||
|
||||
const deleteCombo = (id: number) => {
|
||||
setCombos((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const toggleComboAvailability = (id: number) => {
|
||||
setCombos((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, available: !c.available } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
// ── Category actions ─────────────────────────────────────────────────────
|
||||
|
||||
const addCategory = (category: Omit<MenuCategory, "id">) => {
|
||||
const slug = category.name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const newCategory: MenuCategory = {
|
||||
...category,
|
||||
id: `${slug}-${Date.now()}`,
|
||||
};
|
||||
setCategories((prev) => [...prev, newCategory]);
|
||||
};
|
||||
|
||||
const updateCategory = (category: MenuCategory) => {
|
||||
setCategories((prev) =>
|
||||
prev.map((c) => (c.id === category.id ? category : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteCategory = (id: string) => {
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerContext.Provider
|
||||
value={{
|
||||
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);
|
||||
}
|
||||
+185
-73
@@ -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,81 @@ 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 +240,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,39 +281,37 @@ 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 (
|
||||
hasConflict(
|
||||
shift.date,
|
||||
shift.startTime,
|
||||
shift.endTime,
|
||||
staffId,
|
||||
shiftId,
|
||||
)
|
||||
shift.date &&
|
||||
hasConflict(shift.date, shift.startTime, shift.endTime, staffId, shiftId)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -229,56 +319,79 @@ 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,
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
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) => {
|
||||
setShifts((prev) => prev.filter((s) => s.id !== shiftId));
|
||||
}, []);
|
||||
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 +408,6 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
registerShift,
|
||||
unregisterShift,
|
||||
createShift,
|
||||
updateShift,
|
||||
deleteShift,
|
||||
getShiftsForDate,
|
||||
getShiftsForWeek,
|
||||
|
||||
+85
-56
@@ -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;
|
||||
@@ -52,14 +34,6 @@ export interface SocialLinks {
|
||||
website: string;
|
||||
}
|
||||
|
||||
// ===== EATERY (GRAPHQL) TYPES =====
|
||||
export interface Eatery {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
name: string;
|
||||
isVerified: boolean;
|
||||
}
|
||||
|
||||
// ===== SHOP (QUÁN NƯỚC) TYPES =====
|
||||
export interface Shop {
|
||||
id: number;
|
||||
@@ -107,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"
|
||||
@@ -130,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;
|
||||
}
|
||||
|
||||
Generated
+238
-106
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temp",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.9",
|
||||
"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.3",
|
||||
"version": "1.2.9",
|
||||
"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
+474
-419
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user