Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e20bd33541 | |||
| 2376d57cbb | |||
| 7b4e46c4e4 | |||
| 3a5cfd0494 | |||
| 27bb95dea5 | |||
| 9f8695a870 | |||
| b37bf5d088 | |||
| 378e381454 |
@@ -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
|
||||||
|
// 3–7 lines of the most relevant code
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. [Next step]** — `path/to/file.ext:LINE`
|
||||||
|
|
||||||
|
> ...
|
||||||
|
|
||||||
|
_(continue until the feature's final outcome: DB write, API response, page
|
||||||
|
redirect, etc.)_
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
> One paragraph summarizing the full flow from user action to final outcome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracing Rules
|
||||||
|
|
||||||
|
| Rule | Detail |
|
||||||
|
| --------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| **Max depth** | 5 layers (UI → handler → service → repo → DB). Stop before third-party library internals. |
|
||||||
|
| **Always cite** | Every step must have `file:line`. Never say "somewhere in the codebase." |
|
||||||
|
| **Skip** | Config files, `.env`, migrations, boilerplate. Focus on application logic only. |
|
||||||
|
| **Branches** | If a step has success/error paths, show both as `1a` and `1b`. |
|
||||||
|
| **Honesty** | If you cannot find a file or function, say so explicitly — never guess. |
|
||||||
|
|
||||||
|
## Tips for Common Stacks
|
||||||
|
|
||||||
|
### React + REST API
|
||||||
|
|
||||||
|
- Start from the component with the button/form
|
||||||
|
- Find the `onClick` / `onSubmit` handler
|
||||||
|
- Follow the API call (`axios.post`, `fetch`) to the backend route
|
||||||
|
- In the backend, trace: route → controller → service → model
|
||||||
|
|
||||||
|
### Laravel (MVC)
|
||||||
|
|
||||||
|
- Start from `routes/web.php` or `routes/api.php`
|
||||||
|
- Follow: route → Controller method → Service (if any) → Model / DB query
|
||||||
|
|
||||||
|
### Next.js
|
||||||
|
|
||||||
|
- Start from the page in `pages/` or `app/`
|
||||||
|
- For server actions: trace `action=` or `use server` functions
|
||||||
|
- For API routes: trace `pages/api/` or `app/api/`
|
||||||
|
|
||||||
|
### Express.js
|
||||||
|
|
||||||
|
- Start from route definition in `routes/` or `app.js`
|
||||||
|
- Follow: router → middleware → controller → DB call
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
name: learning-assistant
|
||||||
|
description: >
|
||||||
|
A personal learning companion for students and self-learners. Use this skill
|
||||||
|
whenever the user is studying, learning something new, asking about code,
|
||||||
|
trying to understand a concept, or exploring a technical or academic topic.
|
||||||
|
Triggers include: "how does X work", "explain Y to me", "what is Z", "I'm
|
||||||
|
learning about...", "can you help me understand...", "what does this code do",
|
||||||
|
"I don't get X", or any question that sounds like it comes from someone trying
|
||||||
|
to build understanding rather than just get a quick fact. Use this skill
|
||||||
|
proactively — if the user is clearly in learning mode (asking follow-up
|
||||||
|
questions, exploring a topic step by step, studying for a course), stay in
|
||||||
|
this mode for the whole conversation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Learning Assistant
|
||||||
|
|
||||||
|
Your job is to help the user learn effectively. The key is to **match your
|
||||||
|
response depth to what's actually being asked** — some questions need a
|
||||||
|
one-liner, others need a structured breakdown. Read the question carefully and
|
||||||
|
pick the right mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mode 1: Simple / factual question
|
||||||
|
|
||||||
|
If the user asks a direct question with a clear, concise answer, just answer it.
|
||||||
|
Don't pad it out.
|
||||||
|
|
||||||
|
**Examples of simple questions:**
|
||||||
|
|
||||||
|
- "What does `===` mean in JavaScript?"
|
||||||
|
- "What's the difference between TCP and UDP?"
|
||||||
|
- "What does the `final` keyword do in Java?"
|
||||||
|
|
||||||
|
**How to respond:** One to three sentences. Be accurate and direct. If there's a
|
||||||
|
one-line example that makes it instantly clear, include it. Don't add sections
|
||||||
|
or menus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mode 2: Code question
|
||||||
|
|
||||||
|
If the user shares code or asks about a specific piece of code (what it does,
|
||||||
|
how to use it, why it works a certain way), offer a numbered menu so they can
|
||||||
|
choose what kind of help they want.
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Short one-sentence explanation of what the code does]
|
||||||
|
|
||||||
|
What would you like to know more about?
|
||||||
|
1. How it works step by step
|
||||||
|
2. Example usage
|
||||||
|
3. Common variations / alternatives
|
||||||
|
4. Common mistakes or edge cases
|
||||||
|
5. Something else — just ask
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the initial explanation brief. The menu is there so the user drives the
|
||||||
|
depth, not you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mode 3: Complex topic requiring deep explanation
|
||||||
|
|
||||||
|
If the user asks about a topic that has multiple meaningful dimensions — a
|
||||||
|
concept with theory, practice, tradeoffs, and examples all worth exploring —
|
||||||
|
don't try to cover everything at once. Surface the structure first and let them
|
||||||
|
pick.
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[One or two sentences situating the topic — what it is and why it matters]
|
||||||
|
|
||||||
|
Which part do you want to dig into?
|
||||||
|
1. Core idea / how it works
|
||||||
|
2. Example / walkthrough
|
||||||
|
3. When to use it (and when not to)
|
||||||
|
4. Common misconceptions
|
||||||
|
5. [A specific angle relevant to this topic]
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust the menu options to fit the topic. Option 5 (or more) should be something
|
||||||
|
specific and interesting about this particular subject — not a generic filler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General principles
|
||||||
|
|
||||||
|
- **Be concise by default.** If you can say it clearly in two sentences, don't
|
||||||
|
write six.
|
||||||
|
- **Don't front-load.** Get to the point before adding caveats or context.
|
||||||
|
- **When you give examples, make them concrete.** Toy examples are fine; vague
|
||||||
|
pseudocode is not.
|
||||||
|
- **Let the user lead the depth.** Your job is to open doors, not walk through
|
||||||
|
all of them at once.
|
||||||
|
- **If a follow-up question changes the depth needed, switch modes.** The modes
|
||||||
|
are a guide, not a rigid script.
|
||||||
|
- **Use plain language.** Avoid unnecessary jargon. If a technical term is
|
||||||
|
necessary, define it briefly the first time.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: prompt-optimizer
|
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 20–40%.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# TODO - Add image upload field for manager add/update menu item
|
# TODO - Add image upload field for manager add/update menu item
|
||||||
|
|
||||||
- [x] Update GraphQL queries/mutations in `lib/manager-context.tsx` to include `imageUrl`
|
- [x] Update GraphQL queries/mutations in `lib/manager-context.tsx` to include
|
||||||
|
`imageUrl`
|
||||||
- [x] Update `components/organisms/manager/ProductModal.tsx`
|
- [x] Update `components/organisms/manager/ProductModal.tsx`
|
||||||
- [x] Add file input for image selection
|
- [x] Add file input for image selection
|
||||||
- [x] Add upload button to call `POST /api/file`
|
- [x] Add upload button to call `POST /api/file`
|
||||||
- [x] Store returned URL string into `form.imageUrl`
|
- [x] Store returned URL string into `form.imageUrl`
|
||||||
- [x] Show upload/loading/error states and image preview
|
- [x] Show upload/loading/error states and image preview
|
||||||
- [x] Update `components/organisms/manager/ProductsTab.tsx` to show image thumbnail in table
|
- [x] Update `components/organisms/manager/ProductsTab.tsx` to show image
|
||||||
|
thumbnail in table
|
||||||
- [x] Mark TODO progress after each step completed
|
- [x] Mark TODO progress after each step completed
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ký. 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>
|
||||||
lý...
|
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"
|
||||||
>
|
>
|
||||||
Đã có tài khoản? Đăng nhập
|
Already have an account? Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+39
-12
@@ -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) => {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
export const formatPrice = (value?: number) =>
|
export const formatPrice = (value?: number) =>
|
||||||
(value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
(value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||||
@@ -16,24 +13,23 @@ 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) ??
|
||||||
|
({
|
||||||
name: "Unknown product",
|
name: "Unknown product",
|
||||||
description: "",
|
description: "",
|
||||||
price: 0,
|
price: 0,
|
||||||
} as MenuItemEntity);
|
} as MenuItemEntity);
|
||||||
|
|
||||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
|
||||||
const isCustomer = user?.role === "customer";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto w-full max-w-screen-2xl px-4 py-6 md:px-6 md:py-8 lg:px-8">
|
<div className="mx-auto w-full max-w-screen-2xl px-4 py-6 md:px-6 md:py-8 lg:px-8">
|
||||||
@@ -42,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 có 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">
|
||||||
@@ -83,7 +79,6 @@ export default function PaymentPage() {
|
|||||||
quantity,
|
quantity,
|
||||||
}) => {
|
}) => {
|
||||||
const { name, description } = findProduct(id);
|
const { name, description } = findProduct(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={id}
|
key={id}
|
||||||
@@ -103,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>
|
||||||
@@ -132,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>
|
||||||
@@ -150,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
-36
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ProductsTab } from "@/components/organisms/manager";
|
import { ProductsTab, ReviewsTab } 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 = [
|
||||||
{
|
{
|
||||||
@@ -16,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)">
|
||||||
@@ -86,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">
|
||||||
@@ -121,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) => (
|
{/* sm–lg: 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"
|
||||||
@@ -173,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"
|
||||||
@@ -185,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>
|
||||||
|
|||||||
+202
-149
@@ -50,6 +50,7 @@ export default function StaffSchedulePage() {
|
|||||||
goToPrevMonth,
|
goToPrevMonth,
|
||||||
goToToday,
|
goToToday,
|
||||||
getWeeklyBudget,
|
getWeeklyBudget,
|
||||||
|
shifts,
|
||||||
} = useShift();
|
} = useShift();
|
||||||
|
|
||||||
const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
|
const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
|
||||||
@@ -70,41 +71,48 @@ export default function StaffSchedulePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDateSelect = (date: Date) => {
|
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>
|
||||||
|
|||||||
@@ -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,6 +1,10 @@
|
|||||||
import { formatPrice } from "@/app/(main)/payment/page";
|
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";
|
||||||
|
|
||||||
@@ -10,10 +14,12 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -43,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"
|
||||||
@@ -51,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)}
|
||||||
@@ -83,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,36 +1,74 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ShiftEntity } from "@/lib/types";
|
|
||||||
|
|
||||||
import type { ShiftCardProps } from "./ShiftCard.types";
|
import type { ShiftCardProps } from "./ShiftCard.types";
|
||||||
|
|
||||||
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) {
|
||||||
console.log(shift);
|
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`}
|
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>
|
||||||
{}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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,56 +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`}
|
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}`}>
|
||||||
{}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 && 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.staffId}
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function ProductModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Upload ảnh thất bại.");
|
throw new Error("Image upload failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = await res.text();
|
const raw = await res.text();
|
||||||
@@ -54,12 +54,12 @@ export default function ProductModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
throw new Error("Không nhận được filename ảnh từ server.");
|
throw new Error("No image filename received from server.");
|
||||||
}
|
}
|
||||||
|
|
||||||
setForm((prev) => ({ ...prev, imageUrl: filename }));
|
setForm((prev) => ({ ...prev, imageUrl: filename }));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setUploadError(error?.message || "Không thể upload ảnh.");
|
setUploadError(error?.message || "Unable to upload image.");
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -93,11 +93,11 @@ export default function ProductModal({
|
|||||||
<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>
|
||||||
@@ -107,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
|
||||||
@@ -115,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
|
||||||
@@ -139,7 +139,7 @@ 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)">
|
||||||
Ảnh món
|
Item image
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -154,7 +154,7 @@ export default function ProductModal({
|
|||||||
{uploading && (
|
{uploading && (
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||||
Đang upload ảnh...
|
Uploading image...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +163,7 @@ export default function ProductModal({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Preview ảnh món"
|
alt="Item image preview"
|
||||||
className="h-24 w-24 rounded-lg border border-(--color-border-light) object-cover"
|
className="h-24 w-24 rounded-lg border border-(--color-border-light) object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +179,7 @@ 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)">
|
||||||
Mô tả
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -188,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 ${
|
||||||
@@ -221,14 +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"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:opacity-60"
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isEdit ? "Lưu thay đổi" : "Thêm món"}
|
{isEdit ? "Save changes" : "Add item"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -59,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)"
|
||||||
>
|
>
|
||||||
@@ -81,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 */}
|
||||||
@@ -109,19 +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)">
|
||||||
Ảnh
|
Image
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||||
Tên món
|
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>
|
||||||
@@ -133,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>
|
||||||
) : (
|
) : (
|
||||||
@@ -165,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} />
|
||||||
@@ -175,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ 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,
|
||||||
@@ -35,7 +41,7 @@ export default function ProductGrid({
|
|||||||
({ id, imageUrl, name, price, description }) => (
|
({ id, imageUrl, name, price, description }) => (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
key={id}
|
key={id}
|
||||||
image={imageUrl}
|
image={toDisplayUrl(imageUrl)}
|
||||||
imageAlt={name}
|
imageAlt={name}
|
||||||
productName={name}
|
productName={name}
|
||||||
price={price}
|
price={price}
|
||||||
|
|||||||
@@ -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,10 +22,23 @@ 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 } =
|
||||||
|
useShift();
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
|
||||||
|
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
@@ -57,187 +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 dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
|
() => getShiftsForDate(selectedDate),
|
||||||
const dots: string[] = [];
|
[selectedDate, getShiftsForDate],
|
||||||
// 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.getDate() === selectedDate.getDate());
|
|
||||||
}, [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 today = isToday(date);
|
|
||||||
const selected = date === selectedDate;
|
|
||||||
const dots = getDotColors(date);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={i}
|
|
||||||
onClick={() => setSelectedDate(date)}
|
|
||||||
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;
|
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
||||||
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>
|
||||||
|
|||||||
@@ -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,49 +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 dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
|
|
||||||
// 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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
@@ -85,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?.(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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,6 +7,68 @@ import { useEffect, useState } from "react";
|
|||||||
import { formatDateISO } from "./MonthlyCalendar";
|
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,
|
||||||
@@ -50,7 +112,11 @@ export default function ShiftCreateModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deptName =
|
||||||
|
DEPARTMENTS.find((d) => d.id === department)?.name ?? department;
|
||||||
|
|
||||||
createShift({
|
createShift({
|
||||||
|
name: `${deptName} - ${formatDateISO(date)}`,
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
@@ -110,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>
|
||||||
@@ -135,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"
|
||||||
@@ -155,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"
|
||||||
@@ -169,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"
|
||||||
@@ -198,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,
|
||||||
@@ -21,21 +31,22 @@ export default function ShiftDetailModal({
|
|||||||
|
|
||||||
// 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: string) => {
|
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 */}
|
||||||
@@ -84,7 +133,7 @@ export default function ShiftDetailModal({
|
|||||||
{/* {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>
|
||||||
@@ -114,23 +163,22 @@ export default function ShiftDetailModal({
|
|||||||
<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,16 +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>
|
||||||
<p className="text-foreground mt-1 text-sm font-bold">{""} giờ</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>
|
||||||
@@ -155,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 ký ({shift.registeredStaff!.length}/
|
Registered staff ({registeredStaff.length}/{shift.maxStaff})
|
||||||
{shift.maxStaff})
|
|
||||||
</p>
|
</p>
|
||||||
{shift.registeredStaff && 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 có ai đăng ký
|
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"
|
||||||
@@ -174,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.staffId}
|
<StaffName staffId={staff.staffId} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isManager && (
|
{isManager && (
|
||||||
@@ -184,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>
|
||||||
@@ -210,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 ký ca
|
|
||||||
</button>
|
|
||||||
)} */}
|
|
||||||
{isRegistered && (
|
{isRegistered && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -230,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 ký
|
Unregister
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isManager && (
|
{isManager && (
|
||||||
@@ -240,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
|
||||||
@@ -248,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>
|
||||||
|
|||||||
@@ -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,19 +23,11 @@ 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(Date.now());
|
const today = new Date(Date.now());
|
||||||
return (
|
return (
|
||||||
@@ -63,224 +55,229 @@ export default function WeeklySchedule({
|
|||||||
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 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[date.toISOString()] = dots.slice(0, 3);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [weekDates, getShiftsForDate]);
|
|
||||||
|
|
||||||
const selectedDateStr = useMemo(() => {
|
|
||||||
const inWeek = weekDates.find((d) => d === selectedDate);
|
|
||||||
return inWeek ?? weekDates[0] ?? currentDate;
|
|
||||||
}, [selectedDate, weekDates, currentDate]);
|
}, [selectedDate, weekDates, currentDate]);
|
||||||
|
|
||||||
const renderMobileDayView = mobileCalendarHeader && (
|
// ── Mobile calendar header view ────────────────────────────────────────────
|
||||||
<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 active = date === selectedDateStr;
|
|
||||||
const dots = statusDotsByDate[date.toISOString()] ?? [];
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedDate(date)}
|
|
||||||
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(new Date(date))
|
|
||||||
? "bg-indigo-500 text-white"
|
|
||||||
: i >= 5
|
|
||||||
? "text-pink-500"
|
|
||||||
: "text-sky-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{new Date(date).getDate()}
|
|
||||||
</span>
|
|
||||||
<div className="flex min-h-2 items-center gap-0.5">
|
|
||||||
{dots.map((dot, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className={`h-1.5 w-1.5 rounded-full ${dot}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{DEPARTMENTS.map((dept) => {
|
|
||||||
const deptShifts = getShiftsForDate(new Date(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(new Date(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"
|
||||||
{date.toISOString()}
|
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 = date;
|
? "bg-(--color-primary) text-white shadow-sm"
|
||||||
const shifts = getShiftsForDate(new Date(date));
|
: active
|
||||||
// .filter(
|
? "bg-(--color-primary)/15 text-(--color-primary)"
|
||||||
// (s) => s.department === dept.id,
|
: i >= 5
|
||||||
// );
|
? "text-rose-500"
|
||||||
return (
|
: "text-(--color-text-secondary)"
|
||||||
<td
|
|
||||||
key={i}
|
|
||||||
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
|
|
||||||
isToday(new Date(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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: frontend-container
|
- name: frontend-container
|
||||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.8
|
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.13
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+4
-2
@@ -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}
|
||||||
|
|||||||
+29
-3
@@ -18,11 +18,14 @@ interface CartContextValue {
|
|||||||
items: CartItemEntity[];
|
items: CartItemEntity[];
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
|
cart: CartEntity;
|
||||||
|
eateryId: string | undefined;
|
||||||
addToCart: (product: CartItemEntity) => void;
|
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 CART_ID = "cartId";
|
const CART_ID = "cartId";
|
||||||
@@ -119,16 +122,17 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Lỗi khi tạo giỏ hàng:", err);
|
console.error("Lỗi khi tạo giỏ hàng:", err);
|
||||||
|
localStorage.removeItem(CART_ID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
createCartFunc();
|
createCartFunc();
|
||||||
} else if (!cartId) {
|
} else if (!cartId) {
|
||||||
const localCartId = localStorage.getItem(CART_ID);
|
const localCartId = localStorage.getItem(CART_ID);
|
||||||
if (localCartId) setCartId(localCartId);
|
if (localCartId) setCartId(localCartId);
|
||||||
else createCartFunc();
|
else createCartFunc();
|
||||||
}
|
}
|
||||||
}, [eateryData, createCart, data, loading, error]);
|
}, [eateryData, createCart, data, loading, error]);
|
||||||
|
|
||||||
@@ -150,6 +154,25 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (result) setCart(result.addItem);
|
if (result) setCart(result.addItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DELETE_CART = gql`
|
||||||
|
mutation deleteCart($cartId: String!) {
|
||||||
|
deleteCart(cartId: $cartId)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [mutateDeleteCart] = useMutation(DELETE_CART, {
|
||||||
|
client: cartClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeCart = async () => {
|
||||||
|
localStorage.removeItem(CART_ID);
|
||||||
|
|
||||||
|
await mutateDeleteCart({ variables: { cartId } });
|
||||||
|
|
||||||
|
setCartId(null);
|
||||||
|
setCart(null!);
|
||||||
|
};
|
||||||
|
|
||||||
const setQuantity = async (id: string, newQuantity: number) => {
|
const setQuantity = async (id: string, newQuantity: number) => {
|
||||||
if (!cartId) return;
|
if (!cartId) return;
|
||||||
|
|
||||||
@@ -184,13 +207,16 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
items: cart?.items,
|
items: cart?.items,
|
||||||
totalItems,
|
totalItems,
|
||||||
totalPrice: cart?.totalAmount,
|
totalPrice: cart?.totalAmount,
|
||||||
|
eateryId: cart?.eateryId,
|
||||||
addToCart,
|
addToCart,
|
||||||
increaseQty,
|
increaseQty,
|
||||||
decreaseQty,
|
decreaseQty,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
setQuantity,
|
setQuantity,
|
||||||
|
removeCart,
|
||||||
|
cart,
|
||||||
}),
|
}),
|
||||||
[cart?.items, cart?.totalAmount],
|
[cart?.items, cart?.totalAmount, cart?.eateryId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||||
|
|||||||
+11
-1
@@ -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,6 +51,7 @@ const GET_EATERY_MENU = gql`
|
|||||||
query GetEateryMenu {
|
query GetEateryMenu {
|
||||||
allEateries {
|
allEateries {
|
||||||
id
|
id
|
||||||
|
name
|
||||||
menuItems {
|
menuItems {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -96,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, {
|
||||||
@@ -103,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,
|
||||||
});
|
});
|
||||||
@@ -120,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]);
|
||||||
|
|
||||||
@@ -190,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,
|
||||||
|
|||||||
+133
-55
@@ -41,18 +41,17 @@ interface ShiftContextType {
|
|||||||
shiftId: string,
|
shiftId: string,
|
||||||
staffId: string,
|
staffId: string,
|
||||||
staffName: string,
|
staffName: string,
|
||||||
) => { success: boolean; error?: string };
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
unregisterShift: (shiftId: string, staffId: string) => void;
|
unregisterShift: (shiftId: string, staffId: string) => void;
|
||||||
createShift: (shift: Omit<ShiftEntity, "id">) => void;
|
createShift: (shift: Omit<ShiftEntity, "id">) => void;
|
||||||
updateShift: (shift: ShiftEntity) => void;
|
deleteShift: (shiftId: string) => Promise<void>;
|
||||||
deleteShift: (shiftId: string) => void;
|
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
getShiftsForDate: (date: Date) => ShiftEntity[];
|
getShiftsForDate: (date: Date) => ShiftEntity[];
|
||||||
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
|
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
|
||||||
getWeekDates: () => Date[];
|
getWeekDates: () => Date[];
|
||||||
hasConflict: (
|
hasConflict: (
|
||||||
date: Date,
|
date: Date | string,
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
staffId: string,
|
staffId: string,
|
||||||
@@ -83,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;
|
||||||
@@ -113,6 +127,7 @@ const GET_ALL_SHIFTS = gql`
|
|||||||
maxStaff
|
maxStaff
|
||||||
wage
|
wage
|
||||||
registeredStaff {
|
registeredStaff {
|
||||||
|
id
|
||||||
staffId
|
staffId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,9 +143,20 @@ const CREATE_SHIFT = gql`
|
|||||||
endTime
|
endTime
|
||||||
maxStaff
|
maxStaff
|
||||||
wage
|
wage
|
||||||
registeredStaff {
|
}
|
||||||
staffId
|
}
|
||||||
}
|
`;
|
||||||
|
|
||||||
|
const DELETE_SHIFT = gql`
|
||||||
|
mutation deleteShift($id: String!) {
|
||||||
|
deleteShift(id: $id)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const REGISTER_SHIFT = gql`
|
||||||
|
mutation registerShift($RegisterShiftInput: RegisterShiftInput) {
|
||||||
|
registerShift(registration: $RegisterShiftInput) {
|
||||||
|
staffId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -142,7 +168,7 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
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 } = useQuery<allShiftsQuery>(GET_ALL_SHIFTS, {
|
const { data, refetch } = useQuery<allShiftsQuery>(GET_ALL_SHIFTS, {
|
||||||
client: eateryClient,
|
client: eateryClient,
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
});
|
});
|
||||||
@@ -151,6 +177,18 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
client: eateryClient,
|
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(() => {
|
useEffect(() => {
|
||||||
if (data) setShifts(data.allShifts);
|
if (data) setShifts(data.allShifts);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -206,9 +244,8 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const getShiftsForDate = useCallback(
|
const getShiftsForDate = useCallback(
|
||||||
(date: Date): ShiftEntity[] => {
|
(date: Date): ShiftEntity[] => {
|
||||||
return shifts.filter((s) => {
|
const key = formatDate(date);
|
||||||
return new Date(s.date).getDate() === date.getDate();
|
return shifts.filter((s) => dateKey(s.date) === key);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[shifts],
|
[shifts],
|
||||||
);
|
);
|
||||||
@@ -220,24 +257,26 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
d.setDate(weekStart.getDate() + i);
|
d.setDate(weekStart.getDate() + i);
|
||||||
return 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: Date,
|
date: Date | string,
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
staffId: string,
|
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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -246,33 +285,39 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const getWeeklyBudget = useCallback((): number => {
|
const getWeeklyBudget = useCallback((): number => {
|
||||||
const weekDates = getWeekDates();
|
const weekDates = getWeekDates();
|
||||||
|
const weekKeys = new Set(weekDates.map(formatDate));
|
||||||
return shifts
|
return shifts
|
||||||
.filter(
|
.filter(
|
||||||
(s) => weekDates.includes(s.date) && s.registeredStaff!.length > 0,
|
(s) =>
|
||||||
|
weekKeys.has(dateKey(s.date)) && (s.registeredStaff ?? []).length > 0,
|
||||||
)
|
)
|
||||||
.reduce((sum, s) => sum + s.wage * 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: string,
|
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,
|
||||||
@@ -287,31 +332,28 @@ 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) {
|
||||||
// { 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: string) => {
|
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,
|
||||||
@@ -320,23 +362,60 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createShift = useCallback(async (shift: Omit<ShiftEntity, "id">) => {
|
const createShift = useCallback(
|
||||||
const { data } = await mutateCreateShift({
|
async (shift: Omit<ShiftEntity, "id">) => {
|
||||||
variables: {
|
try {
|
||||||
shiftInput: shift,
|
const { data } = await mutateCreateShift({
|
||||||
},
|
variables: {
|
||||||
});
|
shiftInput: {
|
||||||
|
date: toLocalDateISO(shift.date ?? new Date()),
|
||||||
|
startTime: shift.startTime,
|
||||||
|
endTime: shift.endTime,
|
||||||
|
maxStaff: shift.maxStaff,
|
||||||
|
wage: shift.wage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (data) setShifts((prev) => [...prev, data.createShift]);
|
if (data) {
|
||||||
}, []);
|
// Optimistic: dùng date từ server nếu có, fallback sang local
|
||||||
|
const serverDate = data.createShift.date
|
||||||
|
? new Date(data.createShift.date as unknown as string)
|
||||||
|
: shift.date;
|
||||||
|
setShifts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...data.createShift,
|
||||||
|
date: serverDate,
|
||||||
|
wage: data.createShift.wage ?? shift.wage,
|
||||||
|
registeredStaff: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Refetch để đồng bộ với server
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[createShift] mutation failed:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutateCreateShift, refetch],
|
||||||
|
);
|
||||||
|
|
||||||
const updateShift = useCallback((shift: ShiftEntity) => {
|
const deleteShift = useCallback(
|
||||||
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
async (shiftId: string) => {
|
||||||
}, []);
|
try {
|
||||||
|
const { data } = await mutateDeleteShift({
|
||||||
const deleteShift = useCallback((shiftId: string) => {
|
variables: { id: shiftId },
|
||||||
setShifts((prev) => prev.filter((s) => s.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
|
||||||
@@ -353,7 +432,6 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
registerShift,
|
registerShift,
|
||||||
unregisterShift,
|
unregisterShift,
|
||||||
createShift,
|
createShift,
|
||||||
updateShift,
|
|
||||||
deleteShift,
|
deleteShift,
|
||||||
getShiftsForDate,
|
getShiftsForDate,
|
||||||
getShiftsForWeek,
|
getShiftsForWeek,
|
||||||
|
|||||||
+4
-2
@@ -96,10 +96,11 @@ export interface ShiftRegistrationEntity {
|
|||||||
|
|
||||||
export interface ShiftEntity {
|
export interface ShiftEntity {
|
||||||
id: string;
|
id: string;
|
||||||
date: Date;
|
name?: string;
|
||||||
|
date?: Date;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
wage: number;
|
wage?: number;
|
||||||
maxStaff: number;
|
maxStaff: number;
|
||||||
registeredStaff?: ShiftRegistrationEntity[];
|
registeredStaff?: ShiftRegistrationEntity[];
|
||||||
}
|
}
|
||||||
@@ -152,6 +153,7 @@ export interface CartItemEntity {
|
|||||||
|
|
||||||
export interface CartEntity {
|
export interface CartEntity {
|
||||||
Id: string;
|
Id: string;
|
||||||
|
eateryId: string;
|
||||||
items: CartItemEntity[];
|
items: CartItemEntity[];
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
paymentQrUrl: string;
|
paymentQrUrl: string;
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const gatewayUrl = "http://localhost:32080";
|
const gatewayUrl = "http://172.17.0.1:32080";
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.2.8",
|
"version": "1.2.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.2.8",
|
"version": "1.2.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.9",
|
"@apollo/client": "^4.1.9",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.2.8",
|
"version": "1.2.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Generated
+4
-4
@@ -1485,8 +1485,8 @@ packages:
|
|||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.355:
|
electron-to-chromium@1.5.356:
|
||||||
resolution: {integrity: sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==}
|
resolution: {integrity: sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==}
|
||||||
|
|
||||||
emoji-regex@10.6.0:
|
emoji-regex@10.6.0:
|
||||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||||
@@ -4984,7 +4984,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.10.29
|
baseline-browser-mapping: 2.10.29
|
||||||
caniuse-lite: 1.0.30001792
|
caniuse-lite: 1.0.30001792
|
||||||
electron-to-chromium: 1.5.355
|
electron-to-chromium: 1.5.356
|
||||||
node-releases: 2.0.44
|
node-releases: 2.0.44
|
||||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||||
|
|
||||||
@@ -5315,7 +5315,7 @@ snapshots:
|
|||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.355: {}
|
electron-to-chromium@1.5.356: {}
|
||||||
|
|
||||||
emoji-regex@10.6.0: {}
|
emoji-regex@10.6.0: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user