feat: enhance login functionality and add staff creation page
- Update LoginForm to handle staff role and redirect accordingly. - Improve ProductsTab UI for better visibility when no products are found. - Refactor ShiftCreateModal to use custom TimeInput component for better time handling. - Add parseShiftDate utility in ShiftDetailModal for improved date parsing. - Modify ShiftContext to handle shift deletion with proper error handling. - Introduce feature-workflow-tracer skill for tracing feature workflows in the codebase. - Add learning-assistant skill to support users in understanding concepts and code. - Implement CreateStaffPage for manager to create new staff accounts with validation and error handling.
This commit is contained in:
@@ -14,7 +14,9 @@
|
||||
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/\")",
|
||||
"Bash(npm run build)",
|
||||
"Bash(cat \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
|
||||
"Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")"
|
||||
"Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
|
||||
"Bash(Get-ChildItem -Path \"C:\\\\VS Code\\\\CongNghePhanMem\\\\Final\\\\backend\" -Recurse -Include \"*.graphqls\", \"*.graphql\")",
|
||||
"Bash(Select-Object FullName)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: feature-workflow-tracer
|
||||
description: Trace and explain the end-to-end workflow of any feature or functionality in a codebase. Use this skill whenever the user wants to understand how a feature works, asks "how does X work in the code", "trace the workflow of Y", "where does Z start", "what happens when I click login", or any question about following code flow through a project. Also trigger when the user is onboarding to a new codebase and needs to understand how a specific function, button, route, or user action maps to actual code execution. Trigger even for vague questions like "how is auth handled here?" or "explain the checkout flow".
|
||||
---
|
||||
|
||||
# Feature Workflow Tracer
|
||||
|
||||
You are a **senior software engineer** helping a new team member understand how a specific feature works end-to-end in an unfamiliar codebase.
|
||||
|
||||
## Your Task
|
||||
|
||||
When the user provides a **feature name** (e.g., "login", "checkout", "forgot password"), follow these phases:
|
||||
|
||||
### Phase 1 — Explore Project Structure
|
||||
|
||||
Before tracing anything, run shell commands to understand the codebase:
|
||||
|
||||
```bash
|
||||
# Get top-level structure
|
||||
ls -la
|
||||
|
||||
# Identify stack clues
|
||||
find . -maxdepth 2 -name "package.json" -o -name "composer.json" -o -name "requirements.txt" | head -10
|
||||
|
||||
# Find route files
|
||||
find . -type f \( -name "*.route.*" -o -name "routes.js" -o -name "web.php" -o -name "api.php" -o -name "router.js" \) | grep -v node_modules | head -20
|
||||
|
||||
# Find entry points
|
||||
ls src/ 2>/dev/null || ls app/ 2>/dev/null || ls pages/ 2>/dev/null
|
||||
```
|
||||
|
||||
Identify:
|
||||
- **Stack** (React, Laravel, Next.js, Vue, Express, etc.)
|
||||
- **Architecture pattern** (MVC, feature-based, domain-driven, etc.)
|
||||
- **Entry point** for the requested feature (route file, page component, or controller)
|
||||
|
||||
### Phase 2 — Trace the Workflow
|
||||
|
||||
Starting from the **UI layer**, follow the execution path step by step:
|
||||
|
||||
```
|
||||
UI (button/form/link)
|
||||
→ Event handler / Route handler
|
||||
→ Service / Controller
|
||||
→ Repository / Model / API call
|
||||
→ Response / Side effect
|
||||
```
|
||||
|
||||
For each step, find the **exact file and line number** using:
|
||||
|
||||
```bash
|
||||
# Search for function/component by name
|
||||
grep -rn "functionName\|ComponentName" src/ --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" --include="*.php"
|
||||
|
||||
# Find where a route is defined
|
||||
grep -rn "'/login'\|\"login\"\|login.route\|LoginController" . --include="*.php" --include="*.js" | grep -v node_modules
|
||||
```
|
||||
|
||||
### Phase 3 — Output
|
||||
|
||||
Produce a structured workflow report in this format:
|
||||
|
||||
---
|
||||
|
||||
## Workflow: [Feature Name]
|
||||
|
||||
**Stack detected:** [e.g., React + Laravel REST API]
|
||||
**Entry point:** [e.g., `resources/js/pages/Login.jsx:12`]
|
||||
|
||||
### Step-by-step Flow
|
||||
|
||||
**1. [Action description]** — `path/to/file.ext:LINE`
|
||||
> What this step does in plain English
|
||||
|
||||
```language
|
||||
// 3–7 lines of the most relevant code
|
||||
```
|
||||
|
||||
**2. [Next step]** — `path/to/file.ext:LINE`
|
||||
> ...
|
||||
|
||||
*(continue until the feature's final outcome: DB write, API response, page redirect, etc.)*
|
||||
|
||||
### Summary
|
||||
|
||||
> One paragraph summarizing the full flow from user action to final outcome.
|
||||
|
||||
---
|
||||
|
||||
## Tracing Rules
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| **Max depth** | 5 layers (UI → handler → service → repo → DB). Stop before third-party library internals. |
|
||||
| **Always cite** | Every step must have `file:line`. Never say "somewhere in the codebase." |
|
||||
| **Skip** | Config files, `.env`, migrations, boilerplate. Focus on application logic only. |
|
||||
| **Branches** | If a step has success/error paths, show both as `1a` and `1b`. |
|
||||
| **Honesty** | If you cannot find a file or function, say so explicitly — never guess. |
|
||||
|
||||
## Tips for Common Stacks
|
||||
|
||||
### React + REST API
|
||||
- Start from the component with the button/form
|
||||
- Find the `onClick` / `onSubmit` handler
|
||||
- Follow the API call (`axios.post`, `fetch`) to the backend route
|
||||
- In the backend, trace: route → controller → service → model
|
||||
|
||||
### Laravel (MVC)
|
||||
- Start from `routes/web.php` or `routes/api.php`
|
||||
- Follow: route → Controller method → Service (if any) → Model / DB query
|
||||
|
||||
### Next.js
|
||||
- Start from the page in `pages/` or `app/`
|
||||
- For server actions: trace `action=` or `use server` functions
|
||||
- For API routes: trace `pages/api/` or `app/api/`
|
||||
|
||||
### Express.js
|
||||
- Start from route definition in `routes/` or `app.js`
|
||||
- Follow: router → middleware → controller → DB call
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: learning-assistant
|
||||
description: >
|
||||
A personal learning companion for students and self-learners. Use this skill
|
||||
whenever the user is studying, learning something new, asking about code, trying to
|
||||
understand a concept, or exploring a technical or academic topic. Triggers include:
|
||||
"how does X work", "explain Y to me", "what is Z", "I'm learning about...",
|
||||
"can you help me understand...", "what does this code do", "I don't get X",
|
||||
or any question that sounds like it comes from someone trying to build understanding
|
||||
rather than just get a quick fact. Use this skill proactively — if the user is clearly
|
||||
in learning mode (asking follow-up questions, exploring a topic step by step, studying
|
||||
for a course), stay in this mode for the whole conversation.
|
||||
---
|
||||
|
||||
# Learning Assistant
|
||||
|
||||
Your job is to help the user learn effectively. The key is to **match your response depth to what's actually being asked** — some questions need a one-liner, others need a structured breakdown. Read the question carefully and pick the right mode.
|
||||
|
||||
---
|
||||
|
||||
## Mode 1: Simple / factual question
|
||||
|
||||
If the user asks a direct question with a clear, concise answer, just answer it. Don't pad it out.
|
||||
|
||||
**Examples of simple questions:**
|
||||
- "What does `===` mean in JavaScript?"
|
||||
- "What's the difference between TCP and UDP?"
|
||||
- "What does the `final` keyword do in Java?"
|
||||
|
||||
**How to respond:** One to three sentences. Be accurate and direct. If there's a one-line example that makes it instantly clear, include it. Don't add sections or menus.
|
||||
|
||||
---
|
||||
|
||||
## Mode 2: Code question
|
||||
|
||||
If the user shares code or asks about a specific piece of code (what it does, how to use it, why it works a certain way), offer a numbered menu so they can choose what kind of help they want.
|
||||
|
||||
**Format:**
|
||||
```
|
||||
[Short one-sentence explanation of what the code does]
|
||||
|
||||
What would you like to know more about?
|
||||
1. How it works step by step
|
||||
2. Example usage
|
||||
3. Common variations / alternatives
|
||||
4. Common mistakes or edge cases
|
||||
5. Something else — just ask
|
||||
```
|
||||
|
||||
Keep the initial explanation brief. The menu is there so the user drives the depth, not you.
|
||||
|
||||
---
|
||||
|
||||
## Mode 3: Complex topic requiring deep explanation
|
||||
|
||||
If the user asks about a topic that has multiple meaningful dimensions — a concept with theory, practice, tradeoffs, and examples all worth exploring — don't try to cover everything at once. Surface the structure first and let them pick.
|
||||
|
||||
**Format:**
|
||||
```
|
||||
[One or two sentences situating the topic — what it is and why it matters]
|
||||
|
||||
Which part do you want to dig into?
|
||||
1. Core idea / how it works
|
||||
2. Example / walkthrough
|
||||
3. When to use it (and when not to)
|
||||
4. Common misconceptions
|
||||
5. [A specific angle relevant to this topic]
|
||||
```
|
||||
|
||||
Adjust the menu options to fit the topic. Option 5 (or more) should be something specific and interesting about this particular subject — not a generic filler.
|
||||
|
||||
---
|
||||
|
||||
## General principles
|
||||
|
||||
- **Be concise by default.** If you can say it clearly in two sentences, don't write six.
|
||||
- **Don't front-load.** Get to the point before adding caveats or context.
|
||||
- **When you give examples, make them concrete.** Toy examples are fine; vague pseudocode is not.
|
||||
- **Let the user lead the depth.** Your job is to open doors, not walk through all of them at once.
|
||||
- **If a follow-up question changes the depth needed, switch modes.** The modes are a guide, not a rigid script.
|
||||
- **Use plain language.** Avoid unnecessary jargon. If a technical term is necessary, define it briefly the first time.
|
||||
@@ -1,112 +1,132 @@
|
||||
---
|
||||
name: prompt-optimizer
|
||||
description:
|
||||
Help users rewrite and improve AI/LLM prompts by adding specificity, context,
|
||||
and constraints. Trigger this skill whenever users ask to improve, rewrite,
|
||||
optimize, or refine prompts for AI models. Focus on making prompts clearer,
|
||||
more specific, and more likely to produce better AI results. Present
|
||||
suggestions interactively so users can choose which improvements to apply.
|
||||
description: "Help users rewrite and improve AI/LLM prompts by adding specificity, context, and constraints. Trigger this skill whenever users ask to improve, rewrite, optimize, or refine prompts for AI models. Focus on making prompts clearer, more specific, and more likely to produce better AI results. Present suggestions interactively so users can choose which improvements to apply."
|
||||
---
|
||||
|
||||
|
||||
# Prompt Optimizer
|
||||
|
||||
|
||||
A beginner-friendly skill for improving AI/LLM prompts to get better results.
|
||||
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
This skill helps you rewrite prompts to work better with AI models like Claude.
|
||||
Instead of just giving you a rewritten prompt, it shows you specific improvement
|
||||
suggestions that you can choose to apply or skip.
|
||||
|
||||
|
||||
This skill helps you rewrite prompts to work better with AI models like Claude. Instead of just giving you a rewritten prompt, it shows you specific improvement suggestions that you can choose to apply or skip.
|
||||
|
||||
## Key Improvements
|
||||
|
||||
|
||||
When optimizing a prompt, focus on three main areas:
|
||||
|
||||
|
||||
### 1. **Specificity** — Making the Request Clear
|
||||
|
||||
Good prompts are specific about what you want. Vague prompts get vague results.
|
||||
|
||||
|
||||
**Example improvements:**
|
||||
|
||||
- Add details about format: "Give me a bullet list of 5 items" instead of "tell
|
||||
me about X"
|
||||
- Add details about format: "Give me a bullet list of 5 items" instead of "tell me about X"
|
||||
- Be clear about length: "Write 200 words" instead of "Write something short"
|
||||
- Define who the audience is: "Explain this for a 10-year-old" or "Use technical
|
||||
language"
|
||||
|
||||
- Define who the audience is: "Explain this for a 10-year-old" or "Use technical language"
|
||||
### 2. **Context** — Giving the AI Background Information
|
||||
|
||||
More context helps the AI make better decisions.
|
||||
|
||||
|
||||
**Example improvements:**
|
||||
|
||||
- Explain the goal: "I'm writing a resume, so focus on professional language"
|
||||
- Share constraints: "We only have $500 budget" or "It needs to work on mobile"
|
||||
- Provide background: "I already know Python but not JavaScript"
|
||||
|
||||
### 3. **Constraints** — Setting Boundaries
|
||||
|
||||
Constraints prevent unwanted outputs.
|
||||
|
||||
|
||||
**Example improvements:**
|
||||
|
||||
- Set length limits: "Keep it under 100 words"
|
||||
- Specify format: "Use JSON format" or "Write as a numbered list"
|
||||
- Define tone: "Be casual and friendly, not formal"
|
||||
- Say what NOT to include: "Don't use technical jargon"
|
||||
|
||||
## How to Use This Skill
|
||||
|
||||
|
||||
1. **Share your prompt** — Give me the original prompt you want to improve
|
||||
2. **Review suggestions** — I'll show you specific improvements in each area
|
||||
3. **Choose what you like** — Pick which suggestions to apply
|
||||
4. **Get the final version** — I'll rewrite your prompt with your chosen
|
||||
improvements
|
||||
|
||||
4. **Get the final version** — I'll rewrite your prompt with your chosen improvements
|
||||
## Interactive Selection Process
|
||||
|
||||
|
||||
When you use this skill, you'll see:
|
||||
|
||||
|
||||
- **Original prompt** — Your starting point
|
||||
- **Improvement suggestions** — Specific changes grouped by category
|
||||
(Specificity, Context, Constraints)
|
||||
- **Preview examples** — What each change would look like with the improvement
|
||||
applied
|
||||
- **Your choices** — You pick which suggestions help most (you can apply all,
|
||||
some, or none)
|
||||
|
||||
- **Improvement suggestions** — Specific changes grouped by category (Specificity, Context, Constraints)
|
||||
- **Preview examples** — What each change would look like with the improvement applied
|
||||
- **Your choices** — You pick which suggestions help most (you can apply all, some, or none)
|
||||
Then you get a rewritten prompt combining all your choices.
|
||||
|
||||
|
||||
### Example Interaction
|
||||
|
||||
|
||||
**Original:** "Write me a blog post"
|
||||
|
||||
|
||||
**Suggestions I might offer:**
|
||||
|
||||
- **Specificity**: Add a topic (e.g., "about sustainable living")
|
||||
- **Context**: Explain your goal (e.g., "to build authority on my website")
|
||||
- **Constraints**: Set a word count (e.g., "800-1000 words")
|
||||
|
||||
**Your choice:** "I want all three — add topic, goal, and word count"
|
||||
|
||||
**Final rewritten prompt:** "Write an 800-1000 word blog post about sustainable
|
||||
living for my website. The goal is to establish my authority on eco-friendly
|
||||
practices. Target an audience of people interested in reducing their carbon
|
||||
footprint. Include 3-4 practical tips they can implement immediately, and end
|
||||
with a call-to-action encouraging them to sign up for my newsletter."
|
||||
|
||||
|
||||
**Final rewritten prompt:**
|
||||
"Write an 800-1000 word blog post about sustainable living for my website. The goal is to establish my authority on eco-friendly practices. Target an audience of people interested in reducing their carbon footprint. Include 3-4 practical tips they can implement immediately, and end with a call-to-action encouraging them to sign up for my newsletter."
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
|
||||
- **Start simple** — Even small improvements help
|
||||
- **Focus on your goal** — What outcome do you want?
|
||||
- **Add one constraint at a time** — Too many rules can be confusing
|
||||
- **Test and iterate** — Try the new prompt and see if results improve
|
||||
|
||||
## What Makes a Good Prompt
|
||||
|
||||
|
||||
A prompt becomes "good" when:
|
||||
|
||||
- The AI understands exactly what you want ✓
|
||||
- You've given enough context to explain why ✓
|
||||
- You've set boundaries to prevent bad outputs ✓
|
||||
- Someone else could read it and understand your intent ✓
|
||||
---
|
||||
|
||||
## Anti-Pattern Detection
|
||||
|
||||
Before suggesting improvements, scan the original prompt for these common mistakes and report them with a warning:
|
||||
|
||||
| Anti-Pattern | Description | Example |
|
||||
|---|---|---|
|
||||
| Too generic | No clear subject, action, or goal | "Tell me about AI" |
|
||||
| Ambiguous pronouns | "it", "that", "this" with no clear referent | "Fix it so it works better" |
|
||||
| Internal contradiction | Two requirements that cancel each other out | "Be concise but cover everything in detail" |
|
||||
| Missing context | Requests an action without explaining why or for whom | "Rewrite this paragraph" |
|
||||
|
||||
**Output format:** Before showing improvement suggestions, print a "Detected Issues" block listing any anti-patterns found. If none are found, skip this block silently. Example:
|
||||
|
||||
```
|
||||
⚠️ Detected Issues:
|
||||
- Too generic: No output format specified
|
||||
- Missing context: No audience or goal provided
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Classification
|
||||
|
||||
Automatically classify the input prompt into one of these types, then tailor your improvement suggestions accordingly:
|
||||
|
||||
| Type | Trigger Keywords | Extra Suggestions to Offer |
|
||||
|---|---|---|
|
||||
| `code` | write, fix, debug, refactor, implement | Programming language & version, runtime environment, input/output examples |
|
||||
| `content` | write, blog, email, post, describe, summarize | Target audience, tone (formal/casual), word count, platform |
|
||||
| `analysis` | analyze, compare, evaluate, review, assess | Criteria for evaluation, output format (table, prose), depth of detail |
|
||||
| `qa` | explain, what is, how does, why, define | Knowledge level of audience, analogies allowed?, length of answer |
|
||||
| `task` | do, create, set up, build, generate, automate | Step-by-step vs one-shot, tools/permissions available, success criteria |
|
||||
|
||||
Show the detected type at the top of your response: `📌 Prompt type detected: [type]`
|
||||
|
||||
---
|
||||
|
||||
## Chain-of-Thought Mode
|
||||
|
||||
Offer "Chain-of-Thought Enhancement" as an optional improvement when showing suggestions. When the user selects it, add reasoning instructions to the end of the optimized prompt based on type:
|
||||
|
||||
| Prompt Type | Chain-of-Thought Addition |
|
||||
|---|---|
|
||||
| General | `"Think step by step before answering."` |
|
||||
| Analysis | `"Explain your reasoning for each point before giving a conclusion."` |
|
||||
| Code | `"Outline your approach and data structures before writing any code."` |
|
||||
| Decision-making | `"List pros and cons, then state your recommendation with justification."` |
|
||||
|
||||
**Why it works:** Chain-of-Thought forces the AI to externalize its reasoning process. This reduces hallucination (the AI catches its own errors mid-reasoning), makes outputs easier to verify, and produces more structured answers. Studies show CoT improves accuracy on complex tasks by 20–40%.
|
||||
@@ -54,6 +54,7 @@ export default function LoginOtpPage() {
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
if (userData.role) userData.role = userData.role.toLowerCase();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function LoginPasswordPage() {
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -21,11 +22,12 @@ export default function LoginPasswordPage() {
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || storedRole !== "manager") {
|
||||
if (!storedPhone || (storedRole !== "manager" && storedRole !== "staff")) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
setRole(storedRole);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
@@ -48,11 +50,13 @@ export default function LoginPasswordPage() {
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
if (userData.role) userData.role = userData.role.toLowerCase();
|
||||
else userData.role = role;
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
sessionStorage.removeItem("login_role");
|
||||
router.push("/manager");
|
||||
router.push(role === "staff" ? "/staff/schedule" : "/manager");
|
||||
} else {
|
||||
const STATUS_ERROR_MAP: Record<number, string> = {
|
||||
400: "Incorrect login details, please try again",
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function ManagerSignupPage() {
|
||||
onChange={handleChange(field)}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 pl-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors[field] && (
|
||||
|
||||
+39
-12
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SearchBar } from "@/components/molecules/search-bar";
|
||||
import { CategorySidebar } from "@/components/organisms/navigation";
|
||||
import { ProductGrid } from "@/components/organisms/product-grid";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { eateryClient } from "@/lib/apollo-clients";
|
||||
import { allEateriesQuery } from "@/lib/types";
|
||||
import { gql } from "@apollo/client";
|
||||
@@ -18,18 +18,41 @@ const GET_EATERY_COUNT = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Main page — sidebar + product grid layout.
|
||||
*
|
||||
* Layout:
|
||||
* [Sidebar (sticky, collapsible)] | [Main content (scrollable)]
|
||||
*
|
||||
* Sidebar state:
|
||||
* - Desktop (≥ 1024px): expanded by default
|
||||
* - Mobile (< 1024px): collapsed by default
|
||||
*/
|
||||
function StaffHomePage() {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsSidebarOpen(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setIsSidebarOpen(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] flex-col items-start">
|
||||
{/* ── Body — same as Customer ── */}
|
||||
<main className="min-w-0 w-full flex-1 px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={(q) => setSearchQuery(q)}
|
||||
onClear={() => setSearchQuery("")}
|
||||
placeholder="Search items..."
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<ProductGrid searchQuery={searchQuery} isSidebarOpen={isSidebarOpen} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { user, isInitialized } = useAuth();
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -39,6 +62,7 @@ export default function Home() {
|
||||
{
|
||||
client: eateryClient,
|
||||
fetchPolicy: "no-cache",
|
||||
skip: user?.role === "staff",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -62,6 +86,10 @@ export default function Home() {
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!isInitialized) return <div>Loading...</div>;
|
||||
|
||||
if (user?.role === "staff") return <StaffHomePage />;
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
@@ -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">
|
||||
{/* ── Section heading + search bar ── */}
|
||||
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
{/* Search bar */}
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={(q) => {
|
||||
|
||||
@@ -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 = "Vui lòng nhập họ tên";
|
||||
if (!form.phone.trim()) next.phone = "Vui lòng nhập số điện thoại";
|
||||
else if (!validatePhone(form.phone))
|
||||
next.phone = "Số điện thoại không hợp lệ (vd: 0987654321)";
|
||||
if (!form.password.trim()) next.password = "Vui lòng nhập mật khẩu";
|
||||
else if (form.password.length < 6)
|
||||
next.password = "Mật khẩu phải có ít nhất 6 ký tự";
|
||||
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: "Số điện thoại đã được đăng ký",
|
||||
InvalidPhoneNumber: "Số điện thoại không hợp lệ",
|
||||
InvalidManager: "Không tìm thấy tài khoản manager",
|
||||
};
|
||||
setErrors({
|
||||
...errors,
|
||||
submit: errorMap[errorCode] ?? "Đã có lỗi xảy ra, vui lòng thử lại",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setErrors({ ...errors, submit: "Không thể kết nối, vui lòng thử lại" });
|
||||
} 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)">
|
||||
Tạo tài khoản nhân viên
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Thêm nhân viên mới cho nhà hàng
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{[
|
||||
{
|
||||
id: "name",
|
||||
label: "Họ tên",
|
||||
icon: "fa-user",
|
||||
placeholder: "Nguyễn Văn A",
|
||||
field: "name" as const,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
label: "Số điện thoại",
|
||||
icon: "fa-phone",
|
||||
placeholder: "0987654321",
|
||||
field: "phone" as const,
|
||||
type: "tel",
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
label: "Mật khẩu",
|
||||
icon: "fa-lock",
|
||||
placeholder: "Ít nhất 6 ký tự",
|
||||
field: "password" as const,
|
||||
type: "password",
|
||||
},
|
||||
].map(({ id, label, icon, placeholder, field, type }) => (
|
||||
<div key={id}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i
|
||||
className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}
|
||||
></i>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={form[field]}
|
||||
onChange={handleChange(field)}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 pl-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors[field] && (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{errors[field]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{errors.submit && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<i className="fa-solid fa-circle-exclamation mr-2"></i>
|
||||
{errors.submit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử
|
||||
lý...
|
||||
</>
|
||||
) : (
|
||||
"Tạo tài khoản"
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
Quay lại Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +102,18 @@ export default function ManagerPage() {
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Staff
|
||||
</p>
|
||||
<Link
|
||||
href="/manager/create-staff"
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-user-plus w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Create Staff</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-(--color-border-light) p-3">
|
||||
@@ -188,6 +200,13 @@ export default function ManagerPage() {
|
||||
<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"
|
||||
@@ -270,6 +289,14 @@ export default function ManagerPage() {
|
||||
<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)}
|
||||
@@ -306,6 +333,13 @@ export default function ManagerPage() {
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
Financial Analytics
|
||||
</Link>
|
||||
<Link
|
||||
href="/manager/create-staff"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-user-plus"></i>
|
||||
Create Staff
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
|
||||
@@ -44,12 +44,12 @@ export default function LoginForm() {
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
|
||||
const role = (await res.text().catch(() => "")).trim();
|
||||
const role = (await res.text().catch(() => "")).trim().toLowerCase();
|
||||
|
||||
if (res.ok && (role === "customer" || role === "manager")) {
|
||||
if (res.ok && (role === "customer" || role === "manager" || role === "staff")) {
|
||||
sessionStorage.setItem("login_phone", phone);
|
||||
sessionStorage.setItem("login_role", role);
|
||||
router.push(role === "manager" ? "/login/password" : "/login/otp");
|
||||
router.push(role === "customer" ? "/login/otp" : "/login/password");
|
||||
} else if (res.status === 404) {
|
||||
setErrors({ phone: "", general: "Phone number not registered" });
|
||||
} else {
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function ProductsTab() {
|
||||
className="py-12 text-center text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-mug-hot mb-2 block text-3xl opacity-30"></i>
|
||||
Không tìm thấy món nào
|
||||
<span className="ml-3">Không tìm thấy món nào</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
||||
@@ -7,6 +7,56 @@ import { useEffect, useState } from "react";
|
||||
import { formatDateISO } from "./MonthlyCalendar";
|
||||
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
||||
|
||||
function TimeInput({
|
||||
value,
|
||||
onChange,
|
||||
title,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
title: string;
|
||||
}) {
|
||||
const [h24, m] = value.split(":").map(Number);
|
||||
const isPM = h24 >= 12;
|
||||
const hour12 = h24 === 0 ? 12 : h24 > 12 ? h24 - 12 : h24;
|
||||
|
||||
const emit = (newH12: number, newM: number, newIsPM: boolean) => {
|
||||
let h = newIsPM ? (newH12 === 12 ? 12 : newH12 + 12) : (newH12 === 12 ? 0 : newH12);
|
||||
onChange(`${h.toString().padStart(2, "0")}:${newM.toString().padStart(2, "0")}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center overflow-hidden rounded-xl border border-(--color-border-light) transition focus-within:border-(--color-primary) focus-within:ring-1 focus-within:ring-(--color-primary)">
|
||||
<input
|
||||
title={`${title} hour`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={12}
|
||||
value={hour12}
|
||||
onChange={(e) => emit(Math.min(12, Math.max(1, Number(e.target.value))), m, isPM)}
|
||||
className="text-foreground w-12 border-none bg-transparent py-2.5 pl-3 text-sm outline-none"
|
||||
/>
|
||||
<span className="select-none text-sm text-(--color-text-muted)">:</span>
|
||||
<input
|
||||
title={`${title} minute`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={m.toString().padStart(2, "0")}
|
||||
onChange={(e) => emit(hour12, Math.min(59, Math.max(0, Number(e.target.value))), isPM)}
|
||||
className="text-foreground w-10 border-none bg-transparent py-2.5 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => emit(hour12, m, !isPM)}
|
||||
className="ml-1 mr-2 cursor-pointer rounded-lg border-none bg-(--color-primary)/10 px-2 py-1 text-xs font-bold text-(--color-primary) transition hover:bg-(--color-primary) hover:text-white"
|
||||
>
|
||||
{isPM ? "PM" : "AM"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShiftCreateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -114,24 +164,20 @@ export default function ShiftCreateModal({
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
<TimeInput
|
||||
title="Start Time"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
|
||||
onChange={setStartTime}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
<TimeInput
|
||||
title="End Time"
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
|
||||
onChange={setEndTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,16 @@ import { useState } from "react";
|
||||
|
||||
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
|
||||
|
||||
function parseShiftDate(date: Date | string | undefined): Date | null {
|
||||
if (!date) return null;
|
||||
// Date object → dùng thẳng
|
||||
if (date instanceof Date) return isNaN(date.getTime()) ? null : date;
|
||||
// ISO string hoặc "YYYY-MM-DD" → chỉ lấy 10 ký tự đầu tránh lệch timezone
|
||||
const [y, m, d] = (date as string).slice(0, 10).split("-").map(Number);
|
||||
if (!y || !m || !d) return null;
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export default function ShiftDetailModal({
|
||||
shift,
|
||||
isOpen,
|
||||
@@ -21,10 +31,9 @@ export default function ShiftDetailModal({
|
||||
|
||||
// const dept = DEPARTMENTS.find((d) => d.id === shift.department);
|
||||
const isManager = user?.role === "manager";
|
||||
const isRegistered = user
|
||||
? shift.registeredStaff!.some((s) => s.id === user.id)
|
||||
: false;
|
||||
const isFull = shift.registeredStaff!.length >= shift.maxStaff;
|
||||
const registeredStaff = shift.registeredStaff ?? [];
|
||||
const isRegistered = user ? registeredStaff.some((s) => s.id === user.id) : false;
|
||||
const isFull = registeredStaff.length >= shift.maxStaff;
|
||||
|
||||
const handleRegister = () => {
|
||||
if (!user) return;
|
||||
@@ -117,7 +126,7 @@ export default function ShiftDetailModal({
|
||||
Ngày
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm font-bold">
|
||||
{new Date(shift.date + "T00:00:00").toLocaleDateString(
|
||||
{parseShiftDate(shift.date as Date | string | undefined)?.toLocaleDateString(
|
||||
"vi-VN",
|
||||
{
|
||||
weekday: "long",
|
||||
@@ -125,7 +134,7 @@ export default function ShiftDetailModal({
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
) ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 p-3">
|
||||
@@ -155,16 +164,16 @@ export default function ShiftDetailModal({
|
||||
{/* Registered staff */}
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
||||
Nhân viên đã đăng ký ({shift.registeredStaff!.length}/
|
||||
Nhân viên đã đăng ký ({registeredStaff.length}/
|
||||
{shift.maxStaff})
|
||||
</p>
|
||||
{shift.registeredStaff && shift.registeredStaff.length === 0 ? (
|
||||
{registeredStaff.length === 0 ? (
|
||||
<p className="text-xs text-(--color-text-muted) italic">
|
||||
Chưa có ai đăng ký
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{shift.registeredStaff!.map((staff) => (
|
||||
{registeredStaff.map((staff) => (
|
||||
<div
|
||||
key={staff.id}
|
||||
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
|
||||
|
||||
+71
-32
@@ -44,8 +44,7 @@ interface ShiftContextType {
|
||||
) => { success: boolean; error?: string };
|
||||
unregisterShift: (shiftId: string, staffId: string) => void;
|
||||
createShift: (shift: Omit<ShiftEntity, "id">) => void;
|
||||
updateShift: (shift: ShiftEntity) => void;
|
||||
deleteShift: (shiftId: string) => void;
|
||||
deleteShift: (shiftId: string) => Promise<void>;
|
||||
|
||||
// Helpers
|
||||
getShiftsForDate: (date: Date) => ShiftEntity[];
|
||||
@@ -83,6 +82,14 @@ function formatDate(d: Date): string {
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Dùng local date parts thay vì toISOString() để tránh lệch timezone UTC+7
|
||||
function toLocalDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
function timeToMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
@@ -107,9 +114,11 @@ const GET_ALL_SHIFTS = gql`
|
||||
query allShifts {
|
||||
allShifts {
|
||||
id
|
||||
date
|
||||
startTime
|
||||
endTime
|
||||
maxStaff
|
||||
wage
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -118,13 +127,21 @@ const CREATE_SHIFT = gql`
|
||||
mutation createShift($shiftInput: CreateShiftInput!) {
|
||||
createShift(shiftInput: $shiftInput) {
|
||||
id
|
||||
date
|
||||
startTime
|
||||
endTime
|
||||
maxStaff
|
||||
wage
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DELETE_SHIFT = gql`
|
||||
mutation deleteShift($id: String!) {
|
||||
deleteShift(id: $id)
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
@@ -132,7 +149,7 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setView] = useState<ScheduleView>("week");
|
||||
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,
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
@@ -141,6 +158,10 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
const [mutateDeleteShift] = useMutation<{ deleteShift: boolean }>(DELETE_SHIFT, {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setShifts(data.allShifts);
|
||||
}, [data]);
|
||||
@@ -227,7 +248,7 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
(s) =>
|
||||
s.date === date &&
|
||||
s.id !== excludeShiftId &&
|
||||
s.registeredStaff!.some((rs) => rs.id === staffId) &&
|
||||
(s.registeredStaff ?? []).some((rs) => rs.id === staffId) &&
|
||||
timesOverlap(startTime, endTime, s.startTime, s.endTime),
|
||||
);
|
||||
},
|
||||
@@ -238,9 +259,9 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
const weekDates = getWeekDates();
|
||||
return shifts
|
||||
.filter(
|
||||
(s) => weekDates.includes(s.date) && s.registeredStaff!.length > 0,
|
||||
(s) => weekDates.includes(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]);
|
||||
|
||||
// ── Shift actions ───────────────────────────────────────────────────────
|
||||
@@ -254,11 +275,11 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
const shift = shifts.find((s) => s.id === shiftId);
|
||||
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
|
||||
|
||||
if (shift.registeredStaff!.length >= shift.maxStaff) {
|
||||
if ((shift.registeredStaff ?? []).length >= shift.maxStaff) {
|
||||
return { success: false, error: "Ca làm đã đủ người." };
|
||||
}
|
||||
|
||||
if (shift.registeredStaff!.some((rs) => rs.id === staffId)) {
|
||||
if ((shift.registeredStaff ?? []).some((rs) => rs.id === staffId)) {
|
||||
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
|
||||
}
|
||||
|
||||
@@ -301,7 +322,7 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
setShifts((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== shiftId) return s;
|
||||
const updated = s.registeredStaff!.filter((rs) => rs.id !== staffId);
|
||||
const updated = (s.registeredStaff ?? []).filter((rs) => rs.id !== staffId);
|
||||
return {
|
||||
...s,
|
||||
registeredStaff: updated,
|
||||
@@ -311,31 +332,50 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const createShift = useCallback(async (shift: Omit<ShiftEntity, "id">) => {
|
||||
const { data } = await mutateCreateShift({
|
||||
variables: {
|
||||
shiftInput: {
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
maxStaff: shift.maxStaff,
|
||||
try {
|
||||
const { data } = await mutateCreateShift({
|
||||
variables: {
|
||||
shiftInput: {
|
||||
date: toLocalDateISO(shift.date ?? new Date()),
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
maxStaff: shift.maxStaff,
|
||||
wage: shift.wage,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (data) setShifts((prev) => [...prev, {
|
||||
...data.createShift,
|
||||
date: shift.date,
|
||||
wage: shift.wage,
|
||||
registeredStaff: [],
|
||||
}]);
|
||||
}, [mutateCreateShift]);
|
||||
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) => {
|
||||
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
||||
}, []);
|
||||
|
||||
const deleteShift = useCallback((shiftId: string) => {
|
||||
setShifts((prev) => prev.filter((s) => s.id !== shiftId));
|
||||
}, []);
|
||||
const deleteShift = useCallback(async (shiftId: string) => {
|
||||
try {
|
||||
const { data } = await mutateDeleteShift({
|
||||
variables: { id: shiftId },
|
||||
});
|
||||
if (data?.deleteShift) {
|
||||
setShifts((prev) => prev.filter((s) => s.id !== shiftId));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[deleteShift] mutation failed:", err);
|
||||
}
|
||||
}, [mutateDeleteShift]);
|
||||
|
||||
return (
|
||||
<ShiftContext.Provider
|
||||
@@ -352,7 +392,6 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
registerShift,
|
||||
unregisterShift,
|
||||
createShift,
|
||||
updateShift,
|
||||
deleteShift,
|
||||
getShiftsForDate,
|
||||
getShiftsForWeek,
|
||||
|
||||
Reference in New Issue
Block a user