Compare commits

...

13 Commits

Author SHA1 Message Date
gitea-actions e20bd33541 chore: release [ci skip] 2026-05-15 13:30:33 +00:00
TakahashiNguyen 2376d57cbb fix: final commit (#46)
Release package / release (push) Successful in 8m30s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #46
2026-05-15 13:22:12 +00:00
gitea-actions 7b4e46c4e4 chore: release [ci skip] 2026-05-15 06:38:15 +00:00
TakahashiNguyen 3a5cfd0494 fix: better ui (#45)
Release package / release (push) Successful in 12m29s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #45
2026-05-15 06:26:00 +00:00
TakahashiNguyen 27bb95dea5 fix: final update (#44)
Release package / release (push) Failing after 27m9s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #44
2026-05-15 00:46:09 +00:00
gitea-actions 9f8695a870 chore: release [ci skip] 2026-05-14 15:57:15 +00:00
TakahashiNguyen b37bf5d088 fix: better frontend (#43)
Release package / release (push) Successful in 4m38s
Co-authored-by: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn>
Co-authored-by: Thanh Quy- wolf <524H0124@student.tdtu.edu.vn>
Reviewed-on: #43
2026-05-14 15:52:48 +00:00
gitea-actions 378e381454 chore: release [ci skip] 2026-05-14 03:39:58 +00:00
TaNguyenThanhQuy 8e9e48d9b4 fix: add image to menu item (#42)
Release package / release (push) Successful in 9m19s
Co-authored-by: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn>
Co-authored-by: Thanh Quy- wolf <524H0124@student.tdtu.edu.vn>
Reviewed-on: #42
Co-authored-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
Co-committed-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
2026-05-14 03:31:04 +00:00
gitea-actions 43658ace21 chore: release [ci skip] 2026-05-14 03:23:00 +00:00
TakahashiNguyen 9ed4889310 fix: add shift management (#41)
Release package / release (push) Successful in 10m13s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #41
2026-05-14 03:13:06 +00:00
TakahashiNguyen dfcb1b09c0 fix: resolved minor issue (#40)
Release package / release (push) Failing after 16m38s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #40
2026-05-13 15:24:19 +00:00
TakahashiNguyen 877c7be84b fix: cart connect to backend (#39)
Release package / release (push) Failing after 8m34s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #39
2026-05-13 08:56:58 +00:00
56 changed files with 3205 additions and 2081 deletions
+4 -1
View File
@@ -14,7 +14,10 @@
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/\")", "Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/\")",
"Bash(npm run build)", "Bash(npm run build)",
"Bash(cat \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")", "Bash(cat \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
"Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")" "Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
"Bash(Get-ChildItem -Path \"C:\\\\VS Code\\\\CongNghePhanMem\\\\Final\\\\backend\" -Recurse -Include \"*.graphqls\", \"*.graphql\")",
"Bash(Select-Object FullName)",
"Skill(prompt-optimizer)"
] ]
} }
} }
@@ -0,0 +1,139 @@
---
name: feature-workflow-tracer
description:
Trace and explain the end-to-end workflow of any feature or functionality in a
codebase. Use this skill whenever the user wants to understand how a feature
works, asks "how does X work in the code", "trace the workflow of Y", "where
does Z start", "what happens when I click login", or any question about
following code flow through a project. Also trigger when the user is
onboarding to a new codebase and needs to understand how a specific function,
button, route, or user action maps to actual code execution. Trigger even for
vague questions like "how is auth handled here?" or "explain the checkout
flow".
---
# Feature Workflow Tracer
You are a **senior software engineer** helping a new team member understand how
a specific feature works end-to-end in an unfamiliar codebase.
## Your Task
When the user provides a **feature name** (e.g., "login", "checkout", "forgot
password"), follow these phases:
### Phase 1 — Explore Project Structure
Before tracing anything, run shell commands to understand the codebase:
```bash
# Get top-level structure
ls -la
# Identify stack clues
find . -maxdepth 2 -name "package.json" -o -name "composer.json" -o -name "requirements.txt" | head -10
# Find route files
find . -type f \( -name "*.route.*" -o -name "routes.js" -o -name "web.php" -o -name "api.php" -o -name "router.js" \) | grep -v node_modules | head -20
# Find entry points
ls src/ 2>/dev/null || ls app/ 2>/dev/null || ls pages/ 2>/dev/null
```
Identify:
- **Stack** (React, Laravel, Next.js, Vue, Express, etc.)
- **Architecture pattern** (MVC, feature-based, domain-driven, etc.)
- **Entry point** for the requested feature (route file, page component, or
controller)
### Phase 2 — Trace the Workflow
Starting from the **UI layer**, follow the execution path step by step:
```
UI (button/form/link)
→ Event handler / Route handler
→ Service / Controller
→ Repository / Model / API call
→ Response / Side effect
```
For each step, find the **exact file and line number** using:
```bash
# Search for function/component by name
grep -rn "functionName\|ComponentName" src/ --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" --include="*.php"
# Find where a route is defined
grep -rn "'/login'\|\"login\"\|login.route\|LoginController" . --include="*.php" --include="*.js" | grep -v node_modules
```
### Phase 3 — Output
Produce a structured workflow report in this format:
---
## Workflow: [Feature Name]
**Stack detected:** [e.g., React + Laravel REST API]
**Entry point:** [e.g., `resources/js/pages/Login.jsx:12`]
### Step-by-step Flow
**1. [Action description]**`path/to/file.ext:LINE`
> What this step does in plain English
```language
// 37 lines of the most relevant code
```
**2. [Next step]**`path/to/file.ext:LINE`
> ...
_(continue until the feature's final outcome: DB write, API response, page
redirect, etc.)_
### Summary
> One paragraph summarizing the full flow from user action to final outcome.
---
## Tracing Rules
| Rule | Detail |
| --------------- | ----------------------------------------------------------------------------------------- |
| **Max depth** | 5 layers (UI → handler → service → repo → DB). Stop before third-party library internals. |
| **Always cite** | Every step must have `file:line`. Never say "somewhere in the codebase." |
| **Skip** | Config files, `.env`, migrations, boilerplate. Focus on application logic only. |
| **Branches** | If a step has success/error paths, show both as `1a` and `1b`. |
| **Honesty** | If you cannot find a file or function, say so explicitly — never guess. |
## Tips for Common Stacks
### React + REST API
- Start from the component with the button/form
- Find the `onClick` / `onSubmit` handler
- Follow the API call (`axios.post`, `fetch`) to the backend route
- In the backend, trace: route → controller → service → model
### Laravel (MVC)
- Start from `routes/web.php` or `routes/api.php`
- Follow: route → Controller method → Service (if any) → Model / DB query
### Next.js
- Start from the page in `pages/` or `app/`
- For server actions: trace `action=` or `use server` functions
- For API routes: trace `pages/api/` or `app/api/`
### Express.js
- Start from route definition in `routes/` or `app.js`
- Follow: router → middleware → controller → DB call
+103
View File
@@ -0,0 +1,103 @@
---
name: learning-assistant
description: >
A personal learning companion for students and self-learners. Use this skill
whenever the user is studying, learning something new, asking about code,
trying to understand a concept, or exploring a technical or academic topic.
Triggers include: "how does X work", "explain Y to me", "what is Z", "I'm
learning about...", "can you help me understand...", "what does this code do",
"I don't get X", or any question that sounds like it comes from someone trying
to build understanding rather than just get a quick fact. Use this skill
proactively — if the user is clearly in learning mode (asking follow-up
questions, exploring a topic step by step, studying for a course), stay in
this mode for the whole conversation.
---
# Learning Assistant
Your job is to help the user learn effectively. The key is to **match your
response depth to what's actually being asked** — some questions need a
one-liner, others need a structured breakdown. Read the question carefully and
pick the right mode.
---
## Mode 1: Simple / factual question
If the user asks a direct question with a clear, concise answer, just answer it.
Don't pad it out.
**Examples of simple questions:**
- "What does `===` mean in JavaScript?"
- "What's the difference between TCP and UDP?"
- "What does the `final` keyword do in Java?"
**How to respond:** One to three sentences. Be accurate and direct. If there's a
one-line example that makes it instantly clear, include it. Don't add sections
or menus.
---
## Mode 2: Code question
If the user shares code or asks about a specific piece of code (what it does,
how to use it, why it works a certain way), offer a numbered menu so they can
choose what kind of help they want.
**Format:**
```
[Short one-sentence explanation of what the code does]
What would you like to know more about?
1. How it works step by step
2. Example usage
3. Common variations / alternatives
4. Common mistakes or edge cases
5. Something else — just ask
```
Keep the initial explanation brief. The menu is there so the user drives the
depth, not you.
---
## Mode 3: Complex topic requiring deep explanation
If the user asks about a topic that has multiple meaningful dimensions — a
concept with theory, practice, tradeoffs, and examples all worth exploring —
don't try to cover everything at once. Surface the structure first and let them
pick.
**Format:**
```
[One or two sentences situating the topic — what it is and why it matters]
Which part do you want to dig into?
1. Core idea / how it works
2. Example / walkthrough
3. When to use it (and when not to)
4. Common misconceptions
5. [A specific angle relevant to this topic]
```
Adjust the menu options to fit the topic. Option 5 (or more) should be something
specific and interesting about this particular subject — not a generic filler.
---
## General principles
- **Be concise by default.** If you can say it clearly in two sentences, don't
write six.
- **Don't front-load.** Get to the point before adding caveats or context.
- **When you give examples, make them concrete.** Toy examples are fine; vague
pseudocode is not.
- **Let the user lead the depth.** Your job is to open doors, not walk through
all of them at once.
- **If a follow-up question changes the depth needed, switch modes.** The modes
are a guide, not a rigid script.
- **Use plain language.** Avoid unnecessary jargon. If a technical term is
necessary, define it briefly the first time.
+67 -8
View File
@@ -1,11 +1,11 @@
--- ---
name: prompt-optimizer name: prompt-optimizer
description: description:
Help users rewrite and improve AI/LLM prompts by adding specificity, context, "Help users rewrite and improve AI/LLM prompts by adding specificity, context,
and constraints. Trigger this skill whenever users ask to improve, rewrite, and constraints. Trigger this skill whenever users ask to improve, rewrite,
optimize, or refine prompts for AI models. Focus on making prompts clearer, optimize, or refine prompts for AI models. Focus on making prompts clearer,
more specific, and more likely to produce better AI results. Present more specific, and more likely to produce better AI results. Present
suggestions interactively so users can choose which improvements to apply. suggestions interactively so users can choose which improvements to apply."
--- ---
# Prompt Optimizer # Prompt Optimizer
@@ -73,9 +73,7 @@ When you use this skill, you'll see:
- **Preview examples** — What each change would look like with the improvement - **Preview examples** — What each change would look like with the improvement
applied applied
- **Your choices** — You pick which suggestions help most (you can apply all, - **Your choices** — You pick which suggestions help most (you can apply all,
some, or none) some, or none) Then you get a rewritten prompt combining all your choices.
Then you get a rewritten prompt combining all your choices.
### Example Interaction ### Example Interaction
@@ -85,9 +83,8 @@ Then you get a rewritten prompt combining all your choices.
- **Specificity**: Add a topic (e.g., "about sustainable living") - **Specificity**: Add a topic (e.g., "about sustainable living")
- **Context**: Explain your goal (e.g., "to build authority on my website") - **Context**: Explain your goal (e.g., "to build authority on my website")
- **Constraints**: Set a word count (e.g., "800-1000 words") - **Constraints**: Set a word count (e.g., "800-1000 words") **Your choice:** "I
want all three — add topic, goal, and word count"
**Your choice:** "I want all three — add topic, goal, and word count"
**Final rewritten prompt:** "Write an 800-1000 word blog post about sustainable **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 living for my website. The goal is to establish my authority on eco-friendly
@@ -110,3 +107,65 @@ A prompt becomes "good" when:
- You've given enough context to explain why ✓ - You've given enough context to explain why ✓
- You've set boundaries to prevent bad outputs ✓ - You've set boundaries to prevent bad outputs ✓
- Someone else could read it and understand your intent ✓ - Someone else could read it and understand your intent ✓
---
## Anti-Pattern Detection
Before suggesting improvements, scan the original prompt for these common
mistakes and report them with a warning:
| Anti-Pattern | Description | Example |
| ---------------------- | ----------------------------------------------------- | ------------------------------------------- |
| Too generic | No clear subject, action, or goal | "Tell me about AI" |
| Ambiguous pronouns | "it", "that", "this" with no clear referent | "Fix it so it works better" |
| Internal contradiction | Two requirements that cancel each other out | "Be concise but cover everything in detail" |
| Missing context | Requests an action without explaining why or for whom | "Rewrite this paragraph" |
**Output format:** Before showing improvement suggestions, print a "Detected
Issues" block listing any anti-patterns found. If none are found, skip this
block silently. Example:
```
⚠️ Detected Issues:
- Too generic: No output format specified
- Missing context: No audience or goal provided
```
---
## Prompt Classification
Automatically classify the input prompt into one of these types, then tailor
your improvement suggestions accordingly:
| Type | Trigger Keywords | Extra Suggestions to Offer |
| ---------- | --------------------------------------------- | -------------------------------------------------------------------------- |
| `code` | write, fix, debug, refactor, implement | Programming language & version, runtime environment, input/output examples |
| `content` | write, blog, email, post, describe, summarize | Target audience, tone (formal/casual), word count, platform |
| `analysis` | analyze, compare, evaluate, review, assess | Criteria for evaluation, output format (table, prose), depth of detail |
| `qa` | explain, what is, how does, why, define | Knowledge level of audience, analogies allowed?, length of answer |
| `task` | do, create, set up, build, generate, automate | Step-by-step vs one-shot, tools/permissions available, success criteria |
Show the detected type at the top of your response:
`📌 Prompt type detected: [type]`
---
## Chain-of-Thought Mode
Offer "Chain-of-Thought Enhancement" as an optional improvement when showing
suggestions. When the user selects it, add reasoning instructions to the end of
the optimized prompt based on type:
| Prompt Type | Chain-of-Thought Addition |
| --------------- | -------------------------------------------------------------------------- |
| General | `"Think step by step before answering."` |
| Analysis | `"Explain your reasoning for each point before giving a conclusion."` |
| Code | `"Outline your approach and data structures before writing any code."` |
| Decision-making | `"List pros and cons, then state your recommendation with justification."` |
**Why it works:** Chain-of-Thought forces the AI to externalize its reasoning
process. This reduces hallucination (the AI catches its own errors
mid-reasoning), makes outputs easier to verify, and produces more structured
answers. Studies show CoT improves accuracy on complex tasks by 2040%.
+1 -4
View File
@@ -13,10 +13,7 @@
}, },
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["esbenp.prettier-vscode", "anthropic.claude-code"]
"esbenp.prettier-vscode",
"anthropic.claude-code"
]
} }
}, },
+1 -3
View File
@@ -95,7 +95,6 @@ jobs:
password: ${{ secrets.ACCESS_TOKEN }} password: ${{ secrets.ACCESS_TOKEN }}
- name: Prepare Docker Metadata - name: Prepare Docker Metadata
if: gitea.ref == 'refs/heads/main'
id: meta id: meta
run: | run: |
# Lowercase Repository # Lowercase Repository
@@ -108,11 +107,10 @@ jobs:
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
- name: Build and Push Docker Image - name: Build and Push Docker Image
if: gitea.ref == 'refs/heads/main'
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: true push: ${{ github.ref_name == 'main' }}
tags: | tags: |
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }} vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
+11 -102
View File
@@ -1,103 +1,12 @@
# Coffee Shop Frontend - TODO # TODO - Add image upload field for manager add/update menu item
## Completed Features & Implementations - [x] Update GraphQL queries/mutations in `lib/manager-context.tsx` to include
`imageUrl`
### A. Dead Code Removed - [x] Update `components/organisms/manager/ProductModal.tsx`
- [x] Add file input for image selection
- [x] lib/constants.ts - Removed unused NAV_LINKS export - [x] Add upload button to call `POST /api/file`
- [x] lib/types.ts - Removed unused NavLink interface - [x] Store returned URL string into `form.imageUrl`
- [x] components/Navbar.tsx - Removed trivial handleClick wrapper; inlined - [x] Show upload/loading/error states and image preview
onCategoryChange call - [x] Update `components/organisms/manager/ProductsTab.tsx` to show image
- [x] components/Navbar.tsx - Removed unused Link import thumbnail in table
- [x] Mark TODO progress after each step completed
### 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)
-86
View File
@@ -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>
);
}
-9
View File
@@ -1,9 +0,0 @@
import { FeedLayout } from "@/components/templates/feed-layout";
export default function RootFeedLayout({
children,
}: {
children: React.ReactNode;
}) {
return <FeedLayout>{children}</FeedLayout>;
}
+1
View File
@@ -54,6 +54,7 @@ export default function LoginOtpPage() {
if (res.ok) { if (res.ok) {
const userData = await res.json(); const userData = await res.json();
if (userData.role) userData.role = userData.role.toLowerCase();
setUser(userData); setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData)); localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
sessionStorage.removeItem("login_phone"); sessionStorage.removeItem("login_phone");
+6 -2
View File
@@ -13,6 +13,7 @@ export default function LoginPasswordPage() {
const { setUser } = useAuth(); const { setUser } = useAuth();
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [role, setRole] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -21,11 +22,12 @@ export default function LoginPasswordPage() {
useEffect(() => { useEffect(() => {
const storedPhone = sessionStorage.getItem("login_phone"); const storedPhone = sessionStorage.getItem("login_phone");
const storedRole = sessionStorage.getItem("login_role"); const storedRole = sessionStorage.getItem("login_role");
if (!storedPhone || storedRole !== "manager") { if (!storedPhone || (storedRole !== "manager" && storedRole !== "staff")) {
router.replace("/login"); router.replace("/login");
return; return;
} }
setPhone(storedPhone); setPhone(storedPhone);
setRole(storedRole);
}, [router]); }, [router]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
@@ -48,11 +50,13 @@ export default function LoginPasswordPage() {
if (res.ok) { if (res.ok) {
const userData = await res.json(); const userData = await res.json();
if (userData.role) userData.role = userData.role.toLowerCase();
else userData.role = role;
setUser(userData); setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData)); localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
sessionStorage.removeItem("login_phone"); sessionStorage.removeItem("login_phone");
sessionStorage.removeItem("login_role"); sessionStorage.removeItem("login_role");
router.push("/manager"); router.push(role === "staff" ? "/staff/schedule" : "/manager");
} else { } else {
const STATUS_ERROR_MAP: Record<number, string> = { const STATUS_ERROR_MAP: Record<number, string> = {
400: "Incorrect login details, please try again", 400: "Incorrect login details, please try again",
+38 -20
View File
@@ -19,12 +19,14 @@ export default function ManagerSignupPage() {
phone: "", phone: "",
password: "", password: "",
eateryName: "", eateryName: "",
bankAccount: "", // 1. Thêm bankAccount vào state
}); });
const [errors, setErrors] = useState({ const [errors, setErrors] = useState({
name: "", name: "",
phone: "", phone: "",
password: "", password: "",
eateryName: "", eateryName: "",
bankAccount: "", // 2. Thêm bankAccount vào errors state
submit: "", submit: "",
}); });
@@ -32,9 +34,6 @@ export default function ManagerSignupPage() {
fetch("/api/manager/signup") fetch("/api/manager/signup")
.then((res) => { .then((res) => {
setPageState("available"); setPageState("available");
// if (res.ok) ;
// else if (res.status === 403) setPageState("closed");
// else setPageState("error");
}) })
.catch(() => setPageState("error")); .catch(() => setPageState("error"));
}, []); }, []);
@@ -54,6 +53,7 @@ export default function ManagerSignupPage() {
phone: "", phone: "",
password: "", password: "",
eateryName: "", eateryName: "",
bankAccount: "",
submit: "", submit: "",
}; };
if (!form.name.trim()) next.name = "Please enter your full name"; if (!form.name.trim()) next.name = "Please enter your full name";
@@ -65,8 +65,18 @@ export default function ManagerSignupPage() {
next.password = "Password must be at least 6 characters"; next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim()) if (!form.eateryName.trim())
next.eateryName = "Please enter the restaurant name"; next.eateryName = "Please enter the restaurant name";
if (!form.bankAccount.trim())
next.bankAccount = "Please enter your bank account number";
setErrors(next); setErrors(next);
return !next.name && !next.phone && !next.password && !next.eateryName; return (
!next.name &&
!next.phone &&
!next.password &&
!next.eateryName &&
!next.bankAccount
);
}; };
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
@@ -151,7 +161,7 @@ export default function ManagerSignupPage() {
href="/login" 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" 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> </Link>
</div> </div>
)} )}
@@ -164,10 +174,10 @@ export default function ManagerSignupPage() {
</div> </div>
<div className="text-center"> <div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)"> <h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
Không thể kết nối Unable to connect
</h2> </h2>
<p className="text-sm text-(--color-text-muted)"> <p className="text-sm text-(--color-text-muted)">
Không thể kiểm tra trạng thái đăng . Vui lòng thử lại. Unable to check registration status. Please try again.
</p> </p>
</div> </div>
<Button <Button
@@ -185,13 +195,13 @@ export default function ManagerSignupPage() {
.catch(() => setPageState("error")); .catch(() => setPageState("error"));
}} }}
> >
Thử lại Retry
</Button> </Button>
<Link <Link
href="/login" href="/login"
className="text-sm text-(--color-primary) underline" className="text-sm text-(--color-primary) underline"
> >
Quay lại đăng nhập Back to login
</Link> </Link>
</div> </div>
)} )}
@@ -202,15 +212,15 @@ export default function ManagerSignupPage() {
{[ {[
{ {
id: "name", id: "name",
label: "Họ tên", label: "Full name",
icon: "fa-user", icon: "fa-user",
placeholder: "Nguyễn Văn A", placeholder: "John Doe",
field: "name" as const, field: "name" as const,
type: "text", type: "text",
}, },
{ {
id: "phone", id: "phone",
label: "Số điện thoại", label: "Phone number",
icon: "fa-phone", icon: "fa-phone",
placeholder: "0987654321", placeholder: "0987654321",
field: "phone" as const, field: "phone" as const,
@@ -218,20 +228,28 @@ export default function ManagerSignupPage() {
}, },
{ {
id: "password", id: "password",
label: "Mật khẩu", label: "Password",
icon: "fa-lock", icon: "fa-lock",
placeholder: "Ít nhất 6 ký tự", placeholder: "At least 6 characters",
field: "password" as const, field: "password" as const,
type: "password", type: "password",
}, },
{ {
id: "eateryName", id: "eateryName",
label: "Tên nhà hàng", label: "Restaurant name",
icon: "fa-store", icon: "fa-store",
placeholder: "Coffee & More", placeholder: "Coffee & More",
field: "eateryName" as const, field: "eateryName" as const,
type: "text", type: "text",
}, },
{
id: "bankAccount",
label: "Bank account number (example: acb-44359797)",
icon: "fa-credit-card",
placeholder: "Enter account number (for QR payment)",
field: "bankAccount" as const,
type: "text",
},
].map(({ id, label, icon, placeholder, field, type }) => ( ].map(({ id, label, icon, placeholder, field, type }) => (
<div key={id}> <div key={id}>
<label <label
@@ -251,7 +269,7 @@ export default function ManagerSignupPage() {
onChange={handleChange(field)} onChange={handleChange(field)}
placeholder={placeholder} placeholder={placeholder}
disabled={isLoading} 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 py-3 pl-10 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
/> />
</div> </div>
{errors[field] && ( {errors[field] && (
@@ -280,18 +298,18 @@ export default function ManagerSignupPage() {
> >
{isLoading ? ( {isLoading ? (
<> <>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử <i className="fa-solid fa-spinner fa-spin mr-2"></i>
... Processing...
</> </>
) : ( ) : (
"Đăng ký" "Register"
)} )}
</Button> </Button>
<Link <Link
href="/login" 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" 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"
> >
Đã tài khoản? Đăng nhập Already have an account? Sign in
</Link> </Link>
</div> </div>
</form> </form>
+39 -12
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { SearchBar } from "@/components/molecules/search-bar"; import { SearchBar } from "@/components/molecules/search-bar";
import { CategorySidebar } from "@/components/organisms/navigation";
import { ProductGrid } from "@/components/organisms/product-grid"; import { ProductGrid } from "@/components/organisms/product-grid";
import { eateryClient } from "@/lib/apollo-clients"; import { eateryClient } from "@/lib/apollo-clients";
import { useAuth } from "@/lib/auth-context";
import { allEateriesQuery } from "@/lib/types"; import { allEateriesQuery } from "@/lib/types";
import { gql } from "@apollo/client"; import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
@@ -18,18 +18,41 @@ const GET_EATERY_COUNT = gql`
} }
`; `;
/** function StaffHomePage() {
* Main page — sidebar + product grid layout. const [isSidebarOpen, setIsSidebarOpen] = useState(false);
* const [searchQuery, setSearchQuery] = useState("");
* Layout:
* [Sidebar (sticky, collapsible)] | [Main content (scrollable)] useEffect(() => {
* const mq = window.matchMedia("(min-width: 1024px)");
* Sidebar state: // eslint-disable-next-line react-hooks/set-state-in-effect
* - Desktop (≥ 1024px): expanded by default setIsSidebarOpen(mq.matches);
* - Mobile (< 1024px): collapsed by default const handler = (e: MediaQueryListEvent) => setIsSidebarOpen(e.matches);
*/ mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return (
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] flex-col items-start">
{/* ── Body — same as Customer ── */}
<main className="w-full min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<SearchBar
value={searchQuery}
onChange={(q) => setSearchQuery(q)}
onClear={() => setSearchQuery("")}
placeholder="Search items..."
className="sm:max-w-xs"
/>
</div>
<ProductGrid searchQuery={searchQuery} isSidebarOpen={isSidebarOpen} />
</main>
</div>
);
}
export default function Home() { export default function Home() {
const router = useRouter(); const router = useRouter();
const { user, isInitialized } = useAuth();
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -39,6 +62,7 @@ export default function Home() {
{ {
client: eateryClient, client: eateryClient,
fetchPolicy: "no-cache", fetchPolicy: "no-cache",
skip: user?.role === "staff",
}, },
); );
@@ -62,6 +86,10 @@ export default function Home() {
return () => mq.removeEventListener("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 (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>; if (error) return <div>Error: {error.message}</div>;
@@ -71,7 +99,6 @@ export default function Home() {
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8"> <main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
{/* ── Section heading + search bar ── */} {/* ── Section heading + search bar ── */}
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center"> <div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
{/* Search bar */}
<SearchBar <SearchBar
value={searchQuery} value={searchQuery}
onChange={(q) => { onChange={(q) => {
+17 -27
View File
@@ -2,33 +2,33 @@
import Button from "@/components/atoms/buttons/Button"; import Button from "@/components/atoms/buttons/Button";
import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard"; import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard";
import ReviewModal from "@/components/organisms/modals/ReviewModal";
import { useAuth } from "@/lib/auth-context";
import { useCart } from "@/lib/cart-context"; import { useCart } from "@/lib/cart-context";
import { useManager } from "@/lib/manager-context"; import { useManager } from "@/lib/manager-context";
import { MenuItemEntity } from "@/lib/types"; import { MenuItemEntity } from "@/lib/types";
import { useState } from "react";
const formatPrice = (value: number) => export const formatPrice = (value?: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" }); (value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
export default function PaymentPage() { export default function PaymentPage() {
const { const {
items, items,
totalPrice, totalPrice,
eateryId,
increaseQty, increaseQty,
decreaseQty, decreaseQty,
removeFromCart, removeFromCart,
setQuantity, setQuantity,
} = useCart(); } = useCart();
const { user } = useAuth();
const { products } = useManager(); const { products } = useManager();
const findProduct = (id: string): MenuItemEntity => const findProduct = (id: string): MenuItemEntity =>
products.find((i) => i.id == id)!; products.find((i) => i.id == id) ??
({
const [isReviewOpen, setIsReviewOpen] = useState(false); name: "Unknown product",
const isCustomer = user?.role === "customer"; description: "",
price: 0,
} as MenuItemEntity);
return ( return (
<div> <div>
@@ -38,13 +38,13 @@ export default function PaymentPage() {
<div className="bg-card overflow-hidden rounded-2xl border border-(--color-border-light)"> <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"> <div className="border-b border-(--color-border-light) px-4 py-3">
<h1 className="text-foreground text-lg font-bold md:text-xl"> <h1 className="text-foreground text-lg font-bold md:text-xl">
Trang thanh toán Payment
</h1> </h1>
</div> </div>
{items.length === 0 ? ( {items?.length === 0 ? (
<div className="px-4 py-10 text-center text-(--color-text-muted)"> <div className="px-4 py-10 text-center text-(--color-text-muted)">
Chưa sản phẩm nào trong giỏ hàng. Your cart is empty.
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -72,18 +72,17 @@ export default function PaymentPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map( {items?.map(
({ ({
productId: id, productId: id,
priceAtTimeOfAdding: price, priceAtTimeOfAdding: price,
quantity, quantity,
}) => { }) => {
const { name, description } = findProduct(id); const { name, description } = findProduct(id);
return ( return (
<tr <tr
key={id} key={id}
className="border-t border-(--color-border-light)" className={`border-t border-(--color-border-light) ${quantity == 0 ? "hidden" : ""}`}
> >
<td className="text-foreground px-4 py-3 font-medium"> <td className="text-foreground px-4 py-3 font-medium">
{name} {name}
@@ -99,24 +98,20 @@ export default function PaymentPage() {
<button <button
onClick={() => decreaseQty(id)} onClick={() => decreaseQty(id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)" 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 ${name}`}
> >
- -
</button> </button>
<input <input
type="number" type="number"
min={1}
value={quantity} value={quantity}
onChange={(e) => onChange={(e) =>
setQuantity(id, Number(e.target.value)) setQuantity(id, Number(e.target.value))
} }
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center" className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
title="Nhập số lượng"
/> />
<button <button
onClick={() => increaseQty(id)} onClick={() => increaseQty(id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)" 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 ${name}`}
> >
+ +
</button> </button>
@@ -128,7 +123,6 @@ export default function PaymentPage() {
variant="danger" variant="danger"
size="md" size="md"
style="payment" style="payment"
aria-label={`Xóa ${name} khỏi giỏ hàng`}
> >
Delete product Delete product
</Button> </Button>
@@ -146,16 +140,12 @@ export default function PaymentPage() {
<PaymentSummaryCard <PaymentSummaryCard
totalPrice={totalPrice} totalPrice={totalPrice}
isCustomer={isCustomer} isCustomer={true}
backHref="/" backHref="/"
eateryId={eateryId}
/> />
</div> </div>
</div> </div>
<ReviewModal
isOpen={isReviewOpen}
onClose={() => setIsReviewOpen(false)}
/>
</div> </div>
); );
} }
+181
View File
@@ -0,0 +1,181 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
export default function CreateStaffPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({ name: "", phone: "", password: "" });
const [errors, setErrors] = useState({
name: "",
phone: "",
password: "",
submit: "",
});
const validatePhone = (phone: string) =>
/^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const handleChange =
(field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const validate = () => {
const next = { name: "", phone: "", password: "", submit: "" };
if (!form.name.trim()) next.name = "Please enter full name";
if (!form.phone.trim()) next.phone = "Please enter phone number";
else if (!validatePhone(form.phone))
next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter password";
else if (form.password.length < 6)
next.password = "Password must be at least 6 characters";
setErrors(next);
return !next.name && !next.phone && !next.password;
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
try {
const res = await fetch("/api/Staff/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok || res.status === 201) {
router.push("/manager");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const errorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
InvalidManager: "Manager account not found",
};
setErrors({
...errors,
submit: errorMap[errorCode] ?? "An error occurred, please try again",
});
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className="fa-solid fa-user-plus text-2xl text-(--color-primary)"></i>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
Create Staff Account
</h1>
<p className="text-sm text-(--color-text-muted)">
Add a new staff member to the restaurant
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{[
{
id: "name",
label: "Full name",
icon: "fa-user",
placeholder: "John Doe",
field: "name" as const,
type: "text",
},
{
id: "phone",
label: "Phone number",
icon: "fa-phone",
placeholder: "0987654321",
field: "phone" as const,
type: "tel",
},
{
id: "password",
label: "Password",
icon: "fa-lock",
placeholder: "At least 6 characters",
field: "password" as const,
type: "password",
},
].map(({ id, label, icon, placeholder, field, type }) => (
<div key={id}>
<label
htmlFor={id}
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
{label}
</label>
<div className="relative">
<i
className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}
></i>
<input
id={id}
type={type}
value={form[field]}
onChange={handleChange(field)}
placeholder={placeholder}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 pl-10 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors[field] && (
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{errors[field]}
</p>
)}
</div>
))}
{errors.submit && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<i className="fa-solid fa-circle-exclamation mr-2"></i>
{errors.submit}
</div>
)}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Create account"
)}
</Button>
<Link
href="/manager"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
Back to Dashboard
</Link>
</div>
</form>
</div>
</div>
);
}
+204 -38
View File
@@ -1,15 +1,16 @@
"use client"; "use client";
import { import { ProductsTab, ReviewsTab } from "@/components/organisms/manager";
ProductsTab,
} from "@/components/organisms/manager";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { useManager } from "@/lib/manager-context"; import { useManager } from "@/lib/manager-context";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react";
export default function ManagerPage() { export default function ManagerPage() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { activeTab, setActiveTab, products } = useManager(); const { activeTab, setActiveTab, products } = useManager();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const tabs = [ const tabs = [
{ {
@@ -18,11 +19,30 @@ export default function ManagerPage() {
icon: "fa-solid fa-utensils", icon: "fa-solid fa-utensils",
count: products.length, count: products.length,
}, },
{
id: "reviews" as const,
label: "Reviews",
icon: "fa-solid fa-star",
count: null,
},
]; ];
useEffect(() => {
function handleOutsideClick(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, []);
return ( return (
<div className="flex min-h-screen"> <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"> <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 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)"> <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-primary)">
@@ -88,6 +108,18 @@ export default function ManagerPage() {
</span> </span>
</Link> </Link>
</div> </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> </nav>
<div className="border-t border-(--color-border-light) p-3"> <div className="border-t border-(--color-border-light) p-3">
@@ -123,50 +155,176 @@ export default function ManagerPage() {
{/* ── Main content ── */} {/* ── Main content ── */}
<div className="flex min-w-0 flex-1 flex-col"> <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"> <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">
<div> {/* Title */}
<h1 className="text-foreground text-lg font-bold"> <div className="min-w-0 shrink">
<h1 className="text-foreground truncate text-lg font-bold">
{tabs.find((t) => t.id === activeTab)?.label ?? "Manager"} {tabs.find((t) => t.id === activeTab)?.label ?? "Manager"}
</h1> </h1>
<p className="text-xs text-(--color-text-muted)"> <p className="truncate text-xs text-(--color-text-muted)">
Manage{" "}
{activeTab === "products" {activeTab === "products"
? "menu items" ? "Manage menu items for your store"
: activeTab === "combos" : "Customer reviews for your eatery"}
? "combos"
: activeTab === "categories"
? "categories"
: "menu"}{" "}
for your store
</p> </p>
</div> </div>
{/* Mobile tabs */} {/* ── Actions: hidden on lg+ (sidebar handles nav there) ── */}
<div className="flex items-center gap-1 lg:hidden"> <div className="flex shrink-0 items-center gap-1.5 lg:hidden">
{tabs.map((tab) => ( {/* smlg: inline icon+label buttons */}
<button <div className="hidden items-center gap-1.5 sm:flex">
key={tab.id} {tabs.map((tab) => (
onClick={() => setActiveTab(tab.id)} <button
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${ key={tab.id}
activeTab === tab.id onClick={() => setActiveTab(tab.id)}
? "bg-(--color-primary) text-white" className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)" 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> <i className="fa-solid fa-chart-line"></i>
<span className="hidden sm:inline">{tab.label}</span> <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> </button>
))} </div>
<Link
href="/manager/analytics" {/* < sm: hamburger → dropdown */}
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" <div className="relative sm:hidden" ref={dropdownRef}>
> <button
<i className="fa-solid fa-chart-line"></i> onClick={() => setDropdownOpen((prev) => !prev)}
<span className="hidden sm:inline">Finance</span> aria-label="Open menu"
</Link> className="flex cursor-pointer items-center justify-center rounded-xl border border-(--color-border-light) bg-transparent p-2 text-sm text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
<i
className={
dropdownOpen ? "fa-solid fa-xmark" : "fa-solid fa-bars"
}
></i>
</button>
{dropdownOpen && (
<div className="absolute top-full right-0 z-50 mt-2 w-52 overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-xl">
<div className="p-1.5">
{/* Tab buttons */}
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setDropdownOpen(false);
}}
className={`flex w-full cursor-pointer items-center gap-2.5 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition ${
activeTab === tab.id
? "bg-(--color-primary) text-white"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light)"
}`}
>
<i className={`${tab.icon} w-4 text-center`}></i>
<span className="flex-1 text-left">{tab.label}</span>
{tab.count !== null && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
activeTab === tab.id
? "bg-white/20 text-white"
: "bg-(--color-border-light) text-(--color-text-muted)"
}`}
>
{tab.count}
</span>
)}
</button>
))}
<div className="my-1.5 border-t border-(--color-border-light)"></div>
{/* Navigation links */}
<Link
href="/manager/analytics"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)"
>
<i className="fa-solid fa-chart-line w-4 text-center"></i>
<span>Finance</span>
</Link>
<Link
href="/staff/schedule"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
>
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
<span>Shifts</span>
</Link>
<Link
href="/manager/create-staff"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
>
<i className="fa-solid fa-user-plus w-4 text-center"></i>
<span>Create Staff</span>
</Link>
<Link
href="/"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
>
<i className="fa-solid fa-house w-4 text-center"></i>
<span>Home</span>
</Link>
<div className="my-1.5 border-t border-(--color-border-light)"></div>
<button
onClick={() => {
setDropdownOpen(false);
logout();
}}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-red-500 transition hover:bg-red-50"
>
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
<span>Logout</span>
</button>
</div>
</div>
)}
</div>
</div> </div>
{/* Desktop actions */} {/* ── Desktop actions (lg+): sidebar handles nav, just show shortcut ── */}
<div className="hidden items-center gap-2 lg:flex"> <div className="hidden items-center gap-2 lg:flex">
<Link <Link
href="/manager/analytics" href="/manager/analytics"
@@ -175,6 +333,13 @@ export default function ManagerPage() {
<i className="fa-solid fa-chart-line"></i> <i className="fa-solid fa-chart-line"></i>
Financial Analytics Financial Analytics
</Link> </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 <Link
href="/" 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" 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"
@@ -187,6 +352,7 @@ export default function ManagerPage() {
<main className="flex-1 p-5 md:p-8"> <main className="flex-1 p-5 md:p-8">
{activeTab === "products" && <ProductsTab />} {activeTab === "products" && <ProductsTab />}
{activeTab === "reviews" && <ReviewsTab />}
</main> </main>
</div> </div>
</div> </div>
+208 -155
View File
@@ -7,7 +7,7 @@ import ShiftDetailModal from "@/components/organisms/shift-schedule/ShiftDetailM
import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule"; import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { useShift } from "@/lib/shift-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 Link from "next/link";
import { useState } from "react"; import { useState } from "react";
@@ -50,61 +50,69 @@ export default function StaffSchedulePage() {
goToPrevMonth, goToPrevMonth,
goToToday, goToToday,
getWeeklyBudget, getWeeklyBudget,
shifts,
} = useShift(); } = useShift();
const [selectedShift, setSelectedShift] = useState<ShiftSlot | null>(null); const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [createOpen, setCreateOpen] = 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 isManager = user?.role === "manager";
const handleShiftClick = (shift: ShiftSlot) => { const handleShiftClick = (shift: ShiftEntity) => {
setSelectedShift(shift); setSelectedShift(shift);
setDetailOpen(true); setDetailOpen(true);
}; };
const handleCreateShift = (date: string) => { const handleCreateShift = (date: Date) => {
setCreateDate(date); setCreateDate(date);
setCreateOpen(true); setCreateOpen(true);
}; };
const handleDateSelect = (date: string) => { const handleDateSelect = (date: Date) => {
// In month view on desktop, clicking a date could open create modal for managers
if (isManager) { if (isManager) {
setCreateDate(date); setCreateDate(date);
setCreateOpen(true); setCreateOpen(true);
} }
}; };
// Week range label
const monday = getMonday(currentDate); const monday = getMonday(currentDate);
const sunday = new Date(monday); const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6); sunday.setDate(monday.getDate() + 6);
const weekLabel = `${formatDateShort(monday)} ${formatDateShort(sunday)}`; const weekLabel = `${formatDateShort(monday)} ${formatDateShort(sunday)}`;
const weeklyBudget = getWeeklyBudget(); 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 ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen bg-gray-50/50">
{/* ── Sidebar (Desktop) ── */} {/* ── Sidebar (Desktop) ─────────────────────────────────────────────────── */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-(--color-border-light) bg-white shadow-sm lg:flex"> <aside className="hidden w-64 shrink-0 flex-col border-r border-(--color-border-light) bg-white shadow-sm lg:flex">
{/* Brand */} {/* Brand — gradient accent */}
<div className="flex items-center gap-3 border-b border-(--color-border-light) px-5 py-5"> <div className="bg-gradient-to-br from-(--color-primary) to-(--color-primary-dark) px-5 py-5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-primary)"> <div className="flex items-center gap-3">
<i className="fa-solid fa-calendar-days text-sm text-white"></i> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20 shadow-sm backdrop-blur-sm">
</div> <i className="fa-solid fa-calendar-days text-base text-white"></i>
<div> </div>
<p className="text-foreground text-sm font-bold">Work Schedule</p> <div>
<p className="text-xs text-(--color-text-muted)"> <p className="text-sm font-bold text-white">Work Schedule</p>
{isManager ? "Manager" : "Staff"} <p className="text-xs text-white/70">
</p> {isManager ? "Manager View" : "Staff View"}
</p>
</div>
</div> </div>
</div> </div>
{/* View toggle */} <nav className="flex-1 space-y-0.5 overflow-y-auto p-3">
<nav className="flex-1 space-y-1 p-3"> {/* View toggle */}
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-1 px-3 pt-2 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
View View
</p> </p>
<button <button
@@ -112,35 +120,45 @@ export default function StaffSchedulePage() {
onClick={() => setView("week")} 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 ${ 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" view === "week"
? "bg-(--color-primary) text-white shadow-sm" ? "bg-(--color-primary)/10 text-(--color-primary) shadow-sm"
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)" : "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> <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>
<button <button
type="button" type="button"
onClick={() => setView("month")} 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 ${ 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" view === "month"
? "bg-(--color-primary) text-white shadow-sm" ? "bg-(--color-primary)/10 text-(--color-primary) shadow-sm"
: "hover:bg-background bg-transparent text-(--color-text-secondary) hover:text-(--color-primary-dark)" : "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> <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> </button>
{/* Quick nav */} {/* Navigation */}
<div className="mt-3 border-t border-(--color-border-light) pt-3"> <div className="mt-2 pt-2">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-1 px-3 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
Navigation Navigation
</p> </p>
<button <button
type="button" type="button"
onClick={goToToday} 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> <i className="fa-solid fa-crosshairs w-4 text-center"></i>
<span className="flex-1 text-left">Today</span> <span className="flex-1 text-left">Today</span>
@@ -149,38 +167,64 @@ export default function StaffSchedulePage() {
{/* Manager link */} {/* Manager link */}
{isManager && ( {isManager && (
<div className="mt-3 border-t border-(--color-border-light) pt-3"> <div className="mt-2 pt-2">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-1 px-3 text-[10px] font-bold tracking-widest text-(--color-text-muted) uppercase">
Management Manager
</p> </p>
<Link <Link
href="/manager" 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> <i className="fa-solid fa-store w-4 text-center"></i>
<span className="flex-1 text-left">Dashboard</span> <span className="flex-1 text-left">Dashboard</span>
<i className="fa-solid fa-arrow-right text-xs opacity-40"></i>
</Link> </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> </div>
)} )}
{/* Weekly budget */} {/* Stats cards */}
<div className="mt-3 border-t border-(--color-border-light) pt-3"> <div className="mt-3 space-y-2 border-t border-(--color-border-light) pt-3">
<div className="rounded-xl bg-(--color-primary)/5 p-3"> {isManager && (
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase"> <div className="rounded-xl bg-gradient-to-br from-(--color-primary)/10 to-(--color-primary)/5 p-3">
Weekly Budget <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>
<p className="mt-1 text-lg font-bold text-(--color-primary)"> <p className="mt-1 text-xl font-bold text-(--color-text-secondary)">
{weeklyBudget.toLocaleString("vi-VN")} {totalShiftsThisWeek}
</p> </p>
<p className="text-[10px] text-(--color-text-muted)">VND</p> <p className="text-[10px] text-(--color-text-muted)">shifts</p>
</div> </div>
</div> </div>
</nav> </nav>
{/* User info */} {/* User info */}
<div className="border-t border-(--color-border-light) p-3"> <div className="border-t border-(--color-border-light) p-3">
<div className="flex items-center gap-3 rounded-xl p-3"> <div className="flex items-center gap-3 rounded-xl bg-gray-50 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-primary)/15">
<i <i
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`} className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
></i> ></i>
@@ -189,17 +233,23 @@ export default function StaffSchedulePage() {
<p className="text-foreground truncate text-sm font-semibold"> <p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Staff"} {user?.name ?? "Staff"}
</p> </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"} {isManager ? "Manager" : "Staff"}
</p> </span>
</div> </div>
</div> </div>
<div className="mt-1 flex gap-2 px-1"> <div className="mt-2 flex gap-2 px-1">
<Link <Link
href="/" 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 Home
</Link> </Link>
<button <button
@@ -207,122 +257,125 @@ export default function StaffSchedulePage() {
onClick={logout} onClick={logout}
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50" className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
> >
<i className="fa-solid fa-right-from-bracket"></i> <i className="fa-solid fa-right-from-bracket text-[10px]"></i>
Logout Logout
</button> </button>
</div> </div>
</div> </div>
</aside> </aside>
{/* ── Main content ── */} {/* ── Main content ──────────────────────────────────────────────────────── */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
{/* Header */} {/* Header */}
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4"> <header className="sticky top-0 z-40 border-b border-(--color-border-light) bg-white/95 px-4 py-3 shadow-sm backdrop-blur-sm md:px-6 md:py-4">
<div> <div className="flex items-center justify-between gap-3">
<h1 className="text-foreground text-base font-bold md:text-lg"> {/* Title */}
Register Shift <div>
</h1> <h1 className="text-foreground text-base font-bold md:text-lg">
<p className="text-xs text-(--color-text-muted)"> {isManager ? "Manage Shifts" : "Register Shifts"}
{view === "week" </h1>
? weekLabel <p className="text-xs text-(--color-text-muted)">
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`} {view === "week"
</p> ? `Week: ${weekLabel}`
</div> : `Month: ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
</p>
<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>
</div> </div>
{/* Navigation arrows */} <div className="flex items-center gap-2">
<div className="hidden items-center gap-1 md:flex"> {/* Mobile view toggle */}
<button <div className="flex items-center overflow-hidden rounded-xl border border-(--color-border-light) lg:hidden">
title="Previous" <button
type="button" type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth} onClick={() => setView("week")}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50" className={`cursor-pointer rounded-l-xl border-none px-3 py-2 text-xs font-semibold transition ${
> view === "week"
<i className="fa-solid fa-chevron-left text-xs"></i> ? "bg-(--color-primary) text-white"
</button> : "bg-transparent text-(--color-text-secondary)"
<button }`}
type="button" >
onClick={goToToday} Week
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" </button>
> <button
Today type="button"
</button> onClick={() => setView("month")}
<button className={`cursor-pointer rounded-r-xl border-none px-3 py-2 text-xs font-semibold transition ${
title="Next" view === "month"
type="button" ? "bg-(--color-primary) text-white"
onClick={view === "week" ? goToNextWeek : goToNextMonth} : "bg-transparent text-(--color-text-secondary)"
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> Month
</button> </button>
</div> </div>
{/* Create shift button (manager only) */} {/* Desktop navigation arrows */}
{isManager && ( <div className="hidden items-center gap-1 md:flex">
<button <button
type="button" title="Previous"
onClick={() => { type="button"
setCreateDate(undefined); onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
setCreateOpen(true); 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)"
}} >
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-chevron-left text-xs"></i>
> </button>
<i className="fa-solid fa-plus"></i> <button
Create Shift type="button"
</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 */} {/* Create shift (manager, desktop) */}
<div className="flex items-center gap-1 md:hidden"> {isManager && (
<button <button
title="Previous" type="button"
type="button" onClick={() => {
onClick={view === "week" ? goToPrevWeek : goToPrevMonth} setCreateDate(undefined);
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)" setCreateOpen(true);
> }}
<i className="fa-solid fa-chevron-left text-xs"></i> 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"
</button> >
<button <i className="fa-solid fa-plus"></i>
title="Next" Create shift
type="button" </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)"
> {/* Mobile navigation arrows */}
<i className="fa-solid fa-chevron-right text-xs"></i> <div className="flex items-center gap-1 md:hidden">
</button> <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>
</div> </div>
</header> </header>
{/* Content */} {/* Content */}
<main className="flex-1 p-4 md:p-6"> <main className="flex-1 p-4 md:p-6">
{/* Desktop views */} {/* Desktop */}
<div className="hidden md:block"> <div className="hidden md:block">
{view === "week" ? ( {view === "week" ? (
<WeeklySchedule <WeeklySchedule
@@ -337,7 +390,7 @@ export default function StaffSchedulePage() {
)} )}
</div> </div>
{/* Mobile view */} {/* Mobile */}
<div className="md:hidden"> <div className="md:hidden">
{view === "week" ? ( {view === "week" ? (
<WeeklySchedule <WeeklySchedule
@@ -350,16 +403,16 @@ export default function StaffSchedulePage() {
)} )}
</div> </div>
{/* Mobile FAB for manager */} {/* FAB for mobile manager */}
{isManager && ( {isManager && (
<button <button
title="Create Shift" title="Create shift"
type="button" type="button"
onClick={() => { onClick={() => {
setCreateDate(undefined); setCreateDate(undefined);
setCreateOpen(true); setCreateOpen(true);
}} }}
className="fixed right-4 bottom-4 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-lg transition hover:opacity-90 md:hidden" className="fixed right-4 bottom-6 z-30 flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-(--color-primary) text-white shadow-xl transition hover:opacity-90 md:hidden"
> >
<i className="fa-solid fa-plus text-lg"></i> <i className="fa-solid fa-plus text-lg"></i>
</button> </button>
+1
View File
@@ -19,5 +19,6 @@ export interface PaymentSummaryCardProps {
totalPrice: number; totalPrice: number;
isCustomer: boolean; isCustomer: boolean;
backHref: string; backHref: string;
eateryId?: string;
onPay?: () => void; onPay?: () => void;
} }
@@ -1,21 +1,25 @@
import { formatPrice } from "@/app/(main)/payment/page";
import Button from "@/components/atoms/buttons/Button"; import Button from "@/components/atoms/buttons/Button";
import { ReviewModal } from "@/components/organisms/modals"; import { ReviewModal } from "@/components/organisms/modals";
import { cartClient } from "@/lib/apollo-clients";
import { useCart } from "@/lib/cart-context";
import { gql } from "@apollo/client";
import { useMutation } from "@apollo/client/react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import type { PaymentSummaryCardProps } from "./Card.types"; import type { PaymentSummaryCardProps } from "./Card.types";
const formatPrice = (value: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
export default function PaymentSummaryCard({ export default function PaymentSummaryCard({
totalPrice, totalPrice,
isCustomer = false, isCustomer = false,
backHref, backHref,
eateryId,
}: PaymentSummaryCardProps) { }: PaymentSummaryCardProps) {
const { cart, removeCart } = useCart();
const [isReviewOpen, setIsReviewOpen] = useState(false); const [isReviewOpen, setIsReviewOpen] = useState(false);
const [isQrModalOpen, setIsQrModalOpen] = useState(false);
const handlePayment = () => { const handlePayment = () => {
// UI-only: open review modal after "payment"
if (isCustomer) { if (isCustomer) {
setIsReviewOpen(true); setIsReviewOpen(true);
} }
@@ -45,7 +49,9 @@ export default function PaymentSummaryCard({
<Button <Button
style="payment" style="payment"
onClick={handlePayment} onClick={() => {
setIsQrModalOpen(true);
}}
icon="fa-solid fa-qrcode" icon="fa-solid fa-qrcode"
size="md" size="md"
variant="secondary" variant="secondary"
@@ -53,22 +59,7 @@ export default function PaymentSummaryCard({
QR Code QR Code
</Button> </Button>
{isCustomer && ( <Link href={backHref || "/"} className={"col-span-2"}>
<Button
style="payment"
onClick={handlePayment}
icon="fa-solid fa-star"
size="md"
variant="primary"
>
Review
</Button>
)}
<Link
href={backHref || "/"}
className={isCustomer ? "" : "col-span-2"}
>
<Button <Button
style="payment" style="payment"
onClick={() => setIsReviewOpen(false)} onClick={() => setIsReviewOpen(false)}
@@ -85,8 +76,51 @@ export default function PaymentSummaryCard({
<ReviewModal <ReviewModal
isOpen={isReviewOpen} isOpen={isReviewOpen}
onClose={() => setIsReviewOpen(false)} onClose={() => {
setIsReviewOpen(false);
removeCart();
}}
eateryId={eateryId}
/> />
{isQrModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="animate-in fade-in zoom-in w-full max-w-sm rounded-3xl bg-white p-6 text-center shadow-2xl duration-300">
<h2 className="mb-4 text-xl font-bold">Scan to Pay</h2>
{cart.paymentQrUrl ? (
<div className="mb-4 rounded-2xl bg-gray-100 p-4">
<img
src={cart.paymentQrUrl}
alt="Payment QR Code"
className="mx-auto h-auto w-full"
/>
</div>
) : (
<div className="p-10 text-gray-400">QR Code not available</div>
)}
<p className="mb-6 text-sm text-gray-500">
Total:{" "}
<span className="font-bold text-black">
{formatPrice(totalPrice)}
</span>
</p>
<Button
onClick={() => {
setIsQrModalOpen(false);
setIsReviewOpen(true);
}}
variant="secondary"
className="w-full"
>
Close
</Button>
</div>
</div>
)}
</aside> </aside>
); );
} }
+1 -1
View File
@@ -38,7 +38,7 @@ export default function ProductCard({
</div> </div>
{/* Product image */} {/* Product image */}
<Image <Image
src={image} src={image || "/"}
alt={imageAlt} alt={imageAlt}
fill fill
className="z-1 object-cover" className="z-1 object-cover"
+87 -84
View File
@@ -1,62 +1,74 @@
"use client"; "use client";
import type { ShiftSlot } from "@/lib/types";
import type { ShiftCardProps } from "./ShiftCard.types"; import type { ShiftCardProps } from "./ShiftCard.types";
const STATUS_STYLES: Record<
ShiftSlot["status"],
{ bg: string; text: string; label: string }
> = {
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 { function formatWage(wage: number): string {
if (wage >= 1000) { if (wage >= 1_000_000) return `${(wage / 1_000_000).toFixed(1)}M`;
return `${(wage / 1000).toFixed(0)}k`; if (wage >= 1000) return `${(wage / 1000).toFixed(0)}k`;
}
return wage.toLocaleString("vi-VN"); return wage.toLocaleString("vi-VN");
} }
function getShiftPeriod(
startTime: string,
): "morning" | "afternoon" | "evening" {
const h = parseInt(startTime.split(":")[0] ?? "0", 10);
if (h < 12) return "morning";
if (h < 18) return "afternoon";
return "evening";
}
const PERIOD_STYLES = {
morning: {
border: "border-l-indigo-500",
text: "text-indigo-700",
badge: "bg-indigo-100 text-indigo-700",
dot: "bg-indigo-400",
},
afternoon: {
border: "border-l-amber-500",
text: "text-amber-700",
badge: "bg-amber-100 text-amber-700",
dot: "bg-amber-400",
},
evening: {
border: "border-l-violet-500",
text: "text-violet-700",
badge: "bg-violet-100 text-violet-700",
dot: "bg-violet-400",
},
};
export default function ShiftCard({ export default function ShiftCard({
shift, shift,
compact = false, compact = false,
onClick, onClick,
}: ShiftCardProps) { }: ShiftCardProps) {
const style = STATUS_STYLES[shift.status]; const registeredCount = shift.registeredStaff?.length ?? 0;
const period = getShiftPeriod(shift.startTime);
const s = PERIOD_STYLES[period];
if (compact) { if (compact) {
return ( return (
<button <button
type="button" type="button"
onClick={() => onClick?.(shift)} onClick={() => onClick?.(shift)}
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm ${style.bg} ${style.text}`} className={`group w-full cursor-pointer rounded-xl border-l-[3px] bg-white text-left shadow-sm transition hover:-translate-y-px hover:shadow-md ${s.border}`}
> >
<p className="font-semibold"> <div className="px-2.5 py-2">
{shift.startTime} {shift.endTime} <p className={`text-xs leading-tight font-bold ${s.text}`}>
</p> {shift.startTime}{shift.endTime}
<p className="mt-0.5 opacity-75"> </p>
{shift.durationHours}h · {formatWage(shift.wage)} <div className="mt-1 flex items-center justify-between gap-1">
</p> <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> </button>
); );
} }
@@ -65,55 +77,46 @@ export default function ShiftCard({
<button <button
type="button" type="button"
onClick={() => onClick?.(shift)} 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 className="p-4">
<div> <div className="flex items-start justify-between gap-3">
<p className="text-sm font-bold"> <div className="min-w-0">
{shift.startTime} {shift.endTime} <div className="flex items-center gap-2">
</p> <span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<p className="mt-1 text-xs opacity-75"> <p className={`text-base font-bold ${s.text}`}>
{shift.durationHours}h · {formatWage(shift.wage)} VND {shift.startTime} {shift.endTime}
</p> </p>
</div> </div>
<span <p className="mt-1 text-xs text-(--color-text-muted)">
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${ {(shift.wage ?? 0).toLocaleString("vi-VN")} VND/ca
shift.status === "available" </p>
? "bg-blue-200 text-blue-800"
: shift.status === "registered"
? "bg-blue-300 text-blue-900"
: shift.status === "approved_leave"
? "bg-purple-200 text-purple-800"
: "bg-red-200 text-red-800"
}`}
>
{style.label}
</span>
</div>
{shift.registeredStaff.length > 0 && (
<div className="mt-2 border-t border-current/10 pt-2">
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
</p>
<div className="mt-1 flex flex-wrap gap-1">
{shift.registeredStaff.map((s) => (
<span
key={s.id}
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
>
{s.name}
</span>
))}
</div> </div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${s.badge}`}
>
{registeredCount}/{shift.maxStaff} staff
</span>
</div> </div>
)}
{shift.status === "available" && shift.registeredStaff.length === 0 && ( {registeredCount > 0 && (
<p className="mt-2 text-[10px] italic opacity-50"> <div className="mt-3 border-t border-gray-100 pt-3">
{shift.maxStaff} vị trí còn trống <p className="mb-2 text-[10px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
</p> 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> </button>
); );
} }
@@ -1,7 +1,7 @@
import type { ShiftSlot } from "@/lib/types"; import type { ShiftEntity } from "@/lib/types";
export interface ShiftCardProps { export interface ShiftCardProps {
shift: ShiftSlot; shift: ShiftEntity;
compact?: boolean; compact?: boolean;
onClick?: (shift: ShiftSlot) => void; onClick?: (shift: ShiftEntity) => void;
} }
@@ -6,7 +6,7 @@ export default function SearchBar({
value, value,
onChange, onChange,
onClear, onClear,
placeholder = "Tìm kiếm...", placeholder = "Search...",
className = "", className = "",
}: SearchBarProps) { }: SearchBarProps) {
return ( return (
@@ -17,14 +17,14 @@ export default function SearchBar({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
aria-label="Tìm kiếm món ăn" 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" className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
/> />
{value && ( {value && (
<button <button
onClick={onClear} onClick={onClear}
title="Xóa tìm kiếm" title="Clear search"
aria-label="Xóa tìm kiếm" 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)" 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> <i className="fa-solid fa-xmark text-sm"></i>
+6 -3
View File
@@ -44,12 +44,15 @@ export default function LoginForm() {
body: JSON.stringify({ phone }), 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_phone", phone);
sessionStorage.setItem("login_role", role); 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) { } else if (res.status === 404) {
setErrors({ phone: "", general: "Phone number not registered" }); setErrors({ phone: "", general: "Phone number not registered" });
} else { } else {
+1 -1
View File
@@ -15,7 +15,7 @@ export { ReviewModal } from "./modals";
export type { ReviewModalProps, ConfirmModalProps } from "./modals"; export type { ReviewModalProps, ConfirmModalProps } from "./modals";
// Shop Grid // Shop Grid
export { ShopGrid } from "./shop-grid"; // export { ShopGrid } from "./shop-grid";
export type { ShopGridProps } from "./shop-grid"; export type { ShopGridProps } from "./shop-grid";
// Manager // Manager
@@ -17,9 +17,11 @@ export default function DeleteConfirm({
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100"> <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> <i className="fa-solid fa-trash-can text-xl text-red-500"></i>
</div> </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)"> <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> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -27,13 +29,13 @@ export default function DeleteConfirm({
onClick={onClose} 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)" 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>
<button <button
onClick={onConfirm} 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" 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> </button>
</div> </div>
</div> </div>
@@ -1,5 +1,14 @@
import type { MenuItemEntity } 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 { export interface ProductModalProps {
product: MenuItemEntity | null; product: MenuItemEntity | null;
onSave: (p: MenuItemEntity) => void; onSave: (p: MenuItemEntity) => void;
+115 -20
View File
@@ -14,13 +14,60 @@ export default function ProductModal({
const [form, setForm] = useState<MenuItemEntity>({ const [form, setForm] = useState<MenuItemEntity>({
name: product?.name ?? "", name: product?.name ?? "",
price: product?.price ?? 0, price: product?.price ?? 0,
imageUrl: product?.imageUrl ?? "/imgs/products/placeholder.jpg", imageUrl: product?.imageUrl ?? "",
description: product?.description ?? "", description: product?.description ?? "",
available: product?.available ?? true, 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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isEdit && product) { if (isEdit && product) {
onSave({ ...form, id: product.id }); onSave({ ...form, id: product.id });
} else { } else {
@@ -28,22 +75,29 @@ export default function ProductModal({
} }
}; };
const inputCls = const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
"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 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 ( return (
<div <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl"> <div className="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"> <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"> <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> </h2>
<button <button
onClick={onClose} 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)" 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> <i className="fa-solid fa-xmark"></i>
@@ -53,7 +107,7 @@ export default function ProductModal({
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5"> <form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div> <div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)"> <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> </label>
<input <input
required required
@@ -61,13 +115,13 @@ export default function ProductModal({
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls} className={inputCls}
placeholder="Ví dụ: Cà Phê Đen" placeholder="e.g. Black Coffee"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)"> <label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá (đ) <span className="text-red-500">*</span> Price () <span className="text-red-500">*</span>
</label> </label>
<input <input
required required
@@ -85,7 +139,47 @@ export default function ProductModal({
<div> <div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)"> <label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả 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> </label>
<textarea <textarea
rows={3} rows={3}
@@ -94,19 +188,19 @@ export default function ProductModal({
setForm({ ...form, description: e.target.value }) setForm({ ...form, description: e.target.value })
} }
className={`${inputCls} resize-none`} className={`${inputCls} resize-none`}
placeholder="Mô tả ngắn về món..." placeholder="Short description..."
/> />
</div> </div>
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3"> <div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
<div> <div>
<p className="text-foreground text-sm font-medium">Trạng thái</p> <p className="text-foreground text-sm font-medium">Status</p>
<p className="text-xs text-(--color-text-muted)"> <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> </p>
</div> </div>
<button <button
title="Chuyển trạng thái" title="Toggle status"
type="button" type="button"
onClick={() => setForm({ ...form, available: !form.available })} onClick={() => setForm({ ...form, available: !form.available })}
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${ className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
@@ -127,13 +221,14 @@ export default function ProductModal({
onClick={onClose} 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)" 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>
<button <button
type="submit" 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> </button>
</div> </div>
</form> </form>
+34 -18
View File
@@ -12,6 +12,12 @@ function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ"; 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() { export default function ProductsTab() {
const { const {
products, products,
@@ -53,12 +59,12 @@ export default function ProductsTab() {
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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" className="text-foreground w-full rounded-xl border border-(--color-border) bg-white py-2 pr-9 pl-9 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/> />
{search && ( {search && (
<button <button
title="Xóa tìm kiếm" title="Clear search"
onClick={() => setSearch("")} 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)" 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)"
> >
@@ -75,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)" 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="all">All statuses</option>
<option value="available">Còn hàng</option> <option value="available">In stock</option>
<option value="unavailable">Tạm hết</option> <option value="unavailable">Out of stock</option>
</select> </select>
<button <button
title="Thêm món" title="Add item"
onClick={() => setModalProduct("new")} 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" 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> <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> </button>
</div> </div>
<p className="text-sm text-(--color-text-muted)"> <p className="text-sm text-(--color-text-muted)">
Hiển thị <strong className="text-foreground">{filtered.length}</strong>{" "} Showing <strong className="text-foreground">{filtered.length}</strong> /{" "}
/ {products.length} món {products.length} items
</p> </p>
{/* Table */} {/* Table */}
@@ -103,16 +109,19 @@ export default function ProductsTab() {
<thead className="bg-background"> <thead className="bg-background">
<tr> <tr>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)"> <th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Tên món Image
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Item name
</th> </th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)"> <th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Giá Price
</th> </th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)"> <th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Trạng thái Status
</th> </th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)"> <th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Thao tác Actions
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -124,7 +133,7 @@ export default function ProductsTab() {
className="py-12 text-center text-(--color-text-muted)" className="py-12 text-center text-(--color-text-muted)"
> >
<i className="fa-solid fa-mug-hot mb-2 block text-3xl opacity-30"></i> <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> </td>
</tr> </tr>
) : ( ) : (
@@ -133,6 +142,13 @@ export default function ProductsTab() {
key={p.id} key={p.id}
className="hover:bg-background transition-colors" 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"> <td className="px-4 py-3">
<div> <div>
<p className="text-foreground font-medium">{p.name}</p> <p className="text-foreground font-medium">{p.name}</p>
@@ -149,7 +165,7 @@ export default function ProductsTab() {
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<button <button
onClick={() => toggleProductAvailability(p)} onClick={() => toggleProductAvailability(p)}
title="Nhấn để đổi trạng thái" title="Click to toggle status"
className="cursor-pointer border-none bg-transparent" className="cursor-pointer border-none bg-transparent"
> >
<StatusBadge available={p.available ?? true} /> <StatusBadge available={p.available ?? true} />
@@ -159,14 +175,14 @@ export default function ProductsTab() {
<div className="flex items-center justify-center gap-1.5"> <div className="flex items-center justify-center gap-1.5">
<button <button
onClick={() => setModalProduct(p)} 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)" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
> >
<i className="fa-solid fa-pen text-xs"></i> <i className="fa-solid fa-pen text-xs"></i>
</button> </button>
<button <button
onClick={() => setDeleteTarget(p)} 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" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
> >
<i className="fa-solid fa-trash text-xs"></i> <i className="fa-solid fa-trash text-xs"></i>
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useManager } from "@/lib/manager-context";
import { useEffect, useState } from "react";
import type { ReviewEntity } from "./Manager.types";
interface FetchState {
reviews: ReviewEntity[];
loading: boolean;
error: string | null;
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<i
key={star}
className={
star <= rating
? "fa-solid fa-star text-sm text-yellow-400"
: "fa-regular fa-star text-sm text-(--color-border)"
}
/>
))}
</div>
);
}
export default function ReviewsTab() {
const { eateryId } = useManager();
const [{ reviews, loading, error }, setState] = useState<FetchState>({
reviews: [],
loading: true,
error: null,
});
useEffect(() => {
if (!eateryId) return;
const controller = new AbortController();
fetch(`/api/eatery/review/${eateryId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Failed to load reviews: ${res.status}`);
return res.json() as Promise<ReviewEntity[]>;
})
.then((data) => setState({ reviews: data, loading: false, error: null }))
.catch((err: unknown) => {
if (err instanceof Error && err.name === "AbortError") return;
setState({
reviews: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to load reviews.",
});
});
return () => controller.abort();
}, [eateryId]);
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Loading reviews...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-20 text-red-500">
<i className="fa-solid fa-triangle-exclamation mr-2"></i>
{error}
</div>
);
}
if (reviews.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center text-(--color-text-muted)">
<i className="fa-regular fa-star mb-3 text-4xl"></i>
<p className="text-sm font-medium">No reviews yet</p>
<p className="mt-1 text-xs">Reviews from customers will appear here.</p>
</div>
);
}
const avgRating =
reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
const CustomerName = ({ customerId }: { customerId: string }) => {
const [name, setName] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Hàm gọi API
const getName = async () => {
try {
setLoading(true);
const response = await fetch(`/api/customer/${customerId}`);
if (!response.ok) {
throw new Error("Mạng lỗi hoặc không tìm thấy nhân viên");
}
const data = await response.json();
setName(data["name"]);
} catch (error) {
console.error("Lỗi khi lấy tên:", error);
setName("Lỗi tải tên");
} finally {
setLoading(false);
}
};
if (customerId) {
getName();
}
}, [customerId]); // Chạy lại nếu customerId thay đổi
if (loading)
return <span className="animate-pulse text-gray-400">...</span>;
return <span>{name || "N/A"}</span>;
};
return (
<div className="space-y-4">
{/* Summary header */}
<div className="flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-5">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-(--color-accent-light)">
<i className="fa-solid fa-star text-2xl text-yellow-400"></i>
</div>
<div>
<p className="text-foreground text-2xl font-bold">
{avgRating.toFixed(1)}
<span className="ml-1 text-sm font-normal text-(--color-text-muted)">
/ 5
</span>
</p>
<StarRating rating={Math.round(avgRating)} />
<p className="mt-0.5 text-xs text-(--color-text-muted)">
{reviews.length} review{reviews.length !== 1 ? "s" : ""}
</p>
</div>
</div>
{/* Review list */}
<div className="space-y-3">
{reviews.map((review, idx) => (
<div
key={review.id ?? idx}
className="rounded-2xl border border-(--color-border-light) bg-white p-4"
>
<div className="mb-2 flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className="fa-solid fa-user text-xs text-(--color-primary)"></i>
</div>
<span className="text-sm font-medium text-(--color-text-secondary)">
<CustomerName customerId={review.reviewerId!} />
</span>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StarRating rating={review.rating} />
{review.createdAt && (
<span className="text-xs text-(--color-text-muted)">
{new Date(review.createdAt).toLocaleDateString("vi-VN")}
</span>
)}
</div>
</div>
{review.comment ? (
<p className="text-sm leading-relaxed text-(--color-text-secondary)">
{review.comment}
</p>
) : (
<p className="text-sm text-(--color-text-muted) italic">
No comment provided.
</p>
)}
</div>
))}
</div>
</div>
);
}
+2
View File
@@ -3,8 +3,10 @@ export { default as DeleteConfirm } from "./DeleteConfirm";
export { default as ProductModal } from "./ProductModal"; export { default as ProductModal } from "./ProductModal";
export { default as ProductsTab } from "./ProductsTab"; export { default as ProductsTab } from "./ProductsTab";
export { default as MenuItemsTab } from "./MenuItemsTab"; export { default as MenuItemsTab } from "./MenuItemsTab";
export { default as ReviewsTab } from "./ReviewsTab";
export type { export type {
ProductModalProps, ProductModalProps,
DeleteConfirmProps, DeleteConfirmProps,
StatusBadgeProps, StatusBadgeProps,
ReviewEntity,
} from "./Manager.types"; } from "./Manager.types";
@@ -1,6 +1,7 @@
export interface ReviewModalProps { export interface ReviewModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
eateryId?: string;
} }
export interface ConfirmModalProps { export interface ConfirmModalProps {
+51 -7
View File
@@ -1,28 +1,68 @@
"use client"; "use client";
import { Button, Heading, Text, Textarea } from "@/components/atoms"; import { Button, Heading, Text, Textarea } from "@/components/atoms";
import { useAuth } from "@/lib/auth-context";
import { useState } from "react"; import { useState } from "react";
import type { ReviewModalProps } from "./Modal.types"; 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 [rating, setRating] = useState(0);
const [hovered, setHovered] = useState(0); const [hovered, setHovered] = useState(0);
const [review, setReview] = useState(""); const [review, setReview] = useState("");
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null; if (!isOpen) return null;
const handleSubmit = () => { const handleSubmit = async () => {
setSubmitted(true); if (!eateryId) return;
const input: CreateReviewInput = {
reviewerId: user?.id || "",
eateryId,
rating,
...(review.trim() ? { comment: review.trim() } : {}),
};
setLoading(true);
setError(null);
try {
const res = await fetch("/api/eatery/review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit review.");
} finally {
setLoading(false);
}
}; };
const handleClose = () => { const handleClose = () => {
// Reset state when closing
setRating(0); setRating(0);
setHovered(0); setHovered(0);
setReview(""); setReview("");
setSubmitted(false); setSubmitted(false);
setError(null);
onClose(); onClose();
}; };
@@ -128,6 +168,9 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
/> />
</div> </div>
{/* Error message */}
{error && <p className="mb-4 text-sm text-red-500">{error}</p>}
{/* Footer buttons */} {/* Footer buttons */}
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
@@ -136,17 +179,18 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
variant="secondary" variant="secondary"
className="flex-1" className="flex-1"
icon="fa-arrow-left" icon="fa-arrow-left"
disabled={loading}
> >
Go back Go back
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={rating === 0} disabled={rating === 0 || loading}
className="flex-1" className="flex-1"
icon="fa-check" icon={loading ? "fa-spinner fa-spin" : "fa-check"}
> >
Confirm {loading ? "Submitting..." : "Confirm"}
</Button> </Button>
</div> </div>
</> </>
@@ -37,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`} 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> <i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
Thực Đơn Menu
</span> </span>
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */} {/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
<button <button
onClick={onToggle} onClick={onToggle}
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"} title={isOpen ? "Collapse menu" : "Expand menu"}
aria-label={isOpen ? "Thu gọn menu" : "Mở rộng 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" className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
> >
<i <i
@@ -2,17 +2,24 @@
import { ProductCard } from "@/components/molecules/cards"; import { ProductCard } from "@/components/molecules/cards";
import { useCart } from "@/lib/cart-context"; import { useCart } from "@/lib/cart-context";
import { MOCK_PRODUCTS } from "@/lib/constants"; import { useManager } from "@/lib/manager-context";
import type { ProductGridProps } from "./ProductGrid.types"; import type { ProductGridProps } from "./ProductGrid.types";
function toDisplayUrl(filename: string) {
if (!filename) return "/imgs/products/placeholder.jpg";
if (filename.startsWith("/") || filename.startsWith("http")) return filename;
return `/api/file/${filename}`;
}
export default function ProductGrid({ export default function ProductGrid({
searchQuery = "", searchQuery = "",
isSidebarOpen = false, isSidebarOpen = false,
}: ProductGridProps) { }: ProductGridProps) {
const { addToCart } = useCart(); const { addToCart } = useCart();
const { products } = useManager();
const filteredProducts = MOCK_PRODUCTS.filter((p) => { const filteredProducts = products.filter((p) => {
const isAvailable = p.available !== false; const isAvailable = p.available !== false;
const matchesSearch = const matchesSearch =
searchQuery.trim() === "" || searchQuery.trim() === "" ||
@@ -30,17 +37,19 @@ export default function ProductGrid({
{/* ── Product grid ── */} {/* ── Product grid ── */}
{filteredProducts.length > 0 ? ( {filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}> <div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => ( {filteredProducts.map(
<ProductCard ({ id, imageUrl, name, price, description }) => (
key={product.id} <ProductCard
image={product.imageUrl} key={id}
imageAlt={product.name} image={toDisplayUrl(imageUrl)}
productName={product.name} imageAlt={name}
price={product.price} productName={name}
description={product.description} price={price}
onBuy={() => addToCart(product)} description={description}
/> onBuy={() => addToCart({ productId: id!, quantity: 1 })}
))} />
),
)}
</div> </div>
) : ( ) : (
/* Empty state */ /* Empty state */
@@ -1,30 +1,12 @@
"use client"; "use client";
import ShiftCard from "@/components/molecules/cards/ShiftCard"; import ShiftCard from "@/components/molecules/cards/ShiftCard";
import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context"; import { useShift } from "@/lib/shift-context";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { MobileShiftViewProps } from "./ShiftSchedule.types"; import type { MobileShiftViewProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function formatDateISO(d: Date): string {
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${y}-${m}-${day}`;
}
function isToday(d: Date): boolean {
const today = new Date(2026, 3, 10);
return (
d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() &&
d.getFullYear() === today.getFullYear()
);
}
const MONTH_NAMES = [ const MONTH_NAMES = [
"January", "January",
"February", "February",
@@ -40,13 +22,24 @@ const MONTH_NAMES = [
"December", "December",
]; ];
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");
return `${y}-${m}-${day}`;
}
function isToday(d: Date): boolean {
const today = new Date(2026, 3, 10);
return formatDateKey(d) === formatDateKey(today);
}
export default function MobileShiftView({ export default function MobileShiftView({
onShiftClick, onShiftClick,
}: MobileShiftViewProps) { }: MobileShiftViewProps) {
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift(); const { currentDate, getShiftsForDate, goToNextMonth, goToPrevMonth } =
const [selectedDate, setSelectedDate] = useState<string>( useShift();
formatDateISO(new Date(2026, 3, 10)), const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
);
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
@@ -59,191 +52,137 @@ export default function MobileShiftView({
const days: (Date | null)[] = []; const days: (Date | null)[] = [];
for (let i = 0; i < startOffset; i++) days.push(null); for (let i = 0; i < startOffset; i++) days.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) { for (let d = 1; d <= lastDay.getDate(); d++)
days.push(new Date(year, month, d)); days.push(new Date(year, month, d));
}
while (days.length % 7 !== 0) days.push(null); while (days.length % 7 !== 0) days.push(null);
return days; return days;
}, [currentDate]); }, [currentDate]);
const getDotColors = (date: Date): string[] => { const selectedShifts = useMemo(
const dateStr = formatDateISO(date); () => getShiftsForDate(selectedDate),
const dayShifts = shifts.filter((s) => s.date === dateStr); [selectedDate, getShiftsForDate],
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(() => { const dayOfWeek = DAY_HEADERS[(selectedDate.getDay() + 6) % 7];
return shifts.filter((s) => s.date === selectedDate);
}, [shifts, selectedDate]);
const selectedDateObj = new Date(selectedDate + "T00:00:00");
const dayOfWeek = DAY_HEADERS[(selectedDateObj.getDay() + 6) % 7];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Compact month calendar */} {/* Month calendar card */}
<div className="rounded-xl border border-(--color-border-light) bg-white p-3"> <div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
{/* Month navigation */} {/* 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 <button
title="Previous month" title="Previous month"
type="button" type="button"
onClick={goToPrevMonth} 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> <i className="fa-solid fa-chevron-left text-xs"></i>
</button> </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()} {MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3> </h3>
<button <button
title="Next month" title="Next month"
type="button" type="button"
onClick={goToNextMonth} 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> <i className="fa-solid fa-chevron-right text-xs"></i>
</button> </button>
</div> </div>
{/* Day headers */} <div className="p-3">
<div className="mb-1 grid grid-cols-7"> {/* Day headers */}
{DAY_HEADERS.map((day) => ( <div className="mb-1 grid grid-cols-7">
<div {DAY_HEADERS.map((day, i) => (
key={day} <div
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase" key={day}
> className={`py-1 text-center text-[10px] font-bold uppercase ${
{day} i >= 5 ? "text-rose-400" : "text-(--color-text-muted)"
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
{calendarDays.map((date, i) => {
if (!date) {
return <div key={`empty-${i}`} className="p-1" />;
}
const dateStr = formatDateISO(date);
const today = isToday(date);
const selected = dateStr === selectedDate;
const dots = getDotColors(date);
return (
<button
type="button"
key={i}
onClick={() => setSelectedDate(dateStr)}
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
selected ? "rounded-lg bg-(--color-primary)/10" : ""
}`} }`}
> >
<span {day}
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs ${ </div>
today ))}
? "bg-(--color-primary) font-bold text-white" </div>
: selected
? "text-foreground font-bold"
: "text-foreground"
}`}
>
{date.getDate()}
</span>
<div className="mt-0.5 flex gap-0.5">
{dots.slice(0, 3).map((color, j) => (
<span key={j} className={`h-1 w-1 rounded-full ${color}`} />
))}
</div>
</button>
);
})}
</div>
{/* Legend */} {/* Calendar grid */}
<div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2"> <div className="grid grid-cols-7">
<div className="flex items-center gap-1"> {calendarDays.map((date, i) => {
<span className="h-2 w-2 rounded-full bg-amber-400"></span> if (!date) return <div key={`empty-${i}`} className="p-1" />;
<span className="text-[10px] text-(--color-text-muted)">
Available const today = isToday(date);
</span> const selected =
</div> formatDateKey(date) === formatDateKey(selectedDate);
<div className="flex items-center gap-1"> const dayShifts = getShiftsForDate(date);
<span className="h-2 w-2 rounded-full bg-green-500"></span> const isWeekend = date.getDay() === 0 || date.getDay() === 6;
<span className="text-[10px] text-(--color-text-muted)">
Registered return (
</span> <button
</div> type="button"
<div className="flex items-center gap-1"> key={i}
<span className="h-2 w-2 rounded-full bg-purple-400"></span> onClick={() => setSelectedDate(date)}
<span className="text-[10px] text-(--color-text-muted)"> className="flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition"
On leave >
</span> <span
</div> className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition ${
<div className="flex items-center gap-1"> today
<span className="h-2 w-2 rounded-full bg-red-400"></span> ? "bg-(--color-primary) text-white shadow-sm"
<span className="text-[10px] text-(--color-text-muted)"> : selected
Absent ? "bg-(--color-primary)/15 text-(--color-primary) ring-2 ring-(--color-primary)/30"
</span> : 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> </div>
</div> </div>
{/* Selected day shifts */} {/* Selected day shifts */}
<div> <div>
<h3 className="text-foreground mb-3 text-sm font-bold"> <div className="mb-3 flex items-center justify-between px-1">
{dayOfWeek}, {selectedDateObj.getDate()}/ <h3 className="text-sm font-bold text-(--color-primary-dark)">
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()} {dayOfWeek}, {selectedDate.getDate()}/{selectedDate.getMonth() + 1}/
<span className="ml-2 text-xs font-normal text-(--color-text-muted)"> {selectedDate.getFullYear()}
({selectedShifts.length} shift </h3>
{selectedShifts.length !== 1 ? "s" : ""}) <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> </span>
</h3> </div>
{selectedShifts.length === 0 ? ( {selectedShifts.length === 0 ? (
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center"> <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-2xl text-gray-300"></i> <i className="fa-regular fa-calendar-xmark mb-3 text-3xl text-gray-200"></i>
<p className="text-sm text-(--color-text-muted)"> <p className="text-sm font-medium text-(--color-text-muted)">
No shifts for this day No shifts
</p>
<p className="mt-0.5 text-xs text-(--color-text-muted)">
No shifts created for this day
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2.5">
{DEPARTMENTS.map((dept) => { {selectedShifts.map((shift) => (
const deptShifts = selectedShifts.filter( <ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
(s) => s.department === dept.id, ))}
);
if (deptShifts.length === 0) return null;
return (
<div key={dept.id}>
<div className="mb-2 flex items-center gap-2">
<i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name}
</span>
</div>
<div className="space-y-2">
{deptShifts.map((shift) => (
<ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))}
</div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
@@ -7,7 +7,7 @@ import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; 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 y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0"); const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0"); const day = d.getDate().toString().padStart(2, "0");
@@ -27,7 +27,7 @@ export default function MonthlyCalendar({
onShiftClick, onShiftClick,
onDateSelect, onDateSelect,
}: MonthlyCalendarProps) { }: MonthlyCalendarProps) {
const { currentDate, shifts } = useShift(); const { currentDate, getShiftsForDate } = useShift();
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
@@ -35,50 +35,27 @@ export default function MonthlyCalendar({
const firstDay = new Date(year, month, 1); const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0); const lastDay = new Date(year, month + 1, 0);
// Monday = 0, Sunday = 6
let startOffset = firstDay.getDay() - 1; let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6; if (startOffset < 0) startOffset = 6;
const days: (Date | null)[] = []; const days: (Date | null)[] = [];
for (let i = 0; i < startOffset; i++) days.push(null);
// Fill leading empty cells for (let d = 1; d <= lastDay.getDate(); d++)
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)); days.push(new Date(year, month, d));
} while (days.length % 7 !== 0) days.push(null);
// Fill trailing empty cells to complete the grid
while (days.length % 7 !== 0) {
days.push(null);
}
return days; return days;
}, [currentDate]); }, [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 ( 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 */} {/* Day headers */}
<div className="grid grid-cols-7 border-b border-(--color-border-light) bg-gray-50"> <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) => ( {DAY_HEADERS.map((day, i) => (
<div <div
key={day} 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} {day}
</div> </div>
@@ -86,75 +63,60 @@ export default function MonthlyCalendar({
</div> </div>
{/* Calendar grid */} {/* Calendar grid */}
<div className="grid grid-cols-7"> <div className="grid grid-cols-7 bg-white">
{calendarDays.map((date, i) => { {calendarDays.map((date, i) => {
if (!date) { if (!date) {
return ( return (
<div <div
key={`empty-${i}`} 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 today = isToday(date);
const shifts = getShiftsForDate(date);
const shiftCount = shifts.length;
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return ( return (
<button <button
type="button" type="button"
key={i} key={i}
onClick={() => onDateSelect?.(formatDateISO(date))} onClick={() => onDateSelect?.(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 ${ 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)/5" : "" today ? "bg-(--color-primary)/8" : ""
}`} } ${isWeekend && !today ? "bg-rose-50/30" : ""}`}
> >
{/* Date number */}
<span <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 today
? "bg-(--color-primary) font-bold text-white" ? "bg-(--color-primary) text-white shadow-sm"
: "text-foreground font-medium" : isWeekend
? "text-rose-500"
: "text-(--color-text-secondary) group-hover:text-(--color-primary)"
}`} }`}
> >
{date.getDate()} {date.getDate()}
</span> </span>
{summary.total > 0 && ( {/* Shift count badge */}
<div className="mt-2 space-y-1"> {shiftCount > 0 && (
{summary.available > 0 && ( <div className="mt-1.5 space-y-1">
<div className="flex items-center gap-1.5"> <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)">
<span className="h-2 w-2 rounded-full bg-blue-400"></span> <i className="fa-solid fa-clock text-[8px]"></i>
<span className="text-[10px] text-blue-600"> {shiftCount} shifts
{summary.available} available </span>
</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>
)}
</div> </div>
)} )}
{/* Add indicator for manager */}
{shiftCount === 0 && onDateSelect && (
<p className="mt-1 text-[10px] text-(--color-text-muted) opacity-0 transition group-hover:opacity-100">
+ Add shift
</p>
)}
</button> </button>
); );
})} })}
@@ -4,8 +4,71 @@ import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context"; import { useShift } from "@/lib/shift-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { formatDateISO } from "./MonthlyCalendar";
import type { ShiftCreateModalProps } from "./ShiftSchedule.types"; import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
function TimeInput({
value,
onChange,
title,
}: {
value: string;
onChange: (v: string) => void;
title: string;
}) {
const [h24, m] = value.split(":").map(Number);
const isPM = h24 >= 12;
const hour12 = h24 === 0 ? 12 : h24 > 12 ? h24 - 12 : h24;
const emit = (newH12: number, newM: number, newIsPM: boolean) => {
let h = newIsPM
? newH12 === 12
? 12
: newH12 + 12
: newH12 === 12
? 0
: newH12;
onChange(
`${h.toString().padStart(2, "0")}:${newM.toString().padStart(2, "0")}`,
);
};
return (
<div className="flex items-center overflow-hidden rounded-xl border border-(--color-border-light) transition focus-within:border-(--color-primary) focus-within:ring-1 focus-within:ring-(--color-primary)">
<input
title={`${title} hour`}
type="number"
min={1}
max={12}
value={hour12}
onChange={(e) =>
emit(Math.min(12, Math.max(1, Number(e.target.value))), m, isPM)
}
className="text-foreground w-12 border-none bg-transparent py-2.5 pl-3 text-sm outline-none"
/>
<span className="text-sm text-(--color-text-muted) select-none">:</span>
<input
title={`${title} minute`}
type="number"
min={0}
max={59}
value={m.toString().padStart(2, "0")}
onChange={(e) =>
emit(hour12, Math.min(59, Math.max(0, Number(e.target.value))), isPM)
}
className="text-foreground w-10 border-none bg-transparent py-2.5 text-sm outline-none"
/>
<button
type="button"
onClick={() => emit(hour12, m, !isPM)}
className="mr-2 ml-1 cursor-pointer rounded-lg border-none bg-(--color-primary)/10 px-2 py-1 text-xs font-bold text-(--color-primary) transition hover:bg-(--color-primary) hover:text-white"
>
{isPM ? "PM" : "AM"}
</button>
</div>
);
}
export default function ShiftCreateModal({ export default function ShiftCreateModal({
isOpen, isOpen,
onClose, onClose,
@@ -13,7 +76,7 @@ export default function ShiftCreateModal({
}: ShiftCreateModalProps) { }: ShiftCreateModalProps) {
const { createShift } = useShift(); 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 [startTime, setStartTime] = useState("08:00");
const [endTime, setEndTime] = useState("12:00"); const [endTime, setEndTime] = useState("12:00");
const [department, setDepartment] = useState("bar"); const [department, setDepartment] = useState("bar");
@@ -23,7 +86,7 @@ export default function ShiftCreateModal({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setDate(defaultDate ?? "2026-04-10"); setDate(new Date(defaultDate ?? "2026-04-10"));
} }
}, [defaultDate, isOpen]); }, [defaultDate, isOpen]);
@@ -49,18 +112,16 @@ export default function ShiftCreateModal({
return; return;
} }
const durationHours = (endMinutes - startMinutes) / 60; const deptName =
DEPARTMENTS.find((d) => d.id === department)?.name ?? department;
createShift({ createShift({
name: `${deptName} - ${formatDateISO(date)}`,
date, date,
startTime, startTime,
endTime, endTime,
durationHours,
wage, wage,
department,
maxStaff, maxStaff,
registeredStaff: [],
status: "available",
}); });
onClose(); onClose();
@@ -103,8 +164,8 @@ export default function ShiftCreateModal({
<input <input
title="Date" title="Date"
type="date" type="date"
value={date} value={formatDateISO(date)}
onChange={(e) => setDate(e.target.value)} 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)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
@@ -115,24 +176,20 @@ export default function ShiftCreateModal({
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Start Time Start Time
</label> </label>
<input <TimeInput
title="Start Time" title="Start Time"
type="time"
value={startTime} value={startTime}
onChange={(e) => setStartTime(e.target.value)} onChange={setStartTime}
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
End Time End Time
</label> </label>
<input <TimeInput
title="End Time" title="End Time"
type="time"
value={endTime} value={endTime}
onChange={(e) => setEndTime(e.target.value)} onChange={setEndTime}
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
</div> </div>
@@ -140,7 +197,7 @@ export default function ShiftCreateModal({
{/* Department */} {/* Department */}
<div> <div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Bộ phận Department
</label> </label>
<select <select
title="Department" title="Department"
@@ -160,7 +217,7 @@ export default function ShiftCreateModal({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Số nhân viên tối đa Max staff
</label> </label>
<input <input
title="Max Staff" title="Max Staff"
@@ -174,7 +231,7 @@ export default function ShiftCreateModal({
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Lương ca (VND) Shift wage (VND)
</label> </label>
<input <input
title="Wage" title="Wage"
@@ -203,14 +260,14 @@ export default function ShiftCreateModal({
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90" 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> <i className="fa-solid fa-plus mr-2"></i>
Tạo ca làm Create shift
</button> </button>
<button <button
type="button" type="button"
onClick={onClose} 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" 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> </button>
</div> </div>
</form> </form>
@@ -3,10 +3,20 @@
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { DEPARTMENTS } from "@/lib/constants"; import { DEPARTMENTS } from "@/lib/constants";
import { useShift } from "@/lib/shift-context"; import { useShift } from "@/lib/shift-context";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { ShiftDetailModalProps } from "./ShiftSchedule.types"; 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({ export default function ShiftDetailModal({
shift, shift,
isOpen, isOpen,
@@ -19,23 +29,24 @@ export default function ShiftDetailModal({
if (!isOpen || !shift) return null; 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 isManager = user?.role === "manager";
const registeredStaff = shift.registeredStaff ?? [];
const isRegistered = user const isRegistered = user
? shift.registeredStaff.some((s) => s.id === user.id) ? registeredStaff.some((s) => s.id === user.id)
: false; : false;
const isFull = shift.registeredStaff.length >= shift.maxStaff; const isFull = registeredStaff.length >= shift.maxStaff;
const handleRegister = () => { const handleRegister = async () => {
if (!user) return; if (!user) return;
setError(null); setError(null);
setSuccess(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) { if (result.success) {
setSuccess("Đăng ký ca thành công!"); setSuccess("Shift registered successfully!");
setTimeout(onClose, 1200); setTimeout(onClose, 1200);
} else { } else {
setError(result.error ?? "Có lỗi xảy ra."); setError(result.error ?? "An error occurred.");
} }
}; };
@@ -43,25 +54,25 @@ export default function ShiftDetailModal({
if (!user) return; if (!user) return;
setError(null); setError(null);
unregisterShift(shift.id, user.id); unregisterShift(shift.id, user.id);
setSuccess("Đã hủy đăng ký ca."); setSuccess("Shift registration cancelled.");
setTimeout(onClose, 1200); setTimeout(onClose, 1200);
}; };
const handleManagerUnregister = (staffId: number) => { const handleManagerUnregister = (staffId: string) => {
unregisterShift(shift.id, staffId); unregisterShift(shift.id, staffId);
setSuccess("Đã xóa nhân viên khỏi ca."); setSuccess("Staff removed from shift.");
}; };
const handleDelete = () => { const handleDelete = async () => {
deleteShift(shift.id); await deleteShift(shift.id);
onClose(); onClose();
}; };
const statusLabel = { const statusLabel = {
available: "Còn trống", available: "Available",
registered: "Đã đăng ký", registered: "Registered",
approved_leave: "Nghỉ phép", approved_leave: "On leave",
absent: "Vắng mặt", absent: "Absent",
}; };
const statusColor = { const statusColor = {
@@ -71,6 +82,44 @@ export default function ShiftDetailModal({
absent: "bg-red-100 text-red-700", absent: "bg-red-100 text-red-700",
}; };
const StaffName = ({ staffId }: { staffId: string }) => {
const [name, setName] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Hàm gọi API
const getName = async () => {
try {
setLoading(true);
const response = await fetch(`/api/staff/${staffId}`);
if (!response.ok) {
throw new Error("Mạng lỗi hoặc không tìm thấy nhân viên");
}
const data = await response.json();
setName(data["name"]);
} catch (error) {
console.error("Lỗi khi lấy tên:", error);
setName("Lỗi tải tên");
} finally {
setLoading(false);
}
};
if (staffId) {
getName();
}
}, [staffId]); // Chạy lại nếu staffId thay đổi
if (loading)
return <span className="animate-pulse text-gray-400">...</span>;
return <span>{name || "N/A"}</span>;
};
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */} {/* Backdrop */}
@@ -81,12 +130,12 @@ export default function ShiftDetailModal({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4"> <div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>} {/* {dept && <i className={`${dept.icon} text-(--color-primary)`}></i>} */}
<div> <div>
<h2 className="text-foreground text-base font-bold"> <h2 className="text-foreground text-base font-bold">
Chi tiết ca làm Shift Details
</h2> </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>
</div> </div>
<button <button
@@ -102,35 +151,34 @@ export default function ShiftDetailModal({
{/* Body */} {/* Body */}
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
{/* Status badge */} {/* Status badge */}
<div className="flex items-center gap-2"> {/* <div className="flex items-center gap-2">
<span <span
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`} className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
> >
{statusLabel[shift.status]} {statusLabel[shift.status]}
</span> </span>
</div> </div> */}
{/* Shift info grid */} {/* Shift info grid */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase"> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Ngày Date
</p> </p>
<p className="text-foreground mt-1 text-sm font-bold"> <p className="text-foreground mt-1 text-sm font-bold">
{new Date(shift.date + "T00:00:00").toLocaleDateString( {parseShiftDate(
"vi-VN", shift.date as Date | string | undefined,
{ )?.toLocaleDateString("vi-VN", {
weekday: "long", weekday: "long",
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
}, }) ?? "—"}
)}
</p> </p>
</div> </div>
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase"> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Giờ làm Working hours
</p> </p>
<p className="text-foreground mt-1 text-sm font-bold"> <p className="text-foreground mt-1 text-sm font-bold">
{shift.startTime} {shift.endTime} {shift.startTime} {shift.endTime}
@@ -138,18 +186,16 @@ export default function ShiftDetailModal({
</div> </div>
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase"> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Thời lượng Duration
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.durationHours} giờ
</p> </p>
<p className="text-foreground mt-1 text-sm font-bold">{""} giờ</p>
</div> </div>
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase"> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Lương ca Shift wage
</p> </p>
<p className="mt-1 text-sm font-bold text-(--color-primary)"> <p className="mt-1 text-sm font-bold text-(--color-primary)">
{shift.wage.toLocaleString("vi-VN")} VND {(shift.wage ?? 0).toLocaleString("vi-VN")} VND
</p> </p>
</div> </div>
</div> </div>
@@ -157,16 +203,15 @@ export default function ShiftDetailModal({
{/* Registered staff */} {/* Registered staff */}
<div> <div>
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)"> <p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
Nhân viên đã đăng ({shift.registeredStaff.length}/ Registered staff ({registeredStaff.length}/{shift.maxStaff})
{shift.maxStaff})
</p> </p>
{shift.registeredStaff.length === 0 ? ( {registeredStaff.length === 0 ? (
<p className="text-xs text-(--color-text-muted) italic"> <p className="text-xs text-(--color-text-muted) italic">
Chưa ai đăng No staff registered yet
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{shift.registeredStaff.map((staff) => ( {registeredStaff.map((staff) => (
<div <div
key={staff.id} key={staff.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2" className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
@@ -176,7 +221,7 @@ export default function ShiftDetailModal({
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i> <i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
</div> </div>
<span className="text-foreground text-sm font-medium"> <span className="text-foreground text-sm font-medium">
{staff.name} <StaffName staffId={staff.staffId} />
</span> </span>
</div> </div>
{isManager && ( {isManager && (
@@ -186,7 +231,7 @@ export default function ShiftDetailModal({
className="cursor-pointer rounded-lg border-none bg-transparent px-2 py-1 text-xs text-red-500 transition hover:bg-red-50" 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> <i className="fa-solid fa-user-minus mr-1"></i>
Xóa Remove
</button> </button>
)} )}
</div> </div>
@@ -212,19 +257,16 @@ export default function ShiftDetailModal({
{/* Footer actions */} {/* Footer actions */}
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4"> <div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
{!isRegistered && {!isManager && !isRegistered && !isFull && (
!isFull && <button
shift.status !== "approved_leave" && type="button"
shift.status !== "absent" && ( onClick={handleRegister}
<button 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"
type="button" >
onClick={handleRegister} <i className="fa-solid fa-calendar-plus mr-2"></i>
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" Register
> </button>
<i className="fa-solid fa-calendar-plus mr-2"></i> )}
Đăng ca
</button>
)}
{isRegistered && ( {isRegistered && (
<button <button
type="button" type="button"
@@ -232,7 +274,7 @@ export default function ShiftDetailModal({
className="flex-1 cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50" 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> <i className="fa-solid fa-calendar-minus mr-2"></i>
Hủy đăng Unregister
</button> </button>
)} )}
{isManager && ( {isManager && (
@@ -242,7 +284,7 @@ export default function ShiftDetailModal({
className="cursor-pointer rounded-xl border border-red-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50" 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> <i className="fa-solid fa-trash mr-2"></i>
Xóa ca Delete shift
</button> </button>
)} )}
<button <button
@@ -250,7 +292,7 @@ export default function ShiftDetailModal({
onClick={onClose} 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" 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> </button>
</div> </div>
</div> </div>
@@ -1,22 +1,22 @@
import type { ShiftSlot } from "@/lib/types"; import type { ShiftEntity } from "@/lib/types";
export interface WeeklyScheduleProps { export interface WeeklyScheduleProps {
onShiftClick: (shift: ShiftSlot) => void; onShiftClick: (shift: ShiftEntity) => void;
onCreateShift?: (date: string) => void; onCreateShift?: (date: Date) => void;
mobileCalendarHeader?: boolean; mobileCalendarHeader?: boolean;
} }
export interface MonthlyCalendarProps { export interface MonthlyCalendarProps {
onShiftClick: (shift: ShiftSlot) => void; onShiftClick: (shift: ShiftEntity) => void;
onDateSelect?: (date: string) => void; onDateSelect?: (date: Date) => void;
} }
export interface MobileShiftViewProps { export interface MobileShiftViewProps {
onShiftClick: (shift: ShiftSlot) => void; onShiftClick: (shift: ShiftEntity) => void;
} }
export interface ShiftDetailModalProps { export interface ShiftDetailModalProps {
shift: ShiftSlot | null; shift: ShiftEntity | null;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
@@ -24,5 +24,5 @@ export interface ShiftDetailModalProps {
export interface ShiftCreateModalProps { export interface ShiftCreateModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
defaultDate?: string; defaultDate?: Date;
} }
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
import type { WeeklyScheduleProps } from "./ShiftSchedule.types"; import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
const MONTH_NAMES_EN = [ const MONTH_NAMES = [
"January", "January",
"February", "February",
"March", "March",
@@ -23,21 +23,13 @@ const MONTH_NAMES_EN = [
]; ];
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; 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 { function formatDateShort(d: Date): string {
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`; 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 { function isToday(d: Date): boolean {
const today = new Date(2026, 3, 10); const today = new Date(Date.now());
return ( return (
d.getDate() === today.getDate() && d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() && d.getMonth() === today.getMonth() &&
@@ -59,231 +51,233 @@ export default function WeeklySchedule({
} = useShift(); } = useShift();
const weekDates = getWeekDates(); const weekDates = getWeekDates();
const [selectedDate, setSelectedDate] = useState<string>( const [selectedDate, setSelectedDate] = useState<Date>(
formatDateISO(weekDates[0] ?? currentDate), weekDates[0] ?? currentDate,
); );
const statusDotsByDate = useMemo(() => { const selectedDateRef = useMemo(() => {
const map: Record<string, string[]> = {}; return (
weekDates.forEach((date) => { weekDates.find((d) => d === selectedDate) ?? weekDates[0] ?? currentDate
const dateStr = formatDateISO(date); );
const dayShifts = getShiftsForDate(dateStr);
const dots: string[] = [];
if (dayShifts.some((s) => s.status === "available"))
dots.push("bg-sky-300");
if (dayShifts.some((s) => s.status === "registered"))
dots.push("bg-blue-600");
if (dayShifts.some((s) => s.status === "approved_leave"))
dots.push("bg-purple-400");
if (dayShifts.some((s) => s.status === "absent"))
dots.push("bg-rose-400");
map[dateStr] = dots.slice(0, 3);
});
return map;
}, [weekDates, getShiftsForDate]);
const selectedDateObj = useMemo(() => {
const inWeek = weekDates.find((d) => formatDateISO(d) === selectedDate);
return inWeek ?? weekDates[0] ?? currentDate;
}, [selectedDate, weekDates, currentDate]); }, [selectedDate, weekDates, currentDate]);
const selectedDateStr = formatDateISO(selectedDateObj); // ── Mobile calendar header view ────────────────────────────────────────────
const renderMobileDayView = mobileCalendarHeader && (
<div className="space-y-3">
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
<div className="mb-3 flex items-center justify-between">
<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>
);
if (mobileCalendarHeader) { if (mobileCalendarHeader) {
return renderMobileDayView ?? null; const dayShifts = getShiftsForDate(new Date(selectedDateRef));
}
return ( return (
<div className="overflow-x-auto"> <div className="space-y-4">
<table className="w-full min-w-225 border-collapse"> {/* Week strip */}
<thead> <div className="overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
<tr> <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">
<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"> <button
Department title="Previous week"
</th> type="button"
{weekDates.map((date, i) => ( onClick={goToPrevWeek}
<th 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)"
key={i} >
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${ <i className="fa-solid fa-chevron-left text-xs"></i>
isToday(date) </button>
? "bg-(--color-primary)/10 font-bold text-(--color-primary)" <h3 className="text-sm font-bold text-(--color-primary-dark)">
: "bg-gray-50 font-semibold text-(--color-text-muted)" {MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
}`} </h3>
> <button
<span className="block uppercase">{DAY_LABELS[i]}</span> title="Next week"
<span className="mt-0.5 block text-[11px] font-normal"> type="button"
{formatDateShort(date)} onClick={goToNextWeek}
</span> 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)"
</th> >
))} <i className="fa-solid fa-chevron-right text-xs"></i>
</tr> </button>
</thead> </div>
<tbody> <div className="grid grid-cols-7 gap-1 p-3">
{DEPARTMENTS.map((dept) => ( {weekDates.map((date, i) => {
<tr key={dept.id}> const active = date === selectedDateRef;
<td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top"> const today = isToday(new Date(date));
<div className="flex items-center gap-2"> const shifts = getShiftsForDate(new Date(date));
<i return (
className={`${dept.icon} text-xs text-(--color-primary)`} <button
></i> key={i}
<span className="text-xs font-semibold text-(--color-text-secondary)"> type="button"
{dept.name} 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>
</div> <span
</td> className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold transition ${
{weekDates.map((date, i) => { today
const dateStr = formatDateISO(date); ? "bg-(--color-primary) text-white shadow-sm"
const shifts = getShiftsForDate(dateStr).filter( : active
(s) => s.department === dept.id, ? "bg-(--color-primary)/15 text-(--color-primary)"
); : i >= 5
return ( ? "text-rose-500"
<td : "text-(--color-text-secondary)"
key={i}
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
isToday(date) ? "bg-(--color-primary)/5" : ""
}`} }`}
> >
<div className="flex min-h-20 flex-col gap-1"> {new Date(date).getDate()}
{shifts.map((shift) => ( </span>
<ShiftCard <div className="flex min-h-2 items-center gap-px">
key={shift.id} {shifts.length > 0 && (
shift={shift} <span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)/60" />
compact )}
onClick={onShiftClick} </div>
/> </button>
))} );
{onCreateShift && ( })}
<button </div>
type="button" </div>
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)" {/* Day shifts */}
> <div className="space-y-3">
<i className="fa-solid fa-plus mr-1"></i> <div className="flex items-center justify-between px-1">
Add shift <h3 className="text-sm font-bold text-(--color-primary-dark)">
</button> {DAY_LABELS[(new Date(selectedDateRef).getDay() + 6) % 7]},{" "}
)} {formatDateShort(new Date(selectedDateRef))}
</div> </h3>
</td> <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-hidden rounded-2xl border border-(--color-border-light) shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] border-collapse">
<thead>
<tr>
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50/80 px-4 py-3 text-left text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Department
</th>
{weekDates.map((date, i) => {
const today = isToday(new Date(date));
return (
<th
key={i}
className={`border-r border-b border-(--color-border-light) px-3 py-3 text-center text-xs transition ${
today
? "bg-gradient-to-b from-(--color-primary)/15 to-(--color-primary)/5 font-bold text-(--color-primary)"
: "bg-gray-50/80 font-semibold text-(--color-text-muted)"
}`}
>
<span className="block text-[11px] font-semibold tracking-wider uppercase">
{DAY_LABELS[i]}
</span>
<span
className={`mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-bold ${
today
? "bg-(--color-primary) text-white shadow-sm"
: "text-(--color-text-secondary)"
}`}
>
{date.getDate()}
</span>
<span
className={`mt-0.5 block text-[10px] font-normal ${today ? "text-(--color-primary)/70" : "text-(--color-text-muted)"}`}
>
{(date.getMonth() + 1).toString().padStart(2, "0")}/
{date.getFullYear()}
</span>
</th>
); );
})} })}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {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> </div>
); );
} }
+40 -40
View File
@@ -1,46 +1,46 @@
"use client"; "use client";
import { ShopCard } from "@/components/molecules/cards"; // import { ShopCard } from "@/components/molecules/cards";
import { MOCK_SHOPS } from "@/lib/constants"; // import { MOCK_SHOPS } from "@/lib/constants";
import type { ShopGridProps } from "./ShopGrid.types"; // import type { ShopGridProps } from "./ShopGrid.types";
export default function ShopGrid({ // export default function ShopGrid({
searchName = "", // searchName = "",
searchAddress = "", // searchAddress = "",
}: ShopGridProps) { // }: ShopGridProps) {
const filtered = MOCK_SHOPS.filter((shop) => { // const filtered = MOCK_SHOPS.filter((shop) => {
const matchesName = // const matchesName =
searchName.trim() === "" || // searchName.trim() === "" ||
shop.name.toLowerCase().includes(searchName.toLowerCase()); // shop.name.toLowerCase().includes(searchName.toLowerCase());
const matchesAddress = // const matchesAddress =
searchAddress.trim() === "" || // searchAddress.trim() === "" ||
shop.address.toLowerCase().includes(searchAddress.toLowerCase()); // shop.address.toLowerCase().includes(searchAddress.toLowerCase());
return matchesName && matchesAddress; // return matchesName && matchesAddress;
}); // });
if (filtered.length === 0) { // if (filtered.length === 0) {
return ( // return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)"> // <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> // <i className="fa-solid fa-store text-5xl opacity-30"></i>
<p className="text-base font-medium"> // <p className="text-base font-medium">
No shops found matching your search // No shops found matching your search
</p> // </p>
</div> // </div>
); // );
} // }
return ( // return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> // <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((shop) => ( // {filtered.map((shop) => (
<ShopCard // <ShopCard
key={shop.id} // key={shop.id}
id={shop.id} // id={shop.id}
name={shop.name} // name={shop.name}
address={shop.address} // address={shop.address}
image={shop.image} // image={shop.image}
/> // />
))} // ))}
</div> // </div>
); // );
} // }
+1 -1
View File
@@ -1,2 +1,2 @@
export { default as ShopGrid } from "./ShopGrid"; // export { default as ShopGrid } from "./ShopGrid";
export type { ShopGridProps } from "./ShopGrid.types"; export type { ShopGridProps } from "./ShopGrid.types";
@@ -1,6 +1,7 @@
import { CartFab } from "@/components/organisms/cart"; import { CartFab } from "@/components/organisms/cart";
import Footer from "@/layouts/footer"; import Footer from "@/layouts/footer";
import Header from "@/layouts/header"; import Header from "@/layouts/header";
import { ManagerProvider } from "@/lib/manager-context";
import type { MainLayoutProps } from "./MainLayout.types"; import type { MainLayoutProps } from "./MainLayout.types";
@@ -12,7 +13,9 @@ export default function MainLayout({ children }: MainLayoutProps) {
return ( return (
<> <>
{/* Sticky top header */} {/* Sticky top header */}
<Header /> <ManagerProvider>
<Header />
</ManagerProvider>
{/* Page content (grows to fill remaining height) */} {/* Page content (grows to fill remaining height) */}
<div className="flex-1">{children}</div> <div className="flex-1">{children}</div>
+1 -1
View File
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
# Cài đặt pnpm # 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 file định nghĩa package để tận dụng cache của Docker
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec: spec:
containers: containers:
- name: frontend-container - name: frontend-container
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.4 image: git.demonkernel.io.vn/foodsurf/frontend:1.2.13
ports: ports:
- containerPort: 3000 - containerPort: 3000
resources: resources:
+4 -2
View File
@@ -2,6 +2,7 @@
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants"; import { SHOP_INFO } from "@/lib/constants";
import { useManager } from "@/lib/manager-context";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -25,6 +26,7 @@ import { useRouter } from "next/navigation";
*/ */
export default function Header() { export default function Header() {
const router = useRouter(); const router = useRouter();
const { eatery } = useManager();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const handleAuthClick = () => { const handleAuthClick = () => {
@@ -47,7 +49,7 @@ export default function Header() {
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11"> <div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
<Image <Image
src={SHOP_INFO.logo} src={SHOP_INFO.logo}
alt={`Logo ${SHOP_INFO.name}`} alt={`${eatery?.name} logo`}
fill fill
className="object-contain transition-transform duration-200 group-hover:scale-105" className="object-contain transition-transform duration-200 group-hover:scale-105"
sizes="44px" sizes="44px"
@@ -58,7 +60,7 @@ export default function Header() {
{/* Name + tagline */} {/* Name + tagline */}
<div className="flex flex-col leading-tight"> <div className="flex flex-col leading-tight">
<span className="text-base font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-lg"> <span className="text-base font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-lg">
{SHOP_INFO.name} {eatery?.name}
</span> </span>
<span className="hidden text-xs text-(--color-text-muted) md:block"> <span className="hidden text-xs text-(--color-text-muted) md:block">
{SHOP_INFO.tagline} {SHOP_INFO.tagline}
+174 -83
View File
@@ -1,131 +1,222 @@
"use client"; "use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { CartItemEntity, MenuItemEntity } from "./types"; import { cartClient, eateryClient } from "./apollo-clients";
import {
CartEntity,
CartItemEntity,
addMenuItemMutation,
allEateriesQuery,
createCartMutation,
getCartQuery,
} from "./types";
interface CartContextValue { interface CartContextValue {
items: CartItemEntity[]; items: CartItemEntity[];
totalItems: number; totalItems: number;
totalPrice: number; totalPrice: number;
addToCart: (product: MenuItemEntity) => void; cart: CartEntity;
eateryId: string | undefined;
addToCart: (product: CartItemEntity) => void;
increaseQty: (id: string) => void; increaseQty: (id: string) => void;
decreaseQty: (id: string) => void; decreaseQty: (id: string) => void;
removeFromCart: (id: string) => void; removeFromCart: (id: string) => void;
setQuantity: (id: string, quantity: number) => void; setQuantity: (id: string, quantity: number) => void;
removeCart: () => void;
} }
const STORAGE_KEY = "coffee-shop-cart"; const CART_ID = "cartId";
const CartContext = createContext<CartContextValue | null>(null); const 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 }) { export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItemEntity[]>([]); 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(() => { useEffect(() => {
try { const createCartFunc = async () => {
const raw = localStorage.getItem(STORAGE_KEY); if (eateryData && eateryData.allEateries?.length > 0) {
if (!raw) return; try {
const parsed = JSON.parse(raw) as CartItemEntity[]; const firstEateryId = eateryData.allEateries[0].id;
if (Array.isArray(parsed)) { const { data: mutationResult } = await createCart({
setItems(parsed.filter((i) => i && i.productId && i.quantity > 0)); variables: { eateryId: firstEateryId },
});
const newCartId = mutationResult!.createCart;
if (newCartId) {
localStorage.setItem(CART_ID, newCartId);
setCartId(newCartId);
}
} catch (err) {
console.error("Lỗi khi tạo giỏ hàng:", err);
localStorage.removeItem(CART_ID);
}
} }
} 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(() => { useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); if (data?.getCart) setCart(data.getCart);
}, [items]); }, [data]);
const addToCart = (product: MenuItemEntity) => { const addToCart = async (product: CartItemEntity) => {
setItems((prev) => { if (!cartId) return;
const index = prev.findIndex((i) => i.productId === product.id);
if (index === -1) {
return [
...prev,
{
productId: product.id!,
name: product.name,
description: product.description,
priceAtTimeOfAdding: product.price,
quantity: 1,
},
];
}
const next = [...prev]; const { data: result } = await addMenuItem({
next[index] = { ...next[index], quantity: next[index].quantity + 1 }; variables: {
return next; cartId,
menuItemId: product.productId!,
quantity: product.quantity,
},
}); });
if (result) setCart(result.addItem);
}; };
const increaseQty = (id: string) => { const DELETE_CART = gql`
setItems((prev) => mutation deleteCart($cartId: String!) {
prev.map((item) => deleteCart(cartId: $cartId)
item.productId === id ? { ...item, quantity: item.quantity + 1 } : item,
),
);
};
const decreaseQty = (id: string) => {
setItems((prev) =>
prev
.map((item) =>
item.productId === id
? { ...item, quantity: Math.max(0, item.quantity - 1) }
: item,
)
.filter((item) => item.quantity > 0),
);
};
const removeFromCart = (id: string) => {
setItems((prev) => prev.filter((item) => item.productId !== id));
};
const setQuantity = (id: string, quantity: number) => {
const safeQty = Number.isFinite(quantity)
? Math.max(0, Math.floor(quantity))
: 0;
if (safeQty === 0) {
removeFromCart(id);
return;
} }
`;
setItems((prev) => const [mutateDeleteCart] = useMutation(DELETE_CART, {
prev.map((item) => client: cartClient,
item.productId === id ? { ...item, quantity: safeQty } : item, });
),
); const removeCart = async () => {
localStorage.removeItem(CART_ID);
await mutateDeleteCart({ variables: { cartId } });
setCartId(null);
setCart(null!);
}; };
const setQuantity = async (id: string, newQuantity: number) => {
if (!cartId) return;
const currentItem = cart.items.find((i) => i.productId == id);
const { data: result } = await addMenuItem({
variables: {
cartId,
menuItemId: id,
quantity: newQuantity - currentItem!.quantity,
},
});
if (result) setCart(result.addItem);
};
const removeFromCart = (id: string) => setQuantity(id, 0);
const increaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity + 1);
const decreaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity - 1);
const totalItems = useMemo( const totalItems = useMemo(
() => items.reduce((sum, item) => sum + item.quantity, 0), () => cart?.items.reduce((sum, item) => sum + item.quantity, 0),
[items], [cart],
);
const totalPrice = useMemo(
() =>
items.reduce(
(sum, item) => sum + item.priceAtTimeOfAdding * item.quantity,
0,
),
[items],
); );
const value = useMemo( const value = useMemo(
() => ({ () => ({
items, items: cart?.items,
totalItems, totalItems,
totalPrice, totalPrice: cart?.totalAmount,
eateryId: cart?.eateryId,
addToCart, addToCart,
increaseQty, increaseQty,
decreaseQty, decreaseQty,
removeFromCart, removeFromCart,
setQuantity, setQuantity,
removeCart,
cart,
}), }),
[items, totalItems, totalPrice], [cart?.items, cart?.totalAmount, cart?.eateryId],
); );
return <CartContext.Provider value={value}>{children}</CartContext.Provider>; return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+17 -246
View File
@@ -1,13 +1,9 @@
import type { import type {
Department, Department,
MenuItemEntity,
ProductSalesStats,
RevenueDataPoint, RevenueDataPoint,
ShiftSlot, ShiftEntity,
Shop,
ShopInfo, ShopInfo,
SocialLinks, SocialLinks,
User,
} from "./types"; } from "./types";
// ===== SHOP INFORMATION ===== // ===== SHOP INFORMATION =====
@@ -33,228 +29,6 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/", website: "/",
}; };
// ===== MOCK PRODUCTS =====
// Placeholder data replace with real API calls when backend is ready
export const MOCK_PRODUCTS: MenuItemEntity[] = [
{
id: "1",
name: "Black Coffee",
price: 25000,
imageUrl: "/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",
price: 30000,
imageUrl: "/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",
price: 32000,
imageUrl: "/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",
price: 45000,
imageUrl: "/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",
price: 35000,
imageUrl: "/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",
price: 40000,
imageUrl: "/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",
price: 38000,
imageUrl: "/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",
price: 38000,
imageUrl: "/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",
price: 40000,
imageUrl: "/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",
price: 35000,
imageUrl: "/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",
price: 30000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Ice-cold watermelon juice — instantly refreshing on hot summer days.",
available: true,
},
{
id: "12",
name: "Caramel Latte",
price: 45000,
imageUrl: "/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",
price: 45000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
available: true,
},
{
id: "14",
name: "Toasted Butter Bread",
price: 20000,
imageUrl: "/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",
price: 25000,
imageUrl: "/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",
price: 10000,
imageUrl: "/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",
price: 10000,
imageUrl: "/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",
price: 10000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
available: true,
},
];
// ===== 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 ===== // ===== MOCK FINANCIAL DATA =====
// Daily revenue for the last 30 days (current month) // Daily revenue for the last 30 days (current month)
@@ -336,9 +110,9 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
* Generate mock shift slots for the weeks around today (April 2026). * Generate mock shift slots for the weeks around today (April 2026).
* Covers Mon 6 Apr Sun 26 Apr 2026. * Covers Mon 6 Apr Sun 26 Apr 2026.
*/ */
function generateMockShifts(): ShiftSlot[] { function generateMockShifts(): ShiftEntity[] {
const shifts: ShiftSlot[] = []; const shifts: ShiftEntity[] = [];
const departments = ["bar", "kitchen", "cashier", "janitor"]; const departments = ["waiter"];
const timeSlots = [ const timeSlots = [
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 }, { start: "07:00", end: "11:00", hours: 4, wage: 120000 },
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 }, { start: "11:00", end: "15:00", hours: 4, wage: 120000 },
@@ -372,7 +146,7 @@ function generateMockShifts(): ShiftSlot[] {
// Determine registration status // Determine registration status
const registered: { id: number; name: string }[] = []; const registered: { id: number; name: string }[] = [];
let status: ShiftSlot["status"] = "available"; // let status: ShiftEntity["status"] = "available";
// Past shifts (before April 10) are mostly registered // Past shifts (before April 10) are mostly registered
if (day < 10) { if (day < 10) {
@@ -381,37 +155,37 @@ function generateMockShifts(): ShiftSlot[] {
if (shiftCounter % 7 === 0) { if (shiftCounter % 7 === 0) {
registered.push(staffPool[(staffIdx + 1) % staffPool.length]); registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
} }
status = "registered"; // status = "registered";
// Some past shifts have leave/absent // Some past shifts have leave/absent
if (shiftCounter % 11 === 0) status = "approved_leave"; // if (shiftCounter % 11 === 0) status = "approved_leave";
if (shiftCounter % 13 === 0) status = "absent"; // if (shiftCounter % 13 === 0) status = "absent";
} }
// Current week (April 6-12): mix of registered and available // Current week (April 6-12): mix of registered and available
else if (day >= 10 && day <= 12) { else if (day >= 10 && day <= 12) {
if (shiftCounter % 3 === 0) { if (shiftCounter % 3 === 0) {
registered.push(staffPool[shiftCounter % staffPool.length]); registered.push(staffPool[shiftCounter % staffPool.length]);
status = "registered"; // status = "registered";
} }
} }
// Future shifts: mostly available, some registered // Future shifts: mostly available, some registered
else { else {
if (shiftCounter % 5 === 0) { if (shiftCounter % 5 === 0) {
registered.push(staffPool[shiftCounter % staffPool.length]); registered.push(staffPool[shiftCounter % staffPool.length]);
status = "registered"; // status = "registered";
} }
} }
shifts.push({ shifts.push({
id: "shiftId", id: "shiftId",
date: dateStr, date: new Date(dateStr),
startTime: slot.start, startTime: slot.start,
endTime: slot.end, endTime: slot.end,
durationHours: slot.hours, // durationHours: slot.hours,
wage: slot.wage, wage: slot.wage,
department: dept, // department: dept,
maxStaff: dept === "bar" ? 3 : 2, maxStaff: dept === "bar" ? 3 : 2,
registeredStaff: registered, // registeredStaff: registered,
status, // status,
}); });
} }
} }
@@ -420,11 +194,8 @@ function generateMockShifts(): ShiftSlot[] {
return shifts; return shifts;
} }
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts(); export const MOCK_SHIFT_SLOTS: ShiftEntity[] = generateMockShifts();
export const DEPARTMENTS: Department[] = [ export const DEPARTMENTS: Department[] = [
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" }, { id: "waiter", name: "Bar Staff", icon: "fa-brands fa-jenkins" },
{ 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" },
]; ];
+32 -2
View File
@@ -12,6 +12,7 @@ import {
import { eateryClient } from "./apollo-clients"; import { eateryClient } from "./apollo-clients";
import { import {
EateryEntity,
type MenuItemEntity, type MenuItemEntity,
type addMenuItemMutation, type addMenuItemMutation,
type allEateriesQuery, type allEateriesQuery,
@@ -21,11 +22,13 @@ import {
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export type ManagerTab = "products"; export type ManagerTab = "products" | "reviews";
interface ManagerContextType { interface ManagerContextType {
// Data // Data
products: MenuItemEntity[]; products: MenuItemEntity[];
eateryId: string | null;
eatery?: EateryEntity;
// Active tab // Active tab
activeTab: ManagerTab; activeTab: ManagerTab;
@@ -48,9 +51,11 @@ const GET_EATERY_MENU = gql`
query GetEateryMenu { query GetEateryMenu {
allEateries { allEateries {
id id
name
menuItems { menuItems {
id id
name name
imageUrl
available available
description description
price price
@@ -64,6 +69,7 @@ const ADD_MENU_ITEM = gql`
addMenuItem(menuItem: $menuItem) { addMenuItem(menuItem: $menuItem) {
id id
name name
imageUrl
available available
description description
price price
@@ -76,6 +82,7 @@ const UPDATE_MENU_ITEM = gql`
updateMenuItem(menuItem: $menuItem) { updateMenuItem(menuItem: $menuItem) {
id id
name name
imageUrl
available available
description description
price price
@@ -93,6 +100,7 @@ const DELETE_MENU_ITEM = gql`
export function ManagerProvider({ children }: { children: ReactNode }) { export function ManagerProvider({ children }: { children: ReactNode }) {
const [products, setProducts] = useState<MenuItemEntity[]>([]); const [products, setProducts] = useState<MenuItemEntity[]>([]);
const [eatery, setEatery] = useState<EateryEntity>();
const [activeTab, setActiveTab] = useState<ManagerTab>("products"); const [activeTab, setActiveTab] = useState<ManagerTab>("products");
const { data } = useQuery<allEateriesQuery>(GET_EATERY_MENU, { const { data } = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
@@ -100,6 +108,8 @@ export function ManagerProvider({ children }: { children: ReactNode }) {
fetchPolicy: "network-only", fetchPolicy: "network-only",
}); });
const eateryId = data?.allEateries?.[0]?.id ?? null;
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, { const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
client: eateryClient, client: eateryClient,
}); });
@@ -117,6 +127,7 @@ export function ManagerProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (data?.allEateries?.[0]) { if (data?.allEateries?.[0]) {
setProducts(data.allEateries[0].menuItems); setProducts(data.allEateries[0].menuItems);
setEatery(data.allEateries[0]);
} }
}, [data]); }, [data]);
@@ -129,7 +140,24 @@ export function ManagerProvider({ children }: { children: ReactNode }) {
}, },
}); });
if (data) setProducts((prev) => [...prev, data.addMenuItem]); 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 = async (product: MenuItemEntity) => { const updateProduct = async (product: MenuItemEntity) => {
@@ -170,7 +198,9 @@ export function ManagerProvider({ children }: { children: ReactNode }) {
return ( return (
<ManagerContext.Provider <ManagerContext.Provider
value={{ value={{
eatery,
products, products,
eateryId,
activeTab, activeTab,
setActiveTab, setActiveTab,
addProduct, addProduct,
+202 -66
View File
@@ -1,15 +1,22 @@
"use client"; "use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { import {
ReactNode, ReactNode,
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useState, useState,
} from "react"; } from "react";
import { MOCK_SHIFT_SLOTS } from "./constants"; import { eateryClient } from "./apollo-clients";
import type { ShiftSlot, ShiftStatus } from "./types"; import {
type ShiftEntity,
type allShiftsQuery,
createShiftMutation,
} from "./types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -17,7 +24,7 @@ export type ScheduleView = "week" | "month";
interface ShiftContextType { interface ShiftContextType {
// Data // Data
shifts: ShiftSlot[]; shifts: ShiftEntity[];
view: ScheduleView; view: ScheduleView;
setView: (view: ScheduleView) => void; setView: (view: ScheduleView) => void;
@@ -32,23 +39,22 @@ interface ShiftContextType {
// Shift actions // Shift actions
registerShift: ( registerShift: (
shiftId: string, shiftId: string,
staffId: number, staffId: string,
staffName: string, staffName: string,
) => { success: boolean; error?: string }; ) => Promise<{ success: boolean; error?: string }>;
unregisterShift: (shiftId: string, staffId: number) => void; unregisterShift: (shiftId: string, staffId: string) => void;
createShift: (shift: Omit<ShiftSlot, "id">) => void; createShift: (shift: Omit<ShiftEntity, "id">) => void;
updateShift: (shift: ShiftSlot) => void; deleteShift: (shiftId: string) => Promise<void>;
deleteShift: (shiftId: string) => void;
// Helpers // Helpers
getShiftsForDate: (date: string) => ShiftSlot[]; getShiftsForDate: (date: Date) => ShiftEntity[];
getShiftsForWeek: (weekStart: Date) => ShiftSlot[]; getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
getWeekDates: () => Date[]; getWeekDates: () => Date[];
hasConflict: ( hasConflict: (
date: string, date: Date | string,
startTime: string, startTime: string,
endTime: string, endTime: string,
staffId: number, staffId: string,
excludeShiftId?: string, excludeShiftId?: string,
) => boolean; ) => boolean;
getWeeklyBudget: () => number; getWeeklyBudget: () => number;
@@ -76,6 +82,21 @@ function formatDate(d: Date): string {
return `${y}-${m}-${day}`; 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 { function timeToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number); const [h, m] = time.split(":").map(Number);
return h * 60 + m; return h * 60 + m;
@@ -94,13 +115,84 @@ function timesOverlap(
return s1 < e2 && s2 < e1; 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 ───────────────────────────────────────────────────────────────── // ─── Provider ─────────────────────────────────────────────────────────────────
export function ShiftProvider({ children }: { children: ReactNode }) { 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 [view, setView] = useState<ScheduleView>("week");
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026 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 ────────────────────────────────────────────────────────── // ── Navigation ──────────────────────────────────────────────────────────
const goToNextWeek = useCallback(() => { const goToNextWeek = useCallback(() => {
@@ -151,37 +243,40 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
}, [currentDate]); }, [currentDate]);
const getShiftsForDate = useCallback( const getShiftsForDate = useCallback(
(date: string): ShiftSlot[] => { (date: Date): ShiftEntity[] => {
return shifts.filter((s) => s.date === date); const key = formatDate(date);
return shifts.filter((s) => dateKey(s.date) === key);
}, },
[shifts], [shifts],
); );
const getShiftsForWeek = useCallback( const getShiftsForWeek = useCallback(
(weekStart: Date): ShiftSlot[] => { (weekStart: Date): ShiftEntity[] => {
const dates = Array.from({ length: 7 }, (_, i) => { const dates = Array.from({ length: 7 }, (_, i) => {
const d = new Date(weekStart); const d = new Date(weekStart);
d.setDate(weekStart.getDate() + i); 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], [shifts],
); );
const hasConflict = useCallback( const hasConflict = useCallback(
( (
date: string, date: Date | string,
startTime: string, startTime: string,
endTime: string, endTime: string,
staffId: number, staffId: string,
excludeShiftId?: string, excludeShiftId?: string,
): boolean => { ): boolean => {
const key = dateKey(date);
return shifts.some( return shifts.some(
(s) => (s) =>
s.date === date && dateKey(s.date) === key &&
s.id !== excludeShiftId && s.id !== excludeShiftId &&
s.registeredStaff.some((rs) => rs.id === staffId) && (s.registeredStaff ?? []).some((rs) => rs.id === staffId) &&
timesOverlap(startTime, endTime, s.startTime, s.endTime), timesOverlap(startTime, endTime, s.startTime, s.endTime),
); );
}, },
@@ -189,32 +284,40 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
); );
const getWeeklyBudget = useCallback((): number => { const getWeeklyBudget = useCallback((): number => {
const weekDates = getWeekDates().map(formatDate); const weekDates = getWeekDates();
const weekKeys = new Set(weekDates.map(formatDate));
return shifts return shifts
.filter((s) => weekDates.includes(s.date) && s.registeredStaff.length > 0) .filter(
.reduce((sum, s) => sum + s.wage * s.registeredStaff.length, 0); (s) =>
weekKeys.has(dateKey(s.date)) && (s.registeredStaff ?? []).length > 0,
)
.reduce(
(sum, s) => sum + (s.wage ?? 0) * (s.registeredStaff ?? []).length,
0,
);
}, [shifts, getWeekDates]); }, [shifts, getWeekDates]);
// ── Shift actions ─────────────────────────────────────────────────────── // ── Shift actions ───────────────────────────────────────────────────────
const registerShift = useCallback( const registerShift = useCallback(
( async (
shiftId: string, shiftId: string,
staffId: number, staffId: string,
staffName: string, staffName: string,
): { success: boolean; error?: string } => { ): Promise<{ success: boolean; error?: string }> => {
const shift = shifts.find((s) => s.id === shiftId); 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) 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." }; 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." }; return { success: false, error: "Bạn đã đăng ký ca này rồi." };
} }
if ( if (
shift.date &&
hasConflict( hasConflict(
shift.date, shift.date,
shift.startTime, shift.startTime,
@@ -229,56 +332,90 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
}; };
} }
setShifts((prev) => try {
prev.map((s) => await mutateRegisterShift({
s.id === shiftId variables: { RegisterShiftInput: { shiftId } },
? { });
...s, refetch();
registeredStaff: [ return { success: true };
...s.registeredStaff, } catch (err: unknown) {
{ id: staffId, name: staffName }, const message =
], err instanceof Error ? err.message : "Đăng ký thất bại, thử lại sau.";
status: "registered" as ShiftStatus, return { success: false, error: message };
} }
: s,
),
);
return { success: true };
}, },
[shifts, hasConflict], [shifts, hasConflict, mutateRegisterShift, refetch],
); );
const unregisterShift = useCallback((shiftId: string, staffId: number) => { const unregisterShift = useCallback((shiftId: string, staffId: string) => {
setShifts((prev) => setShifts((prev) =>
prev.map((s) => { prev.map((s) => {
if (s.id !== shiftId) return 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 { return {
...s, ...s,
registeredStaff: updated, registeredStaff: updated,
status:
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
}; };
}), }),
); );
}, []); }, []);
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => { const createShift = useCallback(
const newShift: ShiftSlot = { async (shift: Omit<ShiftEntity, "id">) => {
...shift, try {
id: `shift_${Date.now()}`, const { data } = await mutateCreateShift({
}; variables: {
setShifts((prev) => [...prev, newShift]); shiftInput: {
}, []); date: toLocalDateISO(shift.date ?? new Date()),
startTime: shift.startTime,
endTime: shift.endTime,
maxStaff: shift.maxStaff,
wage: shift.wage,
},
},
});
const updateShift = useCallback((shift: ShiftSlot) => { if (data) {
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s))); // Optimistic: dùng date từ server nếu có, fallback sang local
}, []); const serverDate = data.createShift.date
? new Date(data.createShift.date as unknown as string)
: shift.date;
setShifts((prev) => [
...prev,
{
...data.createShift,
date: serverDate,
wage: data.createShift.wage ?? shift.wage,
registeredStaff: [],
},
]);
// Refetch để đồng bộ với server
refetch();
}
} catch (err) {
console.error("[createShift] mutation failed:", err);
}
},
[mutateCreateShift, refetch],
);
const deleteShift = useCallback((shiftId: string) => { const deleteShift = useCallback(
setShifts((prev) => prev.filter((s) => s.id !== shiftId)); 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 ( return (
<ShiftContext.Provider <ShiftContext.Provider
@@ -295,7 +432,6 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
registerShift, registerShift,
unregisterShift, unregisterShift,
createShift, createShift,
updateShift,
deleteShift, deleteShift,
getShiftsForDate, getShiftsForDate,
getShiftsForWeek, getShiftsForWeek,
+42 -17
View File
@@ -2,7 +2,7 @@
export type UserRole = "manager" | "staff" | "customer"; export type UserRole = "manager" | "staff" | "customer";
export interface User { export interface User {
id: number; id: string;
name: string; name: string;
role: UserRole; role: UserRole;
avatar: string | null; avatar: string | null;
@@ -88,28 +88,27 @@ export type ShiftStatus =
| "approved_leave" | "approved_leave"
| "absent"; | "absent";
export interface RegisteredStaff { export interface ShiftRegistrationEntity {
id: number; id: string;
name: string; staffId: string;
shift: ShiftEntity;
} }
export interface ShiftSlot { export interface ShiftEntity {
id: string; id: string;
date: string; // ISO date string "YYYY-MM-DD" name?: string;
startTime: string; // "HH:mm" date?: Date;
endTime: string; // "HH:mm" startTime: string;
durationHours: number; endTime: string;
wage: number; // VND per shift wage?: number;
department: string;
maxStaff: number; maxStaff: number;
registeredStaff: RegisteredStaff[]; registeredStaff?: ShiftRegistrationEntity[];
status: ShiftStatus;
} }
export interface Department { export interface Department {
id: string; id: string;
name: string; name: string;
icon: string; // FontAwesome class icon: string;
} }
export interface MenuItemEntity { export interface MenuItemEntity {
@@ -122,9 +121,8 @@ export interface MenuItemEntity {
description: string; description: string;
} }
export interface ShiftEntity {}
export interface EateryEntity { export interface EateryEntity {
id: string;
ownerId: string; ownerId: string;
name: string; name: string;
menuItems: MenuItemEntity[]; menuItems: MenuItemEntity[];
@@ -150,10 +148,37 @@ export interface deleteMenuItemMutation {
export interface CartItemEntity { export interface CartItemEntity {
productId: string; productId: string;
quantity: number; quantity: number;
priceAtTimeOfAdding: number; priceAtTimeOfAdding?: number;
} }
export interface CartEntity { export interface CartEntity {
Id: string; Id: string;
eateryId: string;
items: CartItemEntity[]; 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;
} }
+1 -1
View File
@@ -20,7 +20,7 @@ const nextConfig: NextConfig = {
], ],
}, },
async rewrites() { async rewrites() {
const gatewayUrl = "http://host.docker.internal:32080"; const gatewayUrl = "http://172.17.0.1:32080";
return [ return [
{ {
source: "/api/:path*", source: "/api/:path*",
+238 -106
View File
@@ -1,22 +1,23 @@
{ {
"name": "temp", "name": "temp",
"version": "1.2.4", "version": "1.2.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "temp", "name": "temp",
"version": "1.2.4", "version": "1.2.13",
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.2.2", "@apollo/client": "^4.1.9",
"@types/node": "^20.19.37", "@tailwindcss/postcss": "^4.2.4",
"@types/node": "^20.19.39",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"graphql": "^16.14.0",
"next": "16.1.7", "next": "16.1.7",
"nextjs": "^0.0.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"tailwind": "^4.0.0", "tailwind": "^4.0.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"devDependencies": { "devDependencies": {
@@ -27,10 +28,10 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "16.1.7", "eslint-config-next": "16.1.7",
"prettier": "^3.8.1", "prettier": "^3.8.3",
"prettier-plugin-embed": "^0.5.1", "prettier-plugin-embed": "^0.5.1",
"prettier-plugin-groovy": "^0.2.1", "prettier-plugin-groovy": "^0.2.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.4",
"semantic-release": "^25.0.3" "semantic-release": "^25.0.3"
} }
}, },
@@ -95,6 +96,43 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "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": "^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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2119,47 +2166,47 @@
} }
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0", "enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"lightningcss": "1.32.0", "lightningcss": "1.32.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "4.2.2" "tailwindcss": "4.3.0"
} }
}, },
"node_modules/@tailwindcss/oxide": { "node_modules/@tailwindcss/oxide": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20" "node": ">= 20"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2" "@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2173,9 +2220,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2189,9 +2236,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2205,9 +2252,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2221,9 +2268,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2237,9 +2284,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2253,9 +2300,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2269,9 +2316,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2285,9 +2332,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2301,9 +2348,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": { "node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"bundleDependencies": [ "bundleDependencies": [
"@napi-rs/wasm-runtime", "@napi-rs/wasm-runtime",
"@emnapi/core", "@emnapi/core",
@@ -2318,10 +2365,10 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "^1.8.1", "@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.8.1", "@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.1.0", "@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.1", "@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1", "@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
@@ -2330,9 +2377,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2346,9 +2393,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2362,16 +2409,16 @@
} }
}, },
"node_modules/@tailwindcss/postcss": { "node_modules/@tailwindcss/postcss": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz",
"integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.2.2", "@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.2.2", "@tailwindcss/oxide": "4.3.0",
"postcss": "^8.5.6", "postcss": "^8.5.10",
"tailwindcss": "4.2.2" "tailwindcss": "4.3.0"
} }
}, },
"node_modules/@trivago/prettier-plugin-sort-imports": { "node_modules/@trivago/prettier-plugin-sort-imports": {
@@ -2495,9 +2542,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.37", "version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -3090,6 +3137,54 @@
"win32" "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -4864,13 +4959,13 @@
} }
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.20.1", "version": "5.21.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.4", "graceful-fs": "^4.2.4",
"tapable": "^2.3.0" "tapable": "^2.3.3"
}, },
"engines": { "engines": {
"node": ">=10.13.0" "node": ">=10.13.0"
@@ -6715,6 +6810,30 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "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": { "node_modules/handlebars": {
"version": "4.7.9", "version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
@@ -8825,15 +8944,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/nextjs": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/nextjs/-/nextjs-0.0.3.tgz",
"integrity": "sha512-mYbDUo4/sRAZ8TqK63PCpYnFiLg7BICG/ot9+guOrUKd4/Fo71ZmEQ41IZbH6nqbQvG7SXTBuofJXAIWfNho0w==",
"license": "MIT",
"engines": {
"node": ">=0.8.21"
}
},
"node_modules/nocache": { "node_modules/nocache": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz", "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz",
@@ -11083,6 +11193,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -11573,9 +11695,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -11611,9 +11733,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.8.1", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -11671,9 +11793,9 @@
} }
}, },
"node_modules/prettier-plugin-tailwindcss": { "node_modules/prettier-plugin-tailwindcss": {
"version": "0.7.2", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.4.tgz",
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", "integrity": "sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -12228,6 +12350,16 @@
"queue-microtask": "^1.2.2" "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": { "node_modules/safe-array-concat": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -13383,15 +13515,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.2.2", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp", "name": "temp",
"version": "1.2.4", "version": "1.2.13",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -12,15 +12,15 @@
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^4.1.9", "@apollo/client": "^4.1.9",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.3.0",
"@types/node": "^20.19.39", "@types/node": "^20.19.41",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"graphql": "^16.14.0", "graphql": "^16.14.0",
"next": "16.1.7", "next": "16.1.7",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"tailwind": "^4.0.0", "tailwind": "^4.0.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.3.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"devDependencies": { "devDependencies": {
+277 -301
View File
File diff suppressed because it is too large Load Diff