Compare commits
21 Commits
develop
...
9e42174a2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e42174a2e | |||
| 230dde67b0 | |||
| a753603ce2 | |||
| 378e381454 | |||
| 8e9e48d9b4 | |||
| 43658ace21 | |||
| 393176b98c | |||
| 1c8a23b4d1 | |||
| 9ed4889310 | |||
| b6a0ea694f | |||
| dfcb1b09c0 | |||
| 877c7be84b | |||
| ae8134fd64 | |||
| edbf675997 | |||
| c2afb3d3b5 | |||
| 48fc033ffa | |||
| 8b3b8946ab | |||
| 46d366a662 | |||
| 96584c5494 | |||
| 77f9a11132 | |||
| 60b5233e95 |
@@ -1,5 +0,0 @@
|
||||
## Rules:
|
||||
- Khi làm một tính năng mới, trước khi hoàn thành phải update các file mark down mà thư mục đó được update.
|
||||
- Khi có từ khóa "Yêu cầu" và một list các yêu cầu thì phải hoàn thành ĐÚNG yêu cầu, không thêm không bớt.
|
||||
- Sử dụng thư viện tailwind CSS để code css cho project.
|
||||
- Mỗi feature được update đều phải được responsive với các kích cỡ màn hình như smartphone, tablet, desktop.
|
||||
@@ -0,0 +1,483 @@
|
||||
---
|
||||
name: frontend-atomic-design
|
||||
description: Create modern, responsive frontend components and layouts using atomic design methodology with Tailwind CSS. Use this skill whenever building React components, designing UI layouts, creating responsive pages, or working with atomic design principles. Triggers include: component development, page design, responsive design requests, Tailwind CSS styling, creating design systems, building reusable UI elements, or designing for multiple screen sizes (desktop, tablet, mobile). This skill emphasizes reusable variables, modern aesthetics, and responsive design across all viewport sizes.
|
||||
---
|
||||
|
||||
# Atomic Design Frontend System with Tailwind CSS
|
||||
|
||||
A comprehensive guide for building modern, responsive frontend applications
|
||||
using atomic design principles and Tailwind CSS.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Atomic Design Hierarchy
|
||||
|
||||
Atomic Design breaks UI into five distinct levels:
|
||||
|
||||
#### **Atoms**
|
||||
|
||||
Smallest, indivisible UI elements that cannot be broken down without losing
|
||||
functionality.
|
||||
|
||||
- Buttons, input fields, labels, icons, text styles
|
||||
- Color variables, spacing units, typography scales
|
||||
- Simple, pure, reusable building blocks
|
||||
|
||||
```jsx
|
||||
// Example: Button Atom
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
#### **Molecules**
|
||||
|
||||
Groups of atoms bonded together, forming simple functional units.
|
||||
|
||||
- Search bars (input + button + icon)
|
||||
- Form fields (label + input + error message)
|
||||
- Card headers (avatar + title + subtitle)
|
||||
- Navigation items with icons and labels
|
||||
|
||||
```jsx
|
||||
// Example: Search Molecule
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2">
|
||||
<SearchIcon className="h-5 w-5 text-gray-500" />
|
||||
<input type="text" placeholder="Search..." className="flex-1 outline-none" />
|
||||
</div>
|
||||
```
|
||||
|
||||
#### **Organisms**
|
||||
|
||||
Complex functional units made of groups of molecules and/or atoms.
|
||||
|
||||
- Header/Navigation bars
|
||||
- Form sections (multiple form molecules)
|
||||
- Card layouts with multiple sections
|
||||
- Modals with title, content, and actions
|
||||
- Data tables with headers, rows, and pagination
|
||||
|
||||
```jsx
|
||||
// Example: Product Card Organism
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 shadow-sm transition-shadow hover:shadow-md">
|
||||
<img src="image.jpg" className="h-48 w-full object-cover" />
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold">Product Name</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">Description</p>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="font-bold text-blue-600">$99</span>
|
||||
<button className="rounded bg-blue-600 px-3 py-1 text-white">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### **Templates**
|
||||
|
||||
Page-level wireframes showing layout and component placement without final
|
||||
content.
|
||||
|
||||
- Single-column layouts
|
||||
- Two-column layouts (sidebar + main)
|
||||
- Grid-based layouts
|
||||
- Hero + content sections
|
||||
|
||||
#### **Pages**
|
||||
|
||||
Specific instances of templates populated with real content and data.
|
||||
|
||||
- Homepage with actual products
|
||||
- User profile with real user data
|
||||
- Dashboard with live metrics
|
||||
|
||||
---
|
||||
|
||||
## Design System Variables (Reusable Values)
|
||||
|
||||
### Color Palette
|
||||
|
||||
```css
|
||||
/* Define in Tailwind config or use CSS variables */
|
||||
Primary: #2563eb (blue-600)
|
||||
Secondary: #7c3aed (violet-600)
|
||||
Success: #16a34a (green-600)
|
||||
Warning: #ea580c (orange-600)
|
||||
Danger: #dc2626 (red-600)
|
||||
Neutral: #6b7280 (gray-500)
|
||||
```
|
||||
|
||||
### Typography Scale
|
||||
|
||||
```css
|
||||
H1: 32px (2rem) - font-bold
|
||||
H2: 24px (1.5rem) - font-bold
|
||||
H3: 20px (1.25rem) - font-semibold
|
||||
Body: 16px (1rem) - font-normal
|
||||
Small: 14px (0.875rem) - font-normal
|
||||
Tiny: 12px (0.75rem) - font-normal
|
||||
```
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
xs: 4px (0.25rem)
|
||||
sm: 8px (0.5rem)
|
||||
md: 16px (1rem)
|
||||
lg: 24px (1.5rem)
|
||||
xl: 32px (2rem)
|
||||
2xl: 48px (3rem)
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
|
||||
```css
|
||||
Subtle: 4px (rounded-sm)
|
||||
Standard: 8px (rounded-lg)
|
||||
Large: 12px (rounded-xl)
|
||||
Full: 9999px (rounded-full)
|
||||
```
|
||||
|
||||
### Box Shadows
|
||||
|
||||
```css
|
||||
Subtle: 0 1px 2px rgba(0,0,0,0.05)
|
||||
Soft: 0 4px 6px rgba(0,0,0,0.07)
|
||||
Medium: 0 10px 15px rgba(0,0,0,0.10)
|
||||
Strong: 0 20px 25px rgba(0,0,0,0.15)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Design Strategy
|
||||
|
||||
### Breakpoints (Tailwind Default)
|
||||
|
||||
```
|
||||
Mobile: < 640px (sm)
|
||||
Tablet: 640px (md, lg)
|
||||
Desktop: 1024px+ (xl, 2xl)
|
||||
```
|
||||
|
||||
### Mobile-First Approach
|
||||
|
||||
1. **Start with mobile styles** (default, no prefix)
|
||||
2. **Layer tablet styles** (md: prefix)
|
||||
3. **Layer desktop styles** (lg:, xl: prefix)
|
||||
|
||||
### Example: Responsive Layout
|
||||
|
||||
```jsx
|
||||
// Mobile: 1 column, Tablet: 2 columns, Desktop: 3 columns
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Common Responsive Patterns
|
||||
|
||||
**Responsive Typography**
|
||||
|
||||
```jsx
|
||||
<h1 className="text-2xl font-bold md:text-3xl lg:text-4xl">
|
||||
Responsive Heading
|
||||
</h1>
|
||||
```
|
||||
|
||||
**Responsive Padding/Margins**
|
||||
|
||||
```jsx
|
||||
<div className="p-4 md:p-6 lg:p-8">Content with responsive spacing</div>
|
||||
```
|
||||
|
||||
**Responsive Grid**
|
||||
|
||||
```jsx
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
|
||||
{/* Grid items */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Responsive Flexbox**
|
||||
|
||||
```jsx
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<aside className="w-full md:w-64">Sidebar</aside>
|
||||
<main className="flex-1">Main content</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Responsive Images**
|
||||
|
||||
```jsx
|
||||
<img
|
||||
src="image.jpg"
|
||||
className="h-auto w-full object-cover"
|
||||
alt="Responsive image"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modern Design Patterns
|
||||
|
||||
### 1. Consistency & Visual Hierarchy
|
||||
|
||||
- **Use consistent spacing**: Apply spacing scale uniformly
|
||||
- **Establish clear hierarchy**: Size, weight, color for emphasis
|
||||
- **Group related content**: Use whitespace to separate sections
|
||||
- **Align elements**: Use grids for crisp layouts
|
||||
|
||||
```jsx
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-2xl font-bold">Section Title</h2>
|
||||
<p className="text-gray-600">Description text</p>
|
||||
</div>
|
||||
<div className="space-y-4">{/* Related items with consistent spacing */}</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 2. Interactive Feedback
|
||||
|
||||
- **Hover states**: Subtle color/shadow changes
|
||||
- **Active states**: Indicate current selection
|
||||
- **Focus states**: Keyboard navigation support
|
||||
- **Loading states**: Spinners or skeleton screens
|
||||
- **Transitions**: Smooth animations (200-300ms)
|
||||
|
||||
```jsx
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white transition-all duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none active:scale-95 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
Actionable Button
|
||||
</button>
|
||||
```
|
||||
|
||||
### 3. Depth & Layering
|
||||
|
||||
- **Subtle shadows**: Create depth without heaviness
|
||||
- **Elevation levels**: Consistent shadow progression
|
||||
- **Overlays**: Semi-transparent backgrounds for modals
|
||||
- **Z-index strategy**: Clear layering hierarchy
|
||||
|
||||
```jsx
|
||||
<div className="rounded-lg border border-gray-200 shadow-sm transition-shadow duration-300 hover:shadow-md">
|
||||
Card content
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Color Usage
|
||||
|
||||
- **Primary action**: Most frequent call-to-action
|
||||
- **Secondary action**: Alternative actions
|
||||
- **Semantic colors**: Status indicators (success, warning, danger)
|
||||
- **Contrast**: Ensure WCAG AA compliance (4.5:1 ratio)
|
||||
- **Limited palette**: 3-5 colors maximum in most designs
|
||||
|
||||
### 5. Whitespace & Breathing Room
|
||||
|
||||
- Don't crowd elements
|
||||
- Use consistent gap values (gap-4, gap-6, gap-8)
|
||||
- Separate sections with vertical rhythm
|
||||
- Generous padding in cards and containers
|
||||
|
||||
```jsx
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 md:px-6 md:py-12">
|
||||
<div className="space-y-8">{/* Sections with good breathing room */}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tailwind CSS Best Practices
|
||||
|
||||
### 1. Use Utility Classes Effectively
|
||||
|
||||
```jsx
|
||||
// Good: Semantic, reusable, organized
|
||||
<button className="
|
||||
px-4 py-2
|
||||
bg-blue-600 text-white
|
||||
rounded-lg
|
||||
hover:bg-blue-700
|
||||
transition-colors
|
||||
disabled:opacity-50
|
||||
">
|
||||
Submit
|
||||
</button>
|
||||
|
||||
// Avoid: Too many utilities, hard to read
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200">
|
||||
```
|
||||
|
||||
### 2. Extract Reusable Components
|
||||
|
||||
```jsx
|
||||
// Create a Button component to avoid repetition
|
||||
const Button = ({ children, variant = "primary", ...props }) => {
|
||||
const baseStyles = "px-4 py-2 rounded-lg font-medium transition-colors";
|
||||
const variants = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseStyles} ${variants[variant]}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Organize Utility Classes
|
||||
|
||||
```jsx
|
||||
// Organize by category: layout → sizing → colors → effects → responsive
|
||||
<div className="/* Layout */ /* Sizing */ /* Spacing */ /* Colors & text */ /* Borders & shadows */ /* Effects */ /* Responsive */ my-8 flex w-full max-w-2xl flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 text-gray-900 shadow-sm transition-shadow hover:shadow-md md:flex-row lg:gap-6"></div>
|
||||
```
|
||||
|
||||
### 4. Use Tailwind Config for Consistency
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#2563eb",
|
||||
secondary: "#7c3aed",
|
||||
},
|
||||
spacing: {
|
||||
gutter: "1rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Construction Template
|
||||
|
||||
Use this template when building atomic components:
|
||||
|
||||
```jsx
|
||||
/**
|
||||
* {Component Name}
|
||||
*
|
||||
* Atoms/Molecules/Organisms level component
|
||||
* Purpose: [Brief description]
|
||||
*
|
||||
* Props:
|
||||
* - prop1: type - description
|
||||
* - prop2: type - description
|
||||
*/
|
||||
|
||||
export const ComponentName = ({ prop1, prop2, className = "" }) => {
|
||||
return (
|
||||
<div
|
||||
className={`/* Base styles */ /* Responsive */ /* Custom className */ flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 md:flex-row lg:p-8 ${className} `}
|
||||
>
|
||||
{/* Component content */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout Patterns
|
||||
|
||||
### Container + Padding
|
||||
|
||||
```jsx
|
||||
<div className="mx-auto max-w-6xl px-4 md:px-6 lg:px-8">
|
||||
{/* Content constrained to max width with responsive padding */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Two-Column Sidebar Layout
|
||||
|
||||
```jsx
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<aside className="w-full flex-shrink-0 lg:w-64">
|
||||
{/* Sidebar: full width on mobile, fixed on desktop */}
|
||||
</aside>
|
||||
<main className="min-w-0 flex-1">
|
||||
{/* Main content: takes remaining space */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Responsive Grid
|
||||
|
||||
```jsx
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Items automatically stack on mobile, 2 columns on tablet, 3 on desktop */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hero Section
|
||||
|
||||
```jsx
|
||||
<section className="relative overflow-hidden bg-gradient-to-r from-blue-600 to-violet-600 py-12 text-white md:py-20 lg:py-32">
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-4 md:px-6">
|
||||
<h1 className="mb-4 text-4xl font-bold md:text-5xl lg:text-6xl">
|
||||
Hero Title
|
||||
</h1>
|
||||
<p className="mb-8 max-w-2xl text-lg opacity-90 md:text-xl">
|
||||
Hero subtitle or description
|
||||
</p>
|
||||
<button className="rounded-lg bg-white px-8 py-3 font-semibold text-blue-600 transition-colors hover:bg-gray-100">
|
||||
Call to Action
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility & Best Practices
|
||||
|
||||
1. **Semantic HTML**: Use `<button>`, `<a>`, `<form>`, etc. appropriately
|
||||
2. **Color contrast**: Ensure 4.5:1 for text, 3:1 for UI components
|
||||
3. **Focus states**: Always visible keyboard navigation (`focus:ring-2`)
|
||||
4. **ARIA labels**: Add when needed (`aria-label`, `aria-describedby`)
|
||||
5. **Responsive text**: Use relative sizing, not fixed pixels
|
||||
6. **Touch targets**: Minimum 44x44px for interactive elements
|
||||
|
||||
```jsx
|
||||
<button
|
||||
className="min-h-[44px] px-4 py-3 focus:ring-2 focus:ring-offset-2 focus:outline-none"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Limit custom CSS**: Rely on Tailwind utilities
|
||||
2. **Tree-shake unused styles**: Configure Tailwind content paths
|
||||
3. **Optimize images**: Use responsive images, WebP format
|
||||
4. **Lazy load**: Defer non-critical components
|
||||
5. **Minimize bundle**: Use PurgeCSS in production
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Checklist
|
||||
|
||||
When building a component, ensure:
|
||||
|
||||
- [ ] Follows atomic design hierarchy (Atom/Molecule/Organism)
|
||||
- [ ] Uses design system variables (colors, spacing, typography)
|
||||
- [ ] Responsive across mobile, tablet, desktop
|
||||
- [ ] Consistent hover/active/focus states
|
||||
- [ ] Proper whitespace and visual hierarchy
|
||||
- [ ] Semantic HTML structure
|
||||
- [ ] Accessible (contrast, focus, labels)
|
||||
- [ ] Mobile-first approach
|
||||
- [ ] No hardcoded values (use Tailwind config)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "frontend-atomic-design",
|
||||
"version": "1.0.0",
|
||||
"description": "Create modern, responsive frontend components and layouts using atomic design methodology with Tailwind CSS",
|
||||
"author": "Claude",
|
||||
"created": "2026-04-12"
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## 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"
|
||||
- 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"
|
||||
|
||||
### 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
|
||||
|
||||
## 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)
|
||||
|
||||
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."
|
||||
|
||||
## 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 ✓
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
name: ui-ux-testing
|
||||
description:
|
||||
Automated visual regression testing and UI/UX analysis for web applications.
|
||||
Use this skill whenever developers mention "test this UI", "visual
|
||||
regression", "test the UI", "check this interface", "UI testing", or want to
|
||||
create automated tests for web pages. Analyzes URLs and generates
|
||||
comprehensive test strategies including Playwright/Cypress test scripts,
|
||||
manual testing checklists, visual regression detection, and detailed reports
|
||||
with findings and recommendations.
|
||||
compatibility:
|
||||
tools: Claude in Chrome browser automation
|
||||
frameworks: Playwright, Cypress, Selenium
|
||||
---
|
||||
|
||||
# UI/UX Testing Skill
|
||||
|
||||
This skill helps developers create automated visual regression tests and
|
||||
comprehensive UI/UX testing strategies for web applications.
|
||||
|
||||
## Overview
|
||||
|
||||
When a developer asks you to test a UI or create visual regression tests, this
|
||||
skill guides you through:
|
||||
|
||||
1. **Analyzing the target URL** - Inspect the web page structure and components
|
||||
2. **Generating test strategies** - Create both automated and manual testing
|
||||
approaches
|
||||
3. **Writing test code** - Generate Playwright/Cypress test scripts or Selenium
|
||||
code
|
||||
4. **Creating test checklists** - Manual testing steps for visual regression and
|
||||
UX flows
|
||||
5. **Generating reports** - Detailed findings, issues, and recommendations in
|
||||
markdown/HTML
|
||||
|
||||
## When to Trigger
|
||||
|
||||
Trigger this skill when the user:
|
||||
|
||||
- Provides a URL and asks to "test this UI"
|
||||
- Requests "visual regression testing" for a web page
|
||||
- Wants to "check accessibility" or test a component
|
||||
- Asks to "create automated tests" for a UI
|
||||
- Wants a "testing strategy" or "test plan" for a web application
|
||||
- Mentions QA, testing, or validation of UI components
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Inspect the Target URL
|
||||
|
||||
Use Claude in Chrome to:
|
||||
|
||||
- Navigate to the provided URL
|
||||
- Take screenshots of different viewport sizes (desktop, tablet, mobile)
|
||||
- Inspect the DOM structure using `read_page` tool
|
||||
- Identify key components, interactive elements, and critical flows
|
||||
- Note responsive behavior and CSS properties
|
||||
|
||||
### Step 2: Create a Test Strategy
|
||||
|
||||
Based on your inspection, identify:
|
||||
|
||||
- **Visual elements** to regression test (buttons, forms, headers, layouts)
|
||||
- **Interactive flows** to test (hover states, click handlers, form submission)
|
||||
- **Responsive breakpoints** to validate (mobile, tablet, desktop)
|
||||
- **Accessibility concerns** (ARIA labels, color contrast, keyboard navigation)
|
||||
- **Critical user paths** to validate (common workflows)
|
||||
|
||||
### Step 3: Generate Test Code
|
||||
|
||||
**For Automated Testing (Choose one or more):**
|
||||
|
||||
#### Playwright (Recommended)
|
||||
|
||||
```javascript
|
||||
// Example structure
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("visual regression - homepage", async ({ page }) => {
|
||||
await page.goto("https://example.com");
|
||||
|
||||
// Capture baseline screenshot
|
||||
await expect(page).toHaveScreenshot("homepage.png");
|
||||
|
||||
// Test interactive elements
|
||||
await page.hover("button.primary");
|
||||
await expect(page).toHaveScreenshot("button-hover.png");
|
||||
});
|
||||
|
||||
test("responsive layout - mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("https://example.com");
|
||||
await expect(page).toHaveScreenshot("mobile-layout.png");
|
||||
});
|
||||
```
|
||||
|
||||
#### Cypress
|
||||
|
||||
```javascript
|
||||
describe("Visual Regression Tests", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("https://example.com");
|
||||
});
|
||||
|
||||
it("captures baseline screenshot", () => {
|
||||
cy.screenshot("homepage");
|
||||
cy.get('[data-testid="header"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("tests button hover state", () => {
|
||||
cy.get("button.primary").trigger("mouseenter");
|
||||
cy.screenshot("button-hover-state");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Selenium
|
||||
|
||||
```python
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from PIL import Image
|
||||
|
||||
driver = webdriver.Chrome()
|
||||
driver.get('https://example.com')
|
||||
|
||||
# Capture screenshot
|
||||
driver.save_screenshot('homepage.png')
|
||||
|
||||
# Test responsive
|
||||
driver.set_window_size(375, 812)
|
||||
driver.save_screenshot('mobile-view.png')
|
||||
```
|
||||
|
||||
### Step 4: Create Manual Testing Checklist
|
||||
|
||||
Generate a checklist including:
|
||||
|
||||
- [ ] **Visual Consistency**
|
||||
- [ ] All elements render correctly at 1920x1080
|
||||
- [ ] All elements render correctly at 1366x768
|
||||
- [ ] All elements render correctly at 768x1024 (tablet)
|
||||
- [ ] All elements render correctly at 375x667 (mobile)
|
||||
- [ ] Colors match design specifications
|
||||
- [ ] Typography renders correctly (font families, sizes, weights)
|
||||
- [ ] Images load and display at correct aspect ratios
|
||||
|
||||
- [ ] **Responsive Behavior**
|
||||
- [ ] Layout adapts correctly on mobile (no horizontal scroll)
|
||||
- [ ] Navigation collapses/expands appropriately
|
||||
- [ ] Form inputs are touch-friendly (min 44x44px)
|
||||
- [ ] Content reflows without overlapping
|
||||
|
||||
- [ ] **Interactive Elements**
|
||||
- [ ] All buttons are clickable and have hover states
|
||||
- [ ] Form inputs accept user input
|
||||
- [ ] Dropdowns open/close correctly
|
||||
- [ ] Links are underlined and properly colored
|
||||
|
||||
- [ ] **Accessibility**
|
||||
- [ ] Keyboard navigation works (Tab key)
|
||||
- [ ] Color contrast meets WCAG AA standards
|
||||
- [ ] Images have alt text
|
||||
- [ ] Form labels are associated with inputs
|
||||
|
||||
- [ ] **Critical User Paths**
|
||||
- [ ] [Specific path 1]: [Steps and expected result]
|
||||
- [ ] [Specific path 2]: [Steps and expected result]
|
||||
|
||||
### Step 5: Generate Test Report
|
||||
|
||||
Create an HTML/Markdown report with:
|
||||
|
||||
```markdown
|
||||
# UI/UX Testing Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
- URL tested: [URL]
|
||||
- Viewports tested: Desktop (1920x1080), Tablet (768x1024), Mobile (375x667)
|
||||
- Testing date: [Date]
|
||||
- Total issues found: [Count]
|
||||
|
||||
## Issues Found
|
||||
|
||||
### Critical (Breaks functionality)
|
||||
|
||||
1. **Issue Title**
|
||||
- Severity: Critical
|
||||
- Location: [Element/Component]
|
||||
- Steps to reproduce: [Steps]
|
||||
- Expected: [What should happen]
|
||||
- Actual: [What actually happens]
|
||||
- Screenshot: [If applicable]
|
||||
|
||||
### Major (Significant visual/UX impact)
|
||||
|
||||
1. **Issue Title**
|
||||
- Severity: Major
|
||||
- Location: [Element/Component]
|
||||
- Impact: [User impact]
|
||||
|
||||
### Minor (Polish/optimization)
|
||||
|
||||
1. **Issue Title**
|
||||
- Severity: Minor
|
||||
- Location: [Element/Component]
|
||||
- Recommendation: [Suggestion]
|
||||
|
||||
## Visual Regression Analysis
|
||||
|
||||
### Desktop (1920x1080)
|
||||
|
||||
- [List observations]
|
||||
- [List changes from baseline if available]
|
||||
|
||||
### Tablet (768x1024)
|
||||
|
||||
- [List observations]
|
||||
- [Responsive issues found]
|
||||
|
||||
### Mobile (375x667)
|
||||
|
||||
- [List observations]
|
||||
- [Mobile-specific issues]
|
||||
|
||||
## Accessibility Assessment
|
||||
|
||||
| Element | Issue | WCAG Level | Recommendation |
|
||||
| --------- | ------- | ---------- | -------------- |
|
||||
| [Element] | [Issue] | [AA/AAA] | [Fix] |
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **High Priority**
|
||||
- [Recommendation with rationale]
|
||||
|
||||
2. **Medium Priority**
|
||||
- [Recommendation with rationale]
|
||||
|
||||
3. **Low Priority**
|
||||
- [Recommendation with rationale]
|
||||
|
||||
## Test Coverage Summary
|
||||
|
||||
- Automated tests needed: [Count and types]
|
||||
- Manual test cases: [Count]
|
||||
- Estimated testing effort: [Time estimate]
|
||||
- Regression risk: [High/Medium/Low]
|
||||
|
||||
---
|
||||
|
||||
Generated using UI/UX Testing Skill
|
||||
```
|
||||
|
||||
## Output Options
|
||||
|
||||
Based on what the developer needs, generate:
|
||||
|
||||
1. **Test Code Only** - Playwright/Cypress/Selenium scripts ready to integrate
|
||||
2. **Testing Strategy** - Comprehensive checklist and manual test plan
|
||||
3. **Full Report** - Screenshots, findings, issues, and automated test code
|
||||
4. **All of the above** - Complete testing package
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Multiple viewports:** Always test at least mobile (375px), tablet (768px),
|
||||
and desktop (1920px)
|
||||
- **Visual baselines:** Save baseline screenshots before making changes
|
||||
- **Critical paths:** Prioritize testing main user workflows first
|
||||
- **Accessibility first:** Include WCAG AA compliance checks
|
||||
- **Clear assertions:** Make test assertions explicit and meaningful
|
||||
- **Maintainability:** Use data attributes (data-testid) for reliable element
|
||||
selection
|
||||
|
||||
## Example: Complete Testing Session
|
||||
|
||||
1. Developer provides URL: "https://myapp.com/dashboard"
|
||||
2. You inspect the page (screenshots, DOM, responsive behavior)
|
||||
3. You identify:
|
||||
- Dashboard header with navigation
|
||||
- Data table with sorting/filtering
|
||||
- Form for creating items
|
||||
- Mobile menu collapse
|
||||
4. You generate:
|
||||
- Playwright tests for visual regression
|
||||
- Manual testing checklist
|
||||
- HTML report with findings
|
||||
5. Developer receives complete testing artifact ready to use
|
||||
|
||||
## Tips for Success
|
||||
|
||||
- Take screenshots at each viewport to catch responsive issues
|
||||
- Test interactive states (hover, focus, active, disabled)
|
||||
- Verify critical user journeys end-to-end
|
||||
- Include accessibility testing automatically
|
||||
- Provide both automated (code) and manual (checklist) approaches
|
||||
- Make reports actionable with clear severity levels and recommendations
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2024 **Skill Version:** 1.0
|
||||
@@ -0,0 +1,264 @@
|
||||
---
|
||||
name: website-creation-automation
|
||||
description:
|
||||
Automate end-to-end website creation with AI-powered optimization, design, and
|
||||
testing. Trigger this skill whenever the user wants to create a new website,
|
||||
build a landing page, design a web application, or automatically generate a
|
||||
complete website from a description. This skill takes a user's website idea or
|
||||
prompt, optimizes it for clarity, designs a modern responsive website, and
|
||||
automatically tests it for quality issues. Use this skill for any "create a
|
||||
website" or "build a web application" request, including landing pages,
|
||||
portfolio sites, e-commerce pages, dashboards, or any web-based project.
|
||||
compatibility:
|
||||
models:
|
||||
- claude-sonnet-4-20250514 (design and testing)
|
||||
- claude-haiku-4.5-20251001 (bug fixes)
|
||||
required_skills:
|
||||
- prompt-optimizer
|
||||
- frontend-atomic-design
|
||||
- ui-ux-testing
|
||||
---
|
||||
|
||||
# Website Creation Automation Skill
|
||||
|
||||
An intelligent, end-to-end automation workflow for creating complete, tested
|
||||
websites from simple prompts.
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
This skill orchestrates a complete website creation pipeline:
|
||||
|
||||
1. **Prompt Optimization** — Takes your website description and optimizes it
|
||||
into a detailed, structured prompt
|
||||
2. **Website Design** — Uses the optimized prompt to design and build a modern,
|
||||
responsive website
|
||||
3. **Automated Testing** — Tests the generated website for visual, interactive,
|
||||
and responsive issues
|
||||
4. **Auto-Fix** — Detects and fixes any issues found during testing
|
||||
|
||||
The entire workflow is automated, so you just provide a simple description of
|
||||
what you want, and the skill handles the rest.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### Step 1: Optimize Your Website Prompt
|
||||
|
||||
Your initial description (e.g., "Create a portfolio website for a freelance
|
||||
designer") is passed to the **prompt-optimizer** skill, which:
|
||||
|
||||
- Adds specific details about layout, features, and target audience
|
||||
- Clarifies design preferences and functionality requirements
|
||||
- Structures the request to guide high-quality website generation
|
||||
- Returns a detailed, optimized prompt ready for design
|
||||
|
||||
**Example transformation:**
|
||||
|
||||
```
|
||||
Input: "Create a portfolio website for a freelance designer"
|
||||
↓
|
||||
Output: "Create a modern portfolio website for a freelance graphic designer.
|
||||
Include: hero section with featured work, project showcase grid (6-8 projects),
|
||||
about section, services list, client testimonials, contact form, and footer.
|
||||
Target audience: potential clients and collaborators. Design should be minimalist
|
||||
with emphasis on visual work. Mobile-responsive. Use modern sans-serif typography
|
||||
and white space. Include smooth scroll animations."
|
||||
```
|
||||
|
||||
### Step 2: Design the Website
|
||||
|
||||
The **frontend-atomic-design** skill uses the optimized prompt to:
|
||||
|
||||
- Break down the website into atomic components (atoms, molecules, organisms)
|
||||
- Create a responsive layout that works on desktop, tablet, and mobile
|
||||
- Apply modern design patterns using Tailwind CSS
|
||||
- Build interactive elements and proper semantic HTML
|
||||
- Generate a complete, production-ready HTML file (or React component)
|
||||
|
||||
**Outputs:**
|
||||
|
||||
- Full HTML file with embedded CSS and JavaScript
|
||||
- All assets (icons, fonts) are self-contained
|
||||
- Responsive design with mobile-first approach
|
||||
- Accessible markup with semantic HTML5
|
||||
|
||||
### Step 3: Test the Website
|
||||
|
||||
The **ui-ux-testing** skill performs comprehensive testing:
|
||||
|
||||
- Visual regression testing (captures baseline screenshots)
|
||||
- Responsive layout validation (mobile, tablet, desktop)
|
||||
- Interactive element testing (buttons, forms, links)
|
||||
- Accessibility checking (color contrast, keyboard navigation)
|
||||
- Cross-browser compatibility assessment
|
||||
- User flow validation
|
||||
|
||||
**Testing output includes:**
|
||||
|
||||
- Screenshots from multiple viewport sizes
|
||||
- Detailed findings and issues detected
|
||||
- Visual regression comparison
|
||||
- Recommendations for improvements
|
||||
|
||||
### Step 4: Auto-Fix Detected Issues
|
||||
|
||||
Any issues detected in testing are automatically fixed:
|
||||
|
||||
- **Using Haiku 4.5** — A faster model optimized for targeted fixes
|
||||
- **HTML-only fixes** — Modifications to structure, styling, or interactivity
|
||||
- **Preserves design intent** — Fixes maintain the original design aesthetic
|
||||
- **Re-validates** — Quick verification that fixes resolved the issues
|
||||
|
||||
**Common fixes include:**
|
||||
|
||||
- Correcting responsive behavior issues
|
||||
- Fixing accessibility problems
|
||||
- Adjusting spacing, alignment, or colors
|
||||
- Improving interactive element behavior
|
||||
- Ensuring all content is properly visible
|
||||
|
||||
---
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill whenever you want to:
|
||||
|
||||
- **Create a new website** from scratch based on a description
|
||||
- **Build a landing page** for a product, service, or event
|
||||
- **Design a portfolio site** to showcase your work
|
||||
- **Create an e-commerce page** with product displays
|
||||
- **Build a dashboard or web app** UI
|
||||
- **Generate a multi-page website** (returns first page, can iterate)
|
||||
- **Prototype a website concept** quickly
|
||||
|
||||
## How to Trigger This Skill
|
||||
|
||||
Simply provide:
|
||||
|
||||
1. **Website description** — What kind of website you want (e.g., "e-commerce
|
||||
store for handmade jewelry", "SaaS landing page", "restaurant menu website")
|
||||
2. **Optional details** — Any specific requirements (colors, features, tone,
|
||||
audience)
|
||||
|
||||
The skill handles everything else automatically.
|
||||
|
||||
## Example Usage
|
||||
|
||||
**User prompt:** "Create a landing page for a sustainable fashion startup called
|
||||
EcoStitch. Include a hero section, features of our eco-friendly materials,
|
||||
pricing plans, customer testimonials, and a newsletter signup."
|
||||
|
||||
**Skill processes:**
|
||||
|
||||
1. Optimizes prompt with specific design details and layout structure
|
||||
2. Designs a modern, responsive landing page with all requested sections
|
||||
3. Tests layout across mobile/tablet/desktop, forms, links, and visual design
|
||||
4. Fixes any responsive or interactive issues found
|
||||
5. Returns production-ready HTML file
|
||||
|
||||
**Final output:** A complete, tested, bug-free website ready to deploy or
|
||||
customize further.
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Model Usage
|
||||
|
||||
- **Sonnet 4.6** — Used for optimization, design, and testing (high-quality
|
||||
complex tasks)
|
||||
- **Haiku 4.5** — Used for bug fixes only (fast, targeted improvements)
|
||||
|
||||
### Skill Integration
|
||||
|
||||
This skill coordinates three core skills in sequence:
|
||||
|
||||
```
|
||||
User Prompt
|
||||
↓
|
||||
[prompt-optimizer] → Optimized Prompt
|
||||
↓
|
||||
[frontend-atomic-design] → HTML/React Website
|
||||
↓
|
||||
[ui-ux-testing] → Test Results + Screenshots
|
||||
↓
|
||||
[Bug Fix Loop] → Fixed HTML/React Website
|
||||
↓
|
||||
Final Website (Ready to Use)
|
||||
```
|
||||
|
||||
### Output Format
|
||||
|
||||
The final website is delivered as:
|
||||
|
||||
- **HTML file** — Self-contained with CSS and JavaScript embedded
|
||||
- **Screenshots** — Before/after testing comparison
|
||||
- **Test report** — Issues found and fixes applied
|
||||
- **Deployment ready** — Can be hosted on any static hosting service
|
||||
|
||||
---
|
||||
|
||||
## Limitations & Notes
|
||||
|
||||
- **Single-page output** — Generates one complete page (though can be expanded
|
||||
to multi-page)
|
||||
- **Static by default** — Returns HTML; can generate React components if needed
|
||||
- **Database-free** — Forms are functional but don't store data without backend
|
||||
integration
|
||||
- **Rapid iteration** — If you want to modify the result, you can iterate by
|
||||
running the skill again with updated requirements
|
||||
|
||||
---
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
1. **Be descriptive** — More detail in your initial prompt leads to better
|
||||
results
|
||||
2. **Specify audience** — Who is this website for? (target customers, users,
|
||||
etc.)
|
||||
3. **Include features** — What should the website do? (e.g., showcase products,
|
||||
collect emails, etc.)
|
||||
4. **Mention style** — Any aesthetic preferences? (minimalist, colorful,
|
||||
corporate, playful, etc.)
|
||||
5. **Test thoroughly** — Review the testing results to ensure the site meets
|
||||
your needs
|
||||
|
||||
---
|
||||
|
||||
## Workflow Diagram
|
||||
|
||||
```
|
||||
START
|
||||
↓
|
||||
Input: Website Description
|
||||
↓
|
||||
[Call: prompt-optimizer skill]
|
||||
↓
|
||||
Receive: Optimized Detailed Prompt
|
||||
↓
|
||||
[Call: frontend-atomic-design skill]
|
||||
↓
|
||||
Receive: HTML/React Website Code
|
||||
↓
|
||||
[Call: ui-ux-testing skill]
|
||||
↓
|
||||
Receive: Test Results + Issues Found
|
||||
↓
|
||||
Are there critical issues?
|
||||
├─ YES → [Use Haiku 4.5 to fix] → Re-test
|
||||
├─ NO → Proceed
|
||||
↓
|
||||
COMPLETE: Return Website + Test Report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential expansions to this skill:
|
||||
|
||||
- Multi-page website generation (homepage, about, services, contact, etc.)
|
||||
- CMS integration (connect to content management systems)
|
||||
- Backend API scaffolding (Node.js/Express templates)
|
||||
- SEO optimization (meta tags, structured data, open graph)
|
||||
- Analytics integration (Google Analytics, Mixpanel)
|
||||
- E-commerce integration (payment processing, inventory)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/muhmdraouf/devcontainers-features/alpine-apk:0": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "ghcr.io/muhmdraouf/devcontainers-features/alpine-apk@sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044",
|
||||
"integrity": "sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["esbenp.prettier-vscode"]
|
||||
"extensions": ["esbenp.prettier-vscode", "anthropic.claude-code"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
"forwardPorts": [3000]
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "uname -a",
|
||||
|
||||
@@ -22,9 +22,16 @@ jobs:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: gerlero/apt-install@v1
|
||||
with:
|
||||
packages: docker.io zip
|
||||
- name: Install dependencies with Proxy
|
||||
env:
|
||||
http_proxy: "http://host.docker.internal:3142"
|
||||
https_proxy: "http://host.docker.internal:3142"
|
||||
run: |
|
||||
# Kiểm tra proxy có hoạt động không
|
||||
echo "Acquire::http::Proxy \"$http_proxy\";" > /etc/apt/apt.conf.d/80proxy
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends --no-install-suggests \
|
||||
docker-cli zip docker-buildx
|
||||
|
||||
- name: Setup SSH Key
|
||||
run: |
|
||||
@@ -41,9 +48,21 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
version: 10.9.0
|
||||
run_install: false
|
||||
cache: true
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Update dependencies
|
||||
run: pnpm update
|
||||
@@ -76,7 +95,6 @@ jobs:
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Prepare Docker Metadata
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
id: meta
|
||||
run: |
|
||||
# Lowercase Repository
|
||||
@@ -89,11 +107,10 @@ jobs:
|
||||
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
push: ${{ github.ref_name == 'main' }}
|
||||
tags: |
|
||||
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
||||
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -1,762 +0,0 @@
|
||||
# ATOMIC DESIGN STRUCTURE GUIDE
|
||||
|
||||
## Overview
|
||||
|
||||
Project sử dụng **Atomic Design Pattern** để tổ chức UI components theo 5 cấp
|
||||
độ:
|
||||
|
||||
1. **Atoms** - Khối xây dựng cơ bản, không thể chia nhỏ hơn
|
||||
2. **Molecules** - Nhóm atoms đơn giản hoạt động cùng nhau
|
||||
3. **Organisms** - Khu vực UI phức tạp, riêng biệt
|
||||
4. **Templates** - Bố cục cấp trang, cấu trúc nội dung
|
||||
5. **Pages** - Các phiên bản cụ thể với dữ liệu thật
|
||||
|
||||
---
|
||||
|
||||
## 1) ATOMS (`components/atoms/`)
|
||||
|
||||
**Mục đích:** Khối xây dựng cơ bản, tái sử dụng cao, không phụ thuộc logic phức
|
||||
tạp.
|
||||
|
||||
Không có context/hooks logic phức tạp, chỉ nhận props từ parent.
|
||||
|
||||
### Cấu trúc thư mục
|
||||
|
||||
```
|
||||
components/atoms/
|
||||
├── buttons/
|
||||
│ ├── Button.tsx # Nút cơ bản (primary, secondary, danger)
|
||||
│ ├── IconButton.tsx # Nút chỉ có icon
|
||||
│ └── Button.types.ts # Props types
|
||||
├── inputs/
|
||||
│ ├── TextInput.tsx # Text input cơ bản
|
||||
│ ├── NumberInput.tsx # Number input với up/down
|
||||
│ ├── Checkbox.tsx # Checkbox
|
||||
│ └── Input.types.ts # Props types
|
||||
├── badges/
|
||||
│ ├── Badge.tsx # Badge cơ bản (color variants)
|
||||
│ ├── PriceBadge.tsx # Badge hiển thị giá
|
||||
│ └── Badge.types.ts # Props types
|
||||
├── icons/
|
||||
│ ├── StarIcon.tsx # Rating star icon
|
||||
│ ├── CartIcon.tsx # Shopping cart icon
|
||||
│ ├── SearchIcon.tsx # Search icon
|
||||
│ └── icons.types.ts # Props types
|
||||
├── typography/
|
||||
│ ├── Heading.tsx # h1-h6 headings
|
||||
│ ├── Text.tsx # Body text variants
|
||||
│ ├── Caption.tsx # Small caption text
|
||||
│ └── Typography.types.ts # Props types
|
||||
├── dividers/
|
||||
│ ├── Divider.tsx # Horizontal divider
|
||||
│ └── Divider.types.ts # Props types
|
||||
├── loaders/
|
||||
│ ├── Spinner.tsx # Loading spinner
|
||||
│ ├── Skeleton.tsx # Skeleton loader
|
||||
│ └── Loader.types.ts # Props types
|
||||
└── index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Ví dụ Atoms
|
||||
|
||||
**Button.tsx:**
|
||||
|
||||
```tsx
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
|
||||
import type { ButtonProps } from "./Button.types";
|
||||
|
||||
export default function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
disabled = false,
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"font-semibold rounded-lg transition-colors disabled:opacity-50";
|
||||
const variants = {
|
||||
primary:
|
||||
"bg-[color:var(--color-primary)] text-white hover:bg-[color:var(--color-primary-dark)]",
|
||||
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600",
|
||||
};
|
||||
const sizes = {
|
||||
sm: "px-3 py-1 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Button.types.ts:**
|
||||
|
||||
```tsx
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) MOLECULES (`components/molecules/`)
|
||||
|
||||
**Mục đích:** Nhóm atoms tạo thành những UI unit nhỏ, tái sử dụng, có logic đơn
|
||||
giản.
|
||||
|
||||
Có thể sử dụng `useState`, nhưng logic chủ yếu nằm ở parent component.
|
||||
|
||||
### Cấu trúc thư mục
|
||||
|
||||
```
|
||||
components/molecules/
|
||||
├── form-groups/
|
||||
│ ├── FormField.tsx # Input + label + error message
|
||||
│ ├── FormGroup.tsx # Label + input wrapper
|
||||
│ └── FormGroup.types.ts # Props types
|
||||
├── cards/
|
||||
│ ├── ProductCard.tsx # Card hiển thị sản phẩm (image + name + price + btn)
|
||||
│ ├── ShopCard.tsx # Card hiển thị quán
|
||||
│ ├── ReviewCard.tsx # Card hiển thị review
|
||||
│ └── Card.types.ts # Props types
|
||||
├── ratings/
|
||||
│ ├── RatingStars.tsx # Hiển thị 5 sao rating
|
||||
│ ├── RatingInput.tsx # Input 5 sao (interactive)
|
||||
│ └── Rating.types.ts # Props types
|
||||
├── price-display/
|
||||
│ ├── PriceTag.tsx # Hiển thị giá formatted
|
||||
│ ├── PriceRange.tsx # Hiển thị range giá
|
||||
│ └── Price.types.ts # Props types
|
||||
├── search-bar/
|
||||
│ ├── SearchInput.tsx # Search input với icon
|
||||
│ ├── SearchBar.tsx # Search bar wrapper
|
||||
│ └── Search.types.ts # Props types
|
||||
├── breadcrumb/
|
||||
│ ├── Breadcrumb.tsx # Breadcrumb navigation
|
||||
│ └── Breadcrumb.types.ts # Props types
|
||||
├── tabs/
|
||||
│ ├── TabGroup.tsx # Tabs wrapper
|
||||
│ ├── Tab.tsx # Individual tab
|
||||
│ └── Tabs.types.ts # Props types
|
||||
└── index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Ví dụ Molecules
|
||||
|
||||
**ProductCard.tsx:**
|
||||
|
||||
```tsx
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import Text from "@/components/atoms/typography/Text";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { ProductCardProps } from "./Card.types";
|
||||
|
||||
export default function ProductCard({
|
||||
product,
|
||||
onAddToCart,
|
||||
}: ProductCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[color:var(--color-border)] bg-[color:var(--color-bg-card)] p-4 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="relative mb-3 h-48 w-full overflow-hidden rounded-md">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<Text variant="body1" className="font-semibold">
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
className="text-[color:var(--color-text-secondary)]"
|
||||
>
|
||||
{product.description}
|
||||
</Text>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<Text
|
||||
variant="body2"
|
||||
className="font-bold text-[color:var(--color-primary)]"
|
||||
>
|
||||
${product.price.toFixed(2)}
|
||||
</Text>
|
||||
<Button size="sm" onClick={() => onAddToCart(product)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) ORGANISMS (`components/organisms/`)
|
||||
|
||||
**Mục đích:** Khu vực UI phức tạp, độc lập, có logic riêng.
|
||||
|
||||
Kết hợp multiple molecules/atoms, có thể sử dụng contexts (useAuth, useCart,
|
||||
etc.), state phức tạp.
|
||||
|
||||
### Cấu trúc thư mục
|
||||
|
||||
```
|
||||
components/organisms/
|
||||
├── navigation/
|
||||
│ ├── Navbar.tsx # Sidebar category filter (cũ CartProduct)
|
||||
│ ├── CategoryMenu.tsx # Category menu wrapper
|
||||
│ └── Navigation.types.ts # Props types
|
||||
├── cart/
|
||||
│ ├── CartFab.tsx # Floating action button giỏ hàng
|
||||
│ ├── CartSummary.tsx # Cart summary widget
|
||||
│ ├── CartList.tsx # Danh sách sản phẩm trong giỏ
|
||||
│ └── Cart.types.ts # Props types
|
||||
├── product-grid/
|
||||
│ ├── ProductGrid.tsx # Grid hiển thị danh sách sản phẩm
|
||||
│ ├── ProductFilters.tsx # Bộ lọc sản phẩm (category, price, rating)
|
||||
│ └── ProductGrid.types.ts # Props types
|
||||
├── forms/
|
||||
│ ├── LoginForm.tsx # Form đăng nhập (username + password + submit)
|
||||
│ ├── RegisterForm.tsx # Form đăng ký
|
||||
│ ├── CheckoutForm.tsx # Form thanh toán
|
||||
│ ├── ReviewForm.tsx # Form đánh giá (modal content)
|
||||
│ └── Forms.types.ts # Props types
|
||||
├── modals/
|
||||
│ ├── ReviewModal.tsx # Modal đánh giá (header + form + footer)
|
||||
│ ├── ConfirmModal.tsx # Modal xác nhận generic
|
||||
│ └── Modal.types.ts # Props types
|
||||
├── shop-grid/
|
||||
│ ├── ShopGrid.tsx # Grid hiển thị danh sách quán
|
||||
│ ├── ShopFilters.tsx # Bộ lọc quán (location, rating)
|
||||
│ └── ShopGrid.types.ts # Props types
|
||||
├── hero-section/
|
||||
│ ├── HeroSection.tsx # Banner hero cấp trang
|
||||
│ └── Hero.types.ts # Props types
|
||||
├── featured-section/
|
||||
│ ├── FeaturedProducts.tsx # Section sản phẩm nổi bật
|
||||
│ ├── FeaturedShops.tsx # Section quán nổi bật
|
||||
│ └── Featured.types.ts # Props types
|
||||
└── index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Ví dụ Organisms
|
||||
|
||||
**ProductGrid.tsx:**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import ProductCard from "@/components/molecules/cards/ProductCard";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { MOCK_PRODUCTS } from "@/lib/constants";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
|
||||
import type { ProductGridProps } from "./ProductGrid.types";
|
||||
|
||||
export default function ProductGrid({ searchQuery = "" }: ProductGridProps) {
|
||||
const { activeCategory } = useMenu();
|
||||
const { addToCart } = useCart();
|
||||
|
||||
const filtered = MOCK_PRODUCTS.filter((product) => {
|
||||
const matchCategory =
|
||||
activeCategory === "all" || product.category === activeCategory;
|
||||
const matchSearch =
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
product.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchCategory && matchSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{filtered.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={addToCart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) TEMPLATES (`components/templates/`)
|
||||
|
||||
**Mục đích:** Bố cục cấp trang, cấu trúc nội dung, không có data cụ thể.
|
||||
|
||||
Chứa layout và structure của page, nhưng data được truyền từ page component.
|
||||
|
||||
### Cấu trúc thư mục
|
||||
|
||||
```
|
||||
components/templates/
|
||||
├── main-layout/
|
||||
│ ├── MainLayout.tsx # Layout chính (header + sidebar + content + footer)
|
||||
│ ├── MainLayout.types.ts # Props types
|
||||
│ └── styles.ts # Responsive grid layout logic
|
||||
├── feed-layout/
|
||||
│ ├── FeedLayout.tsx # Layout feed (khám phá quán)
|
||||
│ └── FeedLayout.types.ts # Props types
|
||||
├── manager-layout/
|
||||
│ ├── ManagerLayout.tsx # Layout manager dashboard
|
||||
│ └── ManagerLayout.types.ts # Props types
|
||||
├── checkout-layout/
|
||||
│ ├── CheckoutLayout.tsx # Layout thanh toán (steps, cart, form)
|
||||
│ └── CheckoutLayout.types.ts # Props types
|
||||
├── auth-layout/
|
||||
│ ├── AuthLayout.tsx # Layout auth (login/register)
|
||||
│ └── AuthLayout.types.ts # Props types
|
||||
└── index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Ví dụ Templates
|
||||
|
||||
**MainLayout.tsx:**
|
||||
|
||||
```tsx
|
||||
import Navbar from "@/components/organisms/navigation/Navbar";
|
||||
import Footer from "@/layouts/footer";
|
||||
import Header from "@/layouts/header";
|
||||
|
||||
import type { MainLayoutProps } from "./MainLayout.types";
|
||||
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<div className="flex flex-1">
|
||||
{/* Sidebar - ẩn trên mobile */}
|
||||
<nav className="hidden w-64 border-r border-[color:var(--color-border)] bg-[color:var(--color-bg-sidebar)] md:block">
|
||||
<Navbar />
|
||||
</nav>
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-4 md:p-6">{children}</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) PAGES (`app/*/page.tsx`)
|
||||
|
||||
**Mục đích:** Các phiên bản cụ thể với dữ liệu thật, logic cấp trang.
|
||||
|
||||
Server/client components sử dụng templates, organisms, nhận data từ API/context.
|
||||
|
||||
### Ví dụ Pages
|
||||
|
||||
**app/(main)/page.tsx:**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import FeaturedSection from "@/components/organisms/featured-section/FeaturedSection";
|
||||
import ProductGrid from "@/components/organisms/product-grid/ProductGrid";
|
||||
import MainLayout from "@/components/templates/main-layout/MainLayout";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function MainPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<FeaturedSection />
|
||||
<div className="mt-8">
|
||||
<ProductGrid searchQuery={searchQuery} />
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) File Hierarchy Summary
|
||||
|
||||
```
|
||||
components/
|
||||
├── atoms/
|
||||
│ ├── buttons/
|
||||
│ ├── inputs/
|
||||
│ ├── badges/
|
||||
│ ├── icons/
|
||||
│ ├── typography/
|
||||
│ ├── dividers/
|
||||
│ ├── loaders/
|
||||
│ └── index.ts
|
||||
├── molecules/
|
||||
│ ├── form-groups/
|
||||
│ ├── cards/
|
||||
│ ├── ratings/
|
||||
│ ├── price-display/
|
||||
│ ├── search-bar/
|
||||
│ ├── breadcrumb/
|
||||
│ ├── tabs/
|
||||
│ └── index.ts
|
||||
├── organisms/
|
||||
│ ├── navigation/
|
||||
│ ├── cart/
|
||||
│ ├── product-grid/
|
||||
│ ├── forms/
|
||||
│ ├── modals/
|
||||
│ ├── shop-grid/
|
||||
│ ├── hero-section/
|
||||
│ ├── featured-section/
|
||||
│ └── index.ts
|
||||
├── templates/
|
||||
│ ├── main-layout/
|
||||
│ ├── feed-layout/
|
||||
│ ├── manager-layout/
|
||||
│ ├── checkout-layout/
|
||||
│ ├── auth-layout/
|
||||
│ └── index.ts
|
||||
└── ATOMIC_DESIGN.md (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Migration Guide (Old → New)
|
||||
|
||||
### Old Structure → New Structure Mapping
|
||||
|
||||
| Old File | New Location | Type |
|
||||
| ----------------- | ---------------------------------- | -------- |
|
||||
| `CartProduct.tsx` | `molecules/cards/ProductCard.tsx` | Molecule |
|
||||
| `Navbar.tsx` | `organisms/navigation/Navbar.tsx` | Organism |
|
||||
| `CartFab.tsx` | `organisms/cart/CartFab.tsx` | Organism |
|
||||
| `ReviewModal.tsx` | `organisms/modals/ReviewModal.tsx` | Organism |
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. Create new directory structure under `components/`
|
||||
2. Move existing components to appropriate levels (atoms → molecules →
|
||||
organisms)
|
||||
3. Extract shared styles/logic into atoms
|
||||
4. Update imports in `app/` pages
|
||||
5. Test responsiveness at each breakpoint
|
||||
6. Update `COMPONENTS.md` with new structure
|
||||
|
||||
---
|
||||
|
||||
## 8) Best Practices
|
||||
|
||||
### Atoms Development
|
||||
|
||||
- ✅ Reusable across entire project
|
||||
- ✅ No business logic
|
||||
- ✅ No context/hooks (useAuth, useCart)
|
||||
- ✅ Pure props-based
|
||||
- ✅ Full TypeScript typing
|
||||
- ❌ No "use client" needed (unless interactive, e.g., Button)
|
||||
|
||||
### Molecules Development
|
||||
|
||||
- ✅ Combines multiple atoms
|
||||
- ✅ Simple state (open/close, hover state)
|
||||
- ✅ No complex business logic
|
||||
- ✅ Can use useState for UI state
|
||||
- ✅ Reusable in multiple contexts
|
||||
- ❌ No global state (useAuth, useCart)
|
||||
|
||||
### Organisms Development
|
||||
|
||||
- ✅ Complex UI sections
|
||||
- ✅ Can use contexts (useAuth, useCart, useMenu)
|
||||
- ✅ Business logic
|
||||
- ✅ Always "use client"
|
||||
- ✅ Filter, sort, complex interactions
|
||||
- ❌ Not reusable across different page types
|
||||
|
||||
### Templates Development
|
||||
|
||||
- ✅ Page layout structure
|
||||
- ✅ Composition of organisms + layout
|
||||
- ✅ No data fetching/business logic
|
||||
- ✅ Children prop pattern
|
||||
- ✅ Props for customization
|
||||
- ❌ No hardcoded data
|
||||
|
||||
### Pages Development
|
||||
|
||||
- ✅ Specific page implementations
|
||||
- ✅ Route-specific logic
|
||||
- ✅ Data integration
|
||||
- ✅ Context usage at page level
|
||||
- ✅ State management orchestration
|
||||
- ❌ No UI component definitions (use organisms)
|
||||
|
||||
---
|
||||
|
||||
## 9) Import Patterns
|
||||
|
||||
### Atoms
|
||||
|
||||
```tsx
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import Text from "@/components/atoms/typography/Text";
|
||||
```
|
||||
|
||||
### Molecules
|
||||
|
||||
```tsx
|
||||
import ProductCard from "@/components/molecules/cards/ProductCard";
|
||||
import FormField from "@/components/molecules/form-groups/FormField";
|
||||
```
|
||||
|
||||
### Organisms
|
||||
|
||||
```tsx
|
||||
import LoginForm from "@/components/organisms/forms/LoginForm";
|
||||
import ProductGrid from "@/components/organisms/product-grid/ProductGrid";
|
||||
```
|
||||
|
||||
### Templates
|
||||
|
||||
```tsx
|
||||
import MainLayout from "@/components/templates/main-layout/MainLayout";
|
||||
```
|
||||
|
||||
### Barrel Exports
|
||||
|
||||
```tsx
|
||||
// Usage
|
||||
import { Button, Text } from "@/components/atoms";
|
||||
|
||||
// components/atoms/index.ts
|
||||
export { default as Button } from "./buttons/Button";
|
||||
export { default as Text } from "./typography/Text";
|
||||
export * from "./buttons/Button.types";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Common Patterns
|
||||
|
||||
### Creating a New Atom
|
||||
|
||||
```tsx
|
||||
// atoms/buttons/NewButton.tsx
|
||||
export default function NewButton({ variant, ...props }: Props) {
|
||||
return <button className={styles[variant]} {...props} />;
|
||||
}
|
||||
|
||||
// atoms/buttons/NewButton.types.ts
|
||||
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant: "type1" | "type2";
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a New Molecule
|
||||
|
||||
```tsx
|
||||
// molecules/cards/NewCard.tsx
|
||||
export default function NewCard({ item, onAction }: Props) {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)}>{/* atoms composition */}</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a New Organism
|
||||
|
||||
```tsx
|
||||
// organisms/sections/NewSection.tsx
|
||||
"use client";
|
||||
|
||||
import ProductCard from "@/components/molecules/cards/ProductCard";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
export default function NewSection() {
|
||||
const { user } = useAuth();
|
||||
// business logic, filtering, etc.
|
||||
return <section>{/* molecules composition + logic */}</section>;
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a New Template
|
||||
|
||||
```tsx
|
||||
// templates/layouts/NewTemplate.tsx
|
||||
export default function NewTemplate({ children, header }: Props) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<header>{header}</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Testing & Documentation
|
||||
|
||||
### For Each Component Level
|
||||
|
||||
#### Atoms
|
||||
|
||||
- Unit test: Props validation, styling
|
||||
- Storybook: All variants, all states
|
||||
- Doc: Props interface, usage examples
|
||||
|
||||
#### Molecules
|
||||
|
||||
- Integration test: Atoms composition
|
||||
- Storybook: Different molecule states
|
||||
- Doc: Props, behavior, dependencies
|
||||
|
||||
#### Organisms
|
||||
|
||||
- Integration test: With contexts mocked
|
||||
- E2E: User interactions
|
||||
- Doc: Logic flow, API integration points
|
||||
|
||||
#### Templates
|
||||
|
||||
- Layout test: Responsive grid layouts
|
||||
- Visual: Desktop/tablet/mobile
|
||||
- Doc: Layout structure, breakpoints
|
||||
|
||||
#### Pages
|
||||
|
||||
- E2E test: Full user flows
|
||||
- Performance: Metrics
|
||||
- Doc: Route, data flow, features
|
||||
|
||||
---
|
||||
|
||||
## 12) Performance Optimization
|
||||
|
||||
### Code Splitting
|
||||
|
||||
- Atoms: Always bundled (small, frequently used)
|
||||
- Molecules: Bundled by page/feature
|
||||
- Organisms: Use `dynamic()` for heavy sections
|
||||
- Templates: Bundled by layout type
|
||||
- Pages: Automatic splitting by Next.js
|
||||
|
||||
### Lazy Loading Example
|
||||
|
||||
```tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ReviewModal = dynamic(
|
||||
() => import("@/components/organisms/modals/ReviewModal"),
|
||||
{ loading: () => <Spinner /> },
|
||||
);
|
||||
```
|
||||
|
||||
### Image Optimization
|
||||
|
||||
- Use Next.js `Image` component (atoms/molecules)
|
||||
- Optimize with `priority` for above-fold
|
||||
- Use responsive sizes: `sizes="(max-width: 640px) 100vw, 50vw"`
|
||||
|
||||
---
|
||||
|
||||
## 13) Accessibility
|
||||
|
||||
### All Levels
|
||||
|
||||
- Semantic HTML: `<button>`, `<a>`, `<form>`, `<nav>`
|
||||
- ARIA attributes: `aria-label`, `aria-expanded`, `role`
|
||||
- Keyboard navigation: Tab order, focus visible
|
||||
- Color contrast: WCAG AA minimum
|
||||
- Alt text: All images have meaningful `alt`
|
||||
|
||||
### Example
|
||||
|
||||
```tsx
|
||||
<button
|
||||
aria-label="Add to cart"
|
||||
className="focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
<CartIcon aria-hidden="true" />
|
||||
Add to Cart
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14) Version Control & Documentation
|
||||
|
||||
### File Format
|
||||
|
||||
```
|
||||
components/
|
||||
├── atoms/
|
||||
│ ├── buttons/
|
||||
│ │ ├── Button.tsx
|
||||
│ │ ├── Button.types.ts
|
||||
│ │ └── Button.md # Component documentation
|
||||
│ └── ...
|
||||
├── molecules/
|
||||
│ ├── cards/
|
||||
│ │ ├── ProductCard.tsx
|
||||
│ │ ├── Card.types.ts
|
||||
│ │ └── ProductCard.md
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Documentation Template
|
||||
|
||||
```markdown
|
||||
# ProductCard
|
||||
|
||||
## Purpose
|
||||
|
||||
Display individual product with image, name, price, and action button.
|
||||
|
||||
## Props
|
||||
|
||||
- `product: Product` - Product data
|
||||
- `onAddToCart: (product: Product) => void` - Add to cart handler
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`tsx <ProductCard product={item} onAddToCart={addToCart} /> \`\`\`
|
||||
|
||||
## Variants
|
||||
|
||||
- Image with loading state
|
||||
- With discount badge
|
||||
- With rating stars
|
||||
|
||||
## Responsive
|
||||
|
||||
- Mobile: Single column, full width
|
||||
- Desktop: Grid layout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Cấu trúc này cung cấp: ✅ **Scalability**: Dễ thêm components mới ✅
|
||||
**Reusability**: Tối đa tái sử dụng ✅ **Maintainability**: Code dễ hiểu, tìm
|
||||
kiếm ✅ **Testability**: Mỗi level có logic riêng ✅ **Performance**: Smart
|
||||
code-splitting
|
||||
@@ -1,103 +1,10 @@
|
||||
# Coffee Shop Frontend - TODO
|
||||
# TODO - Add image upload field for manager add/update menu item
|
||||
|
||||
## Completed Features & Implementations
|
||||
|
||||
### A. Dead Code Removed
|
||||
|
||||
- [x] lib/constants.ts - Removed unused NAV_LINKS export
|
||||
- [x] lib/types.ts - Removed unused NavLink interface
|
||||
- [x] components/Navbar.tsx - Removed trivial handleClick wrapper; inlined
|
||||
onCategoryChange call
|
||||
- [x] components/Navbar.tsx - Removed unused Link import
|
||||
|
||||
### B. Bugs / Inaccuracies Fixed
|
||||
|
||||
- [x] layouts/header.tsx - Fixed JSDoc: 3-column -> 2-column layout (no center
|
||||
section exists)
|
||||
- [x] app/page.tsx - Added available !== false filter to product list
|
||||
- [x] app/page.tsx - Fixed setState-in-effect lint error: moved initial sidebar
|
||||
state to lazy useState initializer
|
||||
- [x] next.config.ts - Added explanatory JSDoc comment
|
||||
|
||||
### C. Documentation Updated
|
||||
|
||||
- [x] README.md - Fixed file structure tree, removed SCSS, fixed dark mode note,
|
||||
updated tech table
|
||||
- [x] components/COMPONENTS.md - Fixed CartProduct styling (was outdated
|
||||
w-64/text-red-500/bg-blue-600); added Navbar, Header, Footer sections
|
||||
|
||||
### D. New Documentation Created
|
||||
|
||||
- [x] WORKFLOW.md - Architecture, data flow, design token system, how-to guides,
|
||||
dev workflow
|
||||
|
||||
### E. Mini-test Results
|
||||
|
||||
- [x] npm run lint - PASSED (0 errors, 0 warnings)
|
||||
- [x] npm run build - PASSED (Compiled successfully, TypeScript clean, static
|
||||
pages generated)
|
||||
|
||||
---
|
||||
|
||||
## Pending Features (Future Work)
|
||||
|
||||
### Cart & Ordering
|
||||
|
||||
- [ ] Implement cart checkout flow (app/(main)/cart or modal)
|
||||
- [ ] Cart sidebar/modal with item list and total
|
||||
- [ ] Order submission API integration
|
||||
- [ ] Payment page implementation (app/(main)/payment)
|
||||
- [ ] Order history/tracking page
|
||||
- [ ] Toast notifications for cart actions
|
||||
|
||||
### Authentication & User Management
|
||||
|
||||
- [ ] Real backend authentication (replace MOCK_AUTH_DB)
|
||||
- [ ] Real OTP delivery service (SMS integration)
|
||||
- [ ] User profile page with edit capability
|
||||
- [ ] Password reset/recovery flow
|
||||
- [ ] Session management and token refresh
|
||||
|
||||
### Manager Features
|
||||
|
||||
- [ ] Manager dashboard page (app/(manager)/page.tsx)
|
||||
- [ ] Product management (add/edit/delete)
|
||||
- [ ] Category management
|
||||
- [ ] Order management & tracking
|
||||
- [ ] Sales analytics/dashboard
|
||||
- [ ] Inventory management
|
||||
|
||||
### Backend Integration
|
||||
|
||||
- [ ] Replace MOCK_PRODUCTS with API calls (GET /api/products)
|
||||
- [ ] Replace MOCK_SHOPS with API calls (GET /api/shops)
|
||||
- [ ] Replace MOCK_USERS with real authentication (POST /api/auth/login)
|
||||
- [ ] Real product images (replace placeholder.jpg)
|
||||
- [ ] Image upload for products
|
||||
|
||||
### UX Improvements
|
||||
|
||||
- [ ] Dark mode toggle (CSS variables prepared, toggle UI needed)
|
||||
- [ ] Loading skeletons for product grid
|
||||
- [ ] Product detail modal/page with full description
|
||||
- [ ] Wishlist/favorites feature
|
||||
- [ ] Sort products (price, rating, etc.)
|
||||
- [ ] Filter by price range
|
||||
- [ ] Quantity selector in product card
|
||||
- [ ] Related products suggestions
|
||||
|
||||
### Performance & SEO
|
||||
|
||||
- [ ] Dynamic route generation for products (app/(main)/product/[id]/page.tsx)
|
||||
- [ ] Dynamic route generation for shops (app/(feed)/shop/[id]/page.tsx)
|
||||
- [ ] Meta tags and Open Graph for SEO
|
||||
- [ ] Image optimization and lazy loading
|
||||
- [ ] Code splitting and dynamic imports
|
||||
|
||||
### Accessibility & Testing
|
||||
|
||||
- [ ] Keyboard navigation testing
|
||||
- [ ] ARIA labels audit
|
||||
- [ ] Unit tests for contexts
|
||||
- [ ] E2E tests for user flows
|
||||
- [ ] Accessibility audit (WCAG 2.1 AA)
|
||||
- [x] Update GraphQL queries/mutations in `lib/manager-context.tsx` to include `imageUrl`
|
||||
- [x] Update `components/organisms/manager/ProductModal.tsx`
|
||||
- [x] Add file input for image selection
|
||||
- [x] Add upload button to call `POST /api/file`
|
||||
- [x] Store returned URL string into `form.imageUrl`
|
||||
- [x] Show upload/loading/error states and image preview
|
||||
- [x] Update `components/organisms/manager/ProductsTab.tsx` to show image thumbnail in table
|
||||
- [x] Mark TODO progress after each step completed
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SearchBar } from "@/components/molecules/search-bar";
|
||||
import { ShopGrid } from "@/components/organisms/shop-grid";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function FeedPage() {
|
||||
const [searchName, setSearchName] = useState("");
|
||||
const [searchAddress, setSearchAddress] = useState("");
|
||||
|
||||
const hasFilters = searchName || searchAddress;
|
||||
|
||||
return (
|
||||
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 md:px-6 lg:px-8">
|
||||
{/* Page title */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
|
||||
Khám phá quán nước
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
||||
Tìm và chọn quán yêu thích của bạn
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shop grid */}
|
||||
<div className="mb-10">
|
||||
<ShopGrid searchName={searchName} searchAddress={searchAddress} />
|
||||
{hasFilters && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchName("");
|
||||
setSearchAddress("");
|
||||
}}
|
||||
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
||||
>
|
||||
Xóa bộ lọc
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter / Search bar — sticky bottom */}
|
||||
<div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5">
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
|
||||
<i className="fa-solid fa-filter text-(--color-primary)"></i>
|
||||
<span>Lọc quán</span>
|
||||
</div>
|
||||
|
||||
{/* Name search */}
|
||||
<SearchBar
|
||||
value={searchName}
|
||||
onChange={setSearchName}
|
||||
onClear={() => setSearchName("")}
|
||||
placeholder="Tìm theo tên quán..."
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
|
||||
{/* Address search — different icon so not using SearchBar atom */}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
|
||||
<input
|
||||
type="text"
|
||||
value={searchAddress}
|
||||
onChange={(e) => setSearchAddress(e.target.value)}
|
||||
placeholder="Tìm theo địa chỉ..."
|
||||
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
|
||||
/>
|
||||
{searchAddress && (
|
||||
<button
|
||||
onClick={() => setSearchAddress("")}
|
||||
aria-label="Xóa tìm kiếm địa chỉ"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { FeedLayout } from "@/components/templates/feed-layout";
|
||||
|
||||
export default function RootFeedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <FeedLayout>{children}</FeedLayout>;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { MainLayout } from "@/components/templates/main-layout";
|
||||
import { ManagerProvider } from "@/lib/manager-context";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MainLayout>{children}</MainLayout>;
|
||||
return (
|
||||
<MainLayout>
|
||||
<ManagerProvider>{children}</ManagerProvider>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
export default function LoginOtpPage() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ otp: "", general: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || !storedRole) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
setRole(storedRole);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ otp: "Please enter your OTP code", general: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ otp: "", general: "" });
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
role === "manager"
|
||||
? "/api/manager/quick_login"
|
||||
: "/api/customer/quick_login";
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
sessionStorage.removeItem("login_role");
|
||||
router.push(role === "manager" ? "/manager" : "/");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const msg =
|
||||
errorCode === "InvalidOTP"
|
||||
? "Incorrect or expired OTP code"
|
||||
: "An error occurred, please try again";
|
||||
setErrors({ otp: msg, general: "" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ otp: "", general: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!phone) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="relative mb-4 h-20 w-20">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={SHOP_INFO.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Verify your phone number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Info */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<i className="fa-solid fa-circle-info mr-2"></i>
|
||||
OTP code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
OTP code
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="otp"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => {
|
||||
setOtp(e.target.value);
|
||||
setErrors({ otp: "", general: "" });
|
||||
}}
|
||||
placeholder="Enter OTP code"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.otp && (
|
||||
<ErrorMessageLogin message={errors.otp} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/login")}
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Change phone number
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,39 +25,12 @@ export default function LoginPage() {
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Đăng nhập vào hệ thống
|
||||
Enter your phone number to sign in
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<LoginForm />
|
||||
|
||||
{/* Demo Credentials Info */}
|
||||
<div className="bg-background mt-6 rounded-lg p-4">
|
||||
<p className="mb-2 text-xs font-semibold text-(--color-text-muted)">
|
||||
Tài khoản demo:
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs text-(--color-text-muted)">
|
||||
<li>
|
||||
• Quản lý:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
admin / admin
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
• Nhân viên:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
Nguyễn Văn An / Nguyễn Văn An
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
• Khách hàng:{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5">
|
||||
0987654321 / user1
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
export default function LoginPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ password: "", general: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const storedPhone = sessionStorage.getItem("login_phone");
|
||||
const storedRole = sessionStorage.getItem("login_role");
|
||||
if (!storedPhone || storedRole !== "manager") {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setPhone(storedPhone);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password.trim()) {
|
||||
setErrors({ password: "Please enter your password", general: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({ password: "", general: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
sessionStorage.removeItem("login_phone");
|
||||
sessionStorage.removeItem("login_role");
|
||||
router.push("/manager");
|
||||
} else {
|
||||
const STATUS_ERROR_MAP: Record<number, string> = {
|
||||
400: "Incorrect login details, please try again",
|
||||
401: "Incorrect password",
|
||||
403: "Account is locked",
|
||||
404: "Account does not exist",
|
||||
};
|
||||
const msg =
|
||||
STATUS_ERROR_MAP[res.status] ??
|
||||
(res.status >= 500
|
||||
? "Server error, please try again later"
|
||||
: "An error occurred, please try again");
|
||||
setErrors({ password: "", general: msg });
|
||||
}
|
||||
} catch {
|
||||
setErrors({
|
||||
password: "",
|
||||
general: "Unable to connect, please try again",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!phone) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo & Shop Name */}
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="relative mb-4 h-20 w-20">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={SHOP_INFO.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">Manager sign in</p>
|
||||
</div>
|
||||
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Phone display */}
|
||||
<div className="rounded-lg border border-(--color-border) bg-(--color-background) px-4 py-3 text-sm text-(--color-text-secondary)">
|
||||
<i className="fa-solid fa-phone mr-2 text-(--color-text-muted)"></i>
|
||||
{phone}
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-lock absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setErrors({ password: "", general: "" });
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.password ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<ErrorMessageLogin message={errors.password} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/login")}
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Change phone number
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
type PageState = "checking" | "available" | "closed" | "error";
|
||||
|
||||
export default function ManagerSignupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [pageState, setPageState] = useState<PageState>("checking");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
eateryName: "",
|
||||
});
|
||||
const [errors, setErrors] = useState({
|
||||
name: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
eateryName: "",
|
||||
submit: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/manager/signup")
|
||||
.then((res) => {
|
||||
setPageState("available");
|
||||
// if (res.ok) ;
|
||||
// else if (res.status === 403) setPageState("closed");
|
||||
// else setPageState("error");
|
||||
})
|
||||
.catch(() => setPageState("error"));
|
||||
}, []);
|
||||
|
||||
const validatePhone = (phone: string) =>
|
||||
/^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
|
||||
|
||||
const handleChange =
|
||||
(field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm({ ...form, [field]: e.target.value });
|
||||
setErrors({ ...errors, [field]: "", submit: "" });
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const next = {
|
||||
name: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
eateryName: "",
|
||||
submit: "",
|
||||
};
|
||||
if (!form.name.trim()) next.name = "Please enter your full name";
|
||||
if (!form.phone.trim()) next.phone = "Please enter your phone number";
|
||||
else if (!validatePhone(form.phone))
|
||||
next.phone = "Invalid phone number (e.g. 0987654321)";
|
||||
if (!form.password.trim()) next.password = "Please enter your password";
|
||||
else if (form.password.length < 6)
|
||||
next.password = "Password must be at least 6 characters";
|
||||
if (!form.eateryName.trim())
|
||||
next.eateryName = "Please enter the restaurant name";
|
||||
setErrors(next);
|
||||
return !next.name && !next.phone && !next.password && !next.eateryName;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/manager/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (res.ok || res.status === 201) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const errorMap: Record<string, string> = {
|
||||
ExistedUser: "Phone number already registered",
|
||||
InvalidPhoneNumber: "Invalid phone number",
|
||||
};
|
||||
setErrors({
|
||||
...errors,
|
||||
submit: errorMap[errorCode] ?? "An error occurred, please try again",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setErrors({ ...errors, submit: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="relative mb-4 h-20 w-20">
|
||||
<Image
|
||||
src={SHOP_INFO.logo}
|
||||
alt={SHOP_INFO.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="80px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
|
||||
{SHOP_INFO.name}
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Create a manager account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Checking */}
|
||||
{pageState === "checking" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner fa-spin text-2xl text-(--color-primary)"></i>
|
||||
<p className="text-sm">Checking...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed */}
|
||||
{pageState === "closed" && (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
|
||||
Registration closed
|
||||
</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
The system already has a restaurant. Registration is no longer
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{pageState === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100">
|
||||
<i className="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
|
||||
Không thể kết nối
|
||||
</h2>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Không thể kiểm tra trạng thái đăng ký. Vui lòng thử lại.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
style="login"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setPageState("checking");
|
||||
fetch("/api/manager/signup")
|
||||
.then((r) => {
|
||||
if (r.ok) setPageState("available");
|
||||
else if (r.status === 403) setPageState("closed");
|
||||
else setPageState("error");
|
||||
})
|
||||
.catch(() => setPageState("error"));
|
||||
}}
|
||||
>
|
||||
Thử lại
|
||||
</Button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-(--color-primary) underline"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signup Form */}
|
||||
{pageState === "available" && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{[
|
||||
{
|
||||
id: "name",
|
||||
label: "Họ tên",
|
||||
icon: "fa-user",
|
||||
placeholder: "Nguyễn Văn A",
|
||||
field: "name" as const,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
label: "Số điện thoại",
|
||||
icon: "fa-phone",
|
||||
placeholder: "0987654321",
|
||||
field: "phone" as const,
|
||||
type: "tel",
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
label: "Mật khẩu",
|
||||
icon: "fa-lock",
|
||||
placeholder: "Ít nhất 6 ký tự",
|
||||
field: "password" as const,
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
id: "eateryName",
|
||||
label: "Tên nhà hàng",
|
||||
icon: "fa-store",
|
||||
placeholder: "Coffee & More",
|
||||
field: "eateryName" as const,
|
||||
type: "text",
|
||||
},
|
||||
].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 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ý...
|
||||
</>
|
||||
) : (
|
||||
"Đăng ký"
|
||||
)}
|
||||
</Button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
|
||||
>
|
||||
Đã có tài khoản? Đăng nhập
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+39
-27
@@ -3,10 +3,21 @@
|
||||
import { SearchBar } from "@/components/molecules/search-bar";
|
||||
import { CategorySidebar } from "@/components/organisms/navigation";
|
||||
import { ProductGrid } from "@/components/organisms/product-grid";
|
||||
import { MENU_CATEGORIES } from "@/lib/constants";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
import { eateryClient } from "@/lib/apollo-clients";
|
||||
import { allEateriesQuery } from "@/lib/types";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const GET_EATERY_COUNT = gql`
|
||||
{
|
||||
allEateries {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Main page — sidebar + product grid layout.
|
||||
*
|
||||
@@ -18,11 +29,30 @@ import { useEffect, useState } from "react";
|
||||
* - Mobile (< 1024px): collapsed by default
|
||||
*/
|
||||
export default function Home() {
|
||||
const { activeCategory, setActiveCategory } = useMenu();
|
||||
const router = useRouter();
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { data, loading, error } = useQuery<allEateriesQuery>(
|
||||
GET_EATERY_COUNT,
|
||||
{
|
||||
client: eateryClient,
|
||||
fetchPolicy: "no-cache",
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
console.log(data);
|
||||
|
||||
const count = data.allEateries.length ?? 0;
|
||||
if (count === 0) {
|
||||
router.push("/manager-signup");
|
||||
}
|
||||
}
|
||||
}, [data, loading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
@@ -32,41 +62,23 @@ export default function Home() {
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearchQuery("");
|
||||
}, [activeCategory]);
|
||||
|
||||
const activeCategoryLabel =
|
||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
|
||||
{/* ── Sidebar ── */}
|
||||
<CategorySidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onToggle={() => setIsSidebarOpen((prev) => !prev)}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={setActiveCategory}
|
||||
/>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
|
||||
{/* ── Section heading + search bar ── */}
|
||||
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
{/* Title + count */}
|
||||
<div className="shrink-0">
|
||||
<h2 className="text-foreground text-xl font-bold">
|
||||
{activeCategoryLabel}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onChange={(q) => {
|
||||
setSearchQuery(q);
|
||||
}}
|
||||
onClear={() => setSearchQuery("")}
|
||||
placeholder="Tìm kiếm món..."
|
||||
placeholder="Search items..."
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+94
-63
@@ -5,10 +5,12 @@ 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 { useManager } from "@/lib/manager-context";
|
||||
import { MenuItemEntity } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
export const formatPrice = (value?: number) =>
|
||||
(value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
export default function PaymentPage() {
|
||||
const {
|
||||
@@ -20,6 +22,15 @@ export default function PaymentPage() {
|
||||
setQuantity,
|
||||
} = useCart();
|
||||
const { user } = useAuth();
|
||||
const { products } = useManager();
|
||||
|
||||
const findProduct = (id: string): MenuItemEntity =>
|
||||
products.find((i) => i.id == id) ??
|
||||
({
|
||||
name: "Unknown product",
|
||||
description: "",
|
||||
price: 0,
|
||||
} as MenuItemEntity);
|
||||
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const isCustomer = user?.role === "customer";
|
||||
@@ -36,7 +47,7 @@ export default function PaymentPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
{items?.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-(--color-text-muted)">
|
||||
Chưa có sản phẩm nào trong giỏ hàng.
|
||||
</div>
|
||||
@@ -45,72 +56,92 @@ export default function PaymentPage() {
|
||||
<table className="w-full min-w-190 text-sm">
|
||||
<thead>
|
||||
<tr className="bg-(--color-border-light)/40 text-left">
|
||||
<th className="px-4 py-3 font-semibold">
|
||||
Tên sản phẩm
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Product name
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold">Giá tiền</th>
|
||||
<th className="px-4 py-3 font-semibold">Mô tả</th>
|
||||
<th className="px-4 py-3 font-semibold">Số lượng</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">
|
||||
Xóa
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Price
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Description
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold">
|
||||
Quantity
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-3 text-right font-semibold"
|
||||
>
|
||||
Delete
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-(--color-border-light)"
|
||||
>
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
</td>
|
||||
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
|
||||
<p className="line-clamp-2">{item.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decreaseQty(item.id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Giảm số lượng ${item.name}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
setQuantity(item.id, Number(e.target.value))
|
||||
}
|
||||
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
||||
title="Nhập số lượng"
|
||||
/>
|
||||
<button
|
||||
onClick={() => increaseQty(item.id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Tăng số lượng ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
variant="danger"
|
||||
size="md"
|
||||
style="payment"
|
||||
{items?.map(
|
||||
({
|
||||
productId: id,
|
||||
priceAtTimeOfAdding: price,
|
||||
quantity,
|
||||
}) => {
|
||||
const { name, description } = findProduct(id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
className={`border-t border-(--color-border-light) ${quantity == 0 ? "hidden" : ""}`}
|
||||
>
|
||||
Xóa sản phẩm
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-(--color-primary)">
|
||||
{formatPrice(price)}
|
||||
</td>
|
||||
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
|
||||
<p className="line-clamp-2">{description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decreaseQty(id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Giảm số lượng ${name}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(e) =>
|
||||
setQuantity(id, Number(e.target.value))
|
||||
}
|
||||
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
|
||||
title="Nhập số lượng"
|
||||
/>
|
||||
<button
|
||||
onClick={() => increaseQty(id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
|
||||
aria-label={`Tăng số lượng ${name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
onClick={() => removeFromCart(id)}
|
||||
variant="danger"
|
||||
size="md"
|
||||
style="payment"
|
||||
aria-label={`Xóa ${name} khỏi giỏ hàng`}
|
||||
>
|
||||
Delete product
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+148
-33
@@ -6,19 +6,48 @@ import { SHOP_INFO } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
// Static OTP for demo (in production, this would be sent via SMS)
|
||||
const DEMO_OTP = "123456";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { completeRegistration } = useAuth();
|
||||
const { setUser } = useAuth();
|
||||
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [errors, setErrors] = useState({ phone: "", otp: "" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [otpSending, setOtpSending] = useState(false);
|
||||
const [otpSendError, setOtpSendError] = useState("");
|
||||
|
||||
const sendOtp = async () => {
|
||||
setOtpSending(true);
|
||||
setOtpSendError("");
|
||||
try {
|
||||
const res = await fetch("/api/sms_otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setOtpSendError("Unable to send OTP, please try again");
|
||||
}
|
||||
} catch {
|
||||
setOtpSendError("Unable to connect, please try again");
|
||||
} finally {
|
||||
setOtpSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "otp") {
|
||||
sendOtp();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
// Validate Vietnamese phone number
|
||||
const validatePhone = (phoneNumber: string): boolean => {
|
||||
@@ -28,43 +57,87 @@ export default function RegisterPage() {
|
||||
return phoneRegex.test(phoneNumber);
|
||||
};
|
||||
|
||||
const handlePhoneSubmit = (e: FormEvent) => {
|
||||
const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!phone.trim()) {
|
||||
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" });
|
||||
setErrors({ ...errors, phone: "Please enter your phone number" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setErrors({
|
||||
...errors,
|
||||
phone: "Số điện thoại không hợp lệ (VD: 0987654321)",
|
||||
phone: "Invalid phone number (e.g. 0987654321)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to OTP step
|
||||
setStep("otp");
|
||||
setIsLoading(true);
|
||||
setErrors({ phone: "", otp: "" });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/customer/quick_signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStep("otp");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const phoneErrorMap: Record<string, string> = {
|
||||
ExistedUser: "Phone number already registered",
|
||||
InvalidPhoneNumber: "Invalid phone number",
|
||||
};
|
||||
const msg =
|
||||
phoneErrorMap[errorCode] ?? "An error occurred, please try again";
|
||||
setErrors({ phone: msg, otp: "" });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ phone: "Unable to connect, please try again", otp: "" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpSubmit = (e: FormEvent) => {
|
||||
const handleOtpSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" });
|
||||
setErrors({ ...errors, otp: "Please enter your OTP code" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (otp !== DEMO_OTP) {
|
||||
setErrors({ ...errors, otp: "Mã OTP không đúng" });
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setErrors({ phone: "", otp: "" });
|
||||
|
||||
// Complete registration
|
||||
completeRegistration(phone);
|
||||
router.push("/");
|
||||
try {
|
||||
const res = await fetch("/api/customer/quick_login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorCode = (await res.text().catch(() => "")).trim();
|
||||
const msg =
|
||||
errorCode === "InvalidOTP"
|
||||
? "Incorrect or expired OTP code"
|
||||
: "An error occurred, please try again";
|
||||
setErrors({ phone: "", otp: msg });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ phone: "", otp: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToPhone = () => {
|
||||
@@ -94,8 +167,8 @@ export default function RegisterPage() {
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
{step === "phone"
|
||||
? "Đăng ký tài khoản khách hàng"
|
||||
: "Xác thực số điện thoại"}
|
||||
? "Create a customer account"
|
||||
: "Verify your phone number"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +204,7 @@ export default function RegisterPage() {
|
||||
htmlFor="phone"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Số điện thoại
|
||||
Phone number
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
@@ -144,7 +217,8 @@ export default function RegisterPage() {
|
||||
setErrors({ ...errors, phone: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
@@ -154,7 +228,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||
Nhập số điện thoại Việt Nam (10 số, bắt đầu bằng 0)
|
||||
Enter a Vietnamese phone number (10 digits, starting with 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -166,8 +240,16 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Tiếp tục
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to Login */}
|
||||
@@ -175,7 +257,7 @@ export default function RegisterPage() {
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Quay lại đăng nhập
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
@@ -188,7 +270,16 @@ export default function RegisterPage() {
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="mb-2 text-sm text-blue-800">
|
||||
<i className="fa-solid fa-circle-info mr-2"></i>
|
||||
Mã OTP đã được gửi đến số <strong>{phone}</strong>
|
||||
{otpSending ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
|
||||
Sending OTP to <strong>{phone}</strong>...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
OTP code sent to <strong>{phone}</strong>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">
|
||||
Demo OTP:{" "}
|
||||
@@ -197,6 +288,12 @@ export default function RegisterPage() {
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
{otpSendError && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation"></i>
|
||||
{otpSendError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* OTP Input */}
|
||||
<div>
|
||||
@@ -204,7 +301,7 @@ export default function RegisterPage() {
|
||||
htmlFor="otp"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Mã OTP
|
||||
OTP code
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
@@ -216,9 +313,10 @@ export default function RegisterPage() {
|
||||
setOtp(e.target.value);
|
||||
setErrors({ ...errors, otp: "" });
|
||||
}}
|
||||
placeholder="Nhập mã OTP"
|
||||
placeholder="Enter OTP code"
|
||||
maxLength={6}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
</div>
|
||||
{errors.otp && (
|
||||
@@ -237,8 +335,16 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading || otpSending}
|
||||
>
|
||||
Hoàn tất đăng ký
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Complete registration"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back Button */}
|
||||
@@ -247,19 +353,28 @@ export default function RegisterPage() {
|
||||
onClick={handleBackToPhone}
|
||||
size="lg"
|
||||
style="login"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Thay đổi số điện thoại
|
||||
Change phone number
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP (disabled in demo) */}
|
||||
{/* Resend OTP */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="cursor-not-allowed text-sm text-(--color-text-muted)"
|
||||
onClick={sendOtp}
|
||||
disabled={otpSending || isLoading}
|
||||
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
|
||||
>
|
||||
Gửi lại mã OTP (60s)
|
||||
{otpSending ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
|
||||
Resending...
|
||||
</>
|
||||
) : (
|
||||
"Resend OTP code"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
ProductTable,
|
||||
SummaryCard,
|
||||
} from "@/components/organisms/analytics";
|
||||
import type { PieSlice } from "@/components/organisms/analytics";
|
||||
import {
|
||||
calcChange,
|
||||
formatCurrency,
|
||||
formatCurrencyFull,
|
||||
} from "@/lib/analytics-utils";
|
||||
import {
|
||||
MENU_CATEGORIES,
|
||||
MOCK_PRODUCT_SALES,
|
||||
MOCK_REVENUE_DAILY,
|
||||
MOCK_REVENUE_MONTHLY,
|
||||
MOCK_REVENUE_WEEKLY,
|
||||
MOCK_REVENUE_YEARLY,
|
||||
} from "@/lib/constants";
|
||||
import type { AnalyticsPeriod, RevenueDataPoint } from "@/lib/types";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
|
||||
day: "Theo ngày",
|
||||
week: "Theo tuần",
|
||||
month: "Theo tháng",
|
||||
year: "Theo năm",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
"#6F4E37",
|
||||
"#C8973A",
|
||||
"#A0785A",
|
||||
"#8B6914",
|
||||
"#D4A96A",
|
||||
"#4A3728",
|
||||
"#F0D9A8",
|
||||
"#A08060",
|
||||
"#3D2B1F",
|
||||
];
|
||||
|
||||
const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = {
|
||||
day: MOCK_REVENUE_DAILY,
|
||||
week: MOCK_REVENUE_WEEKLY,
|
||||
month: MOCK_REVENUE_MONTHLY,
|
||||
year: MOCK_REVENUE_YEARLY,
|
||||
};
|
||||
|
||||
const CHART_TYPES = ["line", "bar", "pie"] as const;
|
||||
type ChartType = (typeof CHART_TYPES)[number];
|
||||
|
||||
const CHART_META: Record<ChartType, { icon: string; label: string }> = {
|
||||
line: { icon: "fa-chart-line", label: "Line" },
|
||||
bar: { icon: "fa-chart-bar", label: "Bar" },
|
||||
pie: { icon: "fa-chart-pie", label: "Pie" },
|
||||
};
|
||||
|
||||
// ─── Category filter select ───────────────────────────────────────────────────
|
||||
|
||||
function CategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
label = "Danh mục:",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const categories = MENU_CATEGORIES.filter((c) => c.id !== "all");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-(--color-text-muted)">{label}</label>
|
||||
<select
|
||||
title="Danh mục"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="all">Tất cả</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [period, setPeriod] = useState<AnalyticsPeriod>("month");
|
||||
const [activeChart, setActiveChart] = useState<ChartType>("line");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
|
||||
// Revenue data for selected period
|
||||
const revenueData = REVENUE_MAP[period];
|
||||
|
||||
// Split into halves for bar comparison
|
||||
const half = Math.floor(revenueData.length / 2);
|
||||
const barCurrent = revenueData.slice(half);
|
||||
const barPrevious = revenueData.slice(0, half).slice(0, barCurrent.length);
|
||||
|
||||
// Filtered product sales
|
||||
const filteredSales = useMemo(
|
||||
() =>
|
||||
categoryFilter === "all"
|
||||
? MOCK_PRODUCT_SALES
|
||||
: MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter),
|
||||
[categoryFilter],
|
||||
);
|
||||
|
||||
// Summary stats
|
||||
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
|
||||
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
|
||||
const totalProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
|
||||
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||
|
||||
// Period-over-period comparisons
|
||||
const curRevenue = barCurrent.reduce((s, d) => s + d.revenue, 0);
|
||||
const prevRevenue = barPrevious.reduce((s, d) => s + d.revenue, 0);
|
||||
const curOrders = barCurrent.reduce((s, d) => s + d.orders, 0);
|
||||
const prevOrders = barPrevious.reduce((s, d) => s + d.orders, 0);
|
||||
const revComp = calcChange(curRevenue, prevRevenue);
|
||||
const ordComp = calcChange(curOrders, prevOrders);
|
||||
const proComp = calcChange(curRevenue * 0.65, prevRevenue * 0.65);
|
||||
|
||||
// Pie data: revenue by category
|
||||
const pieData = useMemo((): PieSlice[] => {
|
||||
const byCategory: Record<string, number> = {};
|
||||
MOCK_PRODUCT_SALES.forEach((p) => {
|
||||
byCategory[p.category] = (byCategory[p.category] ?? 0) + p.revenue;
|
||||
});
|
||||
return Object.entries(byCategory)
|
||||
.map(([catId, rev], i) => ({
|
||||
label: MENU_CATEGORIES.find((c) => c.id === catId)?.name ?? catId,
|
||||
value: rev,
|
||||
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, []);
|
||||
|
||||
// Top 5 products
|
||||
const top5 = useMemo(
|
||||
() => [...filteredSales].sort((a, b) => b.revenue - a.revenue).slice(0, 5),
|
||||
[filteredSales],
|
||||
);
|
||||
|
||||
// Totals for summary row
|
||||
const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0);
|
||||
const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
|
||||
const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0);
|
||||
const avgMargin =
|
||||
filteredSales.length > 0
|
||||
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) /
|
||||
filteredSales.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
{/* ── Page Header ── */}
|
||||
<header className="sticky top-0 z-30 border-b border-(--color-border-light) bg-(--color-bg-header) shadow-sm">
|
||||
<div className="mx-auto flex max-w-screen-2xl items-center gap-4 px-4 py-3">
|
||||
<Link
|
||||
href="/manager"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-(--color-text-muted) transition-colors hover:bg-(--color-accent-light) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left"></i>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-accent-light) text-(--color-primary)">
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-foreground text-lg leading-tight font-bold">
|
||||
Thống kê & Phân tích tài chính
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Financial Analytics Dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period selector */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{(Object.keys(PERIOD_LABELS) as AnalyticsPeriod[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`hidden rounded-lg px-3 py-1.5 text-xs font-medium transition-colors sm:block ${
|
||||
period === p
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
|
||||
}`}
|
||||
>
|
||||
{PERIOD_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
<select
|
||||
title="Chọn kỳ"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
||||
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
|
||||
>
|
||||
{(
|
||||
Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]
|
||||
).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-screen-2xl space-y-6 p-4 pb-10">
|
||||
{/* ── Summary Cards ── */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Tổng quan
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-sack-dollar"
|
||||
title="Tổng doanh thu"
|
||||
value={formatCurrency(totalRevenue)}
|
||||
subtitle={PERIOD_LABELS[period]}
|
||||
change={revComp.change}
|
||||
changePercent={revComp.changePercent}
|
||||
isPositive={revComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-receipt"
|
||||
title="Số đơn hàng"
|
||||
value={totalOrders.toLocaleString()}
|
||||
subtitle="Tổng đơn trong kỳ"
|
||||
change={ordComp.change}
|
||||
changePercent={ordComp.changePercent}
|
||||
isPositive={ordComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-circle-dollar-to-slot"
|
||||
title="Tổng lợi nhuận"
|
||||
value={formatCurrency(totalProfit)}
|
||||
subtitle="Ước tính từ dữ liệu bán hàng"
|
||||
change={proComp.change}
|
||||
changePercent={proComp.changePercent}
|
||||
isPositive={proComp.isPositive}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="fa-solid fa-basket-shopping"
|
||||
title="Giá trị đơn TB"
|
||||
value={formatCurrency(avgOrderValue)}
|
||||
subtitle="Doanh thu / số đơn hàng"
|
||||
change={0}
|
||||
changePercent={0}
|
||||
isPositive={true}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Revenue Chart ── */}
|
||||
<section className="bg-background rounded-2xl border border-(--color-border-light) p-5 shadow-sm">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-foreground text-base font-semibold">
|
||||
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
|
||||
Biểu đồ doanh thu
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{CHART_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActiveChart(t)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
activeChart === t
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid text-xs ${CHART_META[t].icon}`}></i>
|
||||
<span className="hidden sm:inline">
|
||||
{CHART_META[t].label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeChart === "line" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Doanh thu theo thời gian — {PERIOD_LABELS[period]}
|
||||
</p>
|
||||
<LineChart data={revenueData} height={220} />
|
||||
</>
|
||||
)}
|
||||
{activeChart === "bar" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
So sánh doanh thu nửa đầu và nửa sau kỳ hiện tại
|
||||
</p>
|
||||
<BarChart
|
||||
current={barCurrent}
|
||||
previous={barPrevious}
|
||||
height={220}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeChart === "pie" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Tỷ trọng doanh thu theo danh mục sản phẩm
|
||||
</p>
|
||||
<PieChart data={pieData} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Top 5 Products ── */}
|
||||
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-foreground text-base font-semibold">
|
||||
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
|
||||
Top sản phẩm bán chạy
|
||||
</h2>
|
||||
<CategorySelect
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{top5.map((p, i) => {
|
||||
const pct = (p.revenue / top5[0].revenue) * 100;
|
||||
return (
|
||||
<div key={p.productId}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light) text-xs font-bold text-(--color-primary)">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{p.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
<span className="text-(--color-text-muted) tabular-nums">
|
||||
{p.unitsSold} ly
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary) tabular-nums">
|
||||
{formatCurrency(p.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full rounded-full bg-(--color-primary) transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Full Product Table ── */}
|
||||
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-foreground text-base font-semibold">
|
||||
<i className="fa-solid fa-table text-foreground mr-2"></i>
|
||||
Phân tích chi tiết sản phẩm
|
||||
</h2>
|
||||
<CategorySelect
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
label="Lọc danh mục:"
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-(--color-text-muted)">
|
||||
Click vào tiêu đề cột để sắp xếp. Hiển thị {filteredSales.length}{" "}
|
||||
sản phẩm.
|
||||
</p>
|
||||
<ProductTable data={filteredSales} />
|
||||
|
||||
{/* Summary row */}
|
||||
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Tổng doanh thu:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-(--color-primary)">
|
||||
{formatCurrencyFull(filteredRevenue)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Tổng lợi nhuận:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrencyFull(filteredProfit)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Tổng sản lượng:{" "}
|
||||
</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{filteredUnits.toLocaleString()} ly
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-(--color-text-muted)">
|
||||
Biên LN trung bình:{" "}
|
||||
</span>
|
||||
<span className="font-semibold text-yellow-700">
|
||||
{avgMargin.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+194
-76
@@ -1,43 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CategoriesTab,
|
||||
CombosTab,
|
||||
ProductsTab,
|
||||
} from "@/components/organisms/manager";
|
||||
import { ProductsTab } from "@/components/organisms/manager";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function ManagerPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { activeTab, setActiveTab, products, combos, categories } =
|
||||
useManager();
|
||||
const { activeTab, setActiveTab, products } = useManager();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "products" as const,
|
||||
label: "Thực đơn",
|
||||
label: "Menu",
|
||||
icon: "fa-solid fa-utensils",
|
||||
count: products.length,
|
||||
},
|
||||
{
|
||||
id: "combos" as const,
|
||||
label: "Combo",
|
||||
icon: "fa-solid fa-layer-group",
|
||||
count: combos.length,
|
||||
},
|
||||
{
|
||||
id: "categories" as const,
|
||||
label: "Danh mục",
|
||||
icon: "fa-solid fa-tags",
|
||||
count: categories.length,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* ── Sidebar ── */}
|
||||
{/* ── Sidebar (lg+) ── */}
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-(--color-border-light) bg-white shadow-sm lg:flex">
|
||||
<div className="flex items-center gap-3 border-b border-(--color-border-light) px-5 py-5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-primary)">
|
||||
@@ -51,7 +50,7 @@ export default function ManagerPage() {
|
||||
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Quản lý thực đơn
|
||||
Menu Management
|
||||
</p>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
@@ -65,29 +64,31 @@ export default function ManagerPage() {
|
||||
>
|
||||
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||
<span className="flex-1 text-left">{tab.label}</span>
|
||||
<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>
|
||||
{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="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Phân tích
|
||||
Analytics
|
||||
</p>
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Tài chính</span>
|
||||
<span className="flex-1 text-left">Finance</span>
|
||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||
Mới
|
||||
New
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
@@ -95,9 +96,9 @@ export default function ManagerPage() {
|
||||
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Ca làm</span>
|
||||
<span className="flex-1 text-left">Shifts</span>
|
||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
|
||||
Mới
|
||||
New
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -110,9 +111,9 @@ export default function ManagerPage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-semibold">
|
||||
{user?.name ?? "Quản lý"}
|
||||
{user?.name ?? "Manager"}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">Quản lý quán</p>
|
||||
<p className="text-xs text-(--color-text-muted)">Store Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2 px-1">
|
||||
@@ -121,14 +122,14 @@ export default function ManagerPage() {
|
||||
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
Trang chủ
|
||||
Home
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
Đăng xuất
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,70 +137,187 @@ export default function ManagerPage() {
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-foreground text-lg font-bold">
|
||||
{tabs.find((t) => t.id === activeTab)?.label ?? "Quản lý"}
|
||||
<header className="sticky top-0 z-40 flex items-center justify-between gap-3 border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm">
|
||||
{/* Title */}
|
||||
<div className="min-w-0 shrink">
|
||||
<h1 className="text-foreground truncate text-lg font-bold">
|
||||
{tabs.find((t) => t.id === activeTab)?.label ?? "Manager"}
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Quản lý{" "}
|
||||
<p className="truncate text-xs text-(--color-text-muted)">
|
||||
Manage{" "}
|
||||
{activeTab === "products"
|
||||
? "thực đơn"
|
||||
? "menu items"
|
||||
: activeTab === "combos"
|
||||
? "combo"
|
||||
: "danh mục"}{" "}
|
||||
của quán
|
||||
? "combos"
|
||||
: activeTab === "categories"
|
||||
? "categories"
|
||||
: "menu"}{" "}
|
||||
for your store
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex items-center gap-1 lg:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
}`}
|
||||
{/* ── Actions: hidden on lg+ (sidebar handles nav there) ── */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 lg:hidden">
|
||||
{/* sm–lg: inline icon+label buttons */}
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
<i className={tab.icon}></i>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
|
||||
>
|
||||
<i className={tab.icon}></i>
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
<span>Finance</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/staff/schedule"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition hover:text-(--color-primary-dark)"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-days"></i>
|
||||
<span>Shifts</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-xl border border-red-200 bg-transparent px-3 py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
<span className="hidden sm:inline">Tài chính</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* < sm: hamburger → dropdown */}
|
||||
<div className="relative sm:hidden" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((prev) => !prev)}
|
||||
aria-label="Open menu"
|
||||
className="flex cursor-pointer items-center justify-center rounded-xl border border-(--color-border-light) bg-transparent p-2 text-sm text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||
>
|
||||
<i
|
||||
className={
|
||||
dropdownOpen ? "fa-solid fa-xmark" : "fa-solid fa-bars"
|
||||
}
|
||||
></i>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-2xl border border-(--color-border-light) bg-white shadow-xl">
|
||||
<div className="p-1.5">
|
||||
{/* Tab buttons */}
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
className={`flex w-full cursor-pointer items-center gap-2.5 rounded-xl border-none px-3 py-2.5 text-sm font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light)"
|
||||
}`}
|
||||
>
|
||||
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||
<span className="flex-1 text-left">{tab.label}</span>
|
||||
{tab.count !== null && (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
activeTab === tab.id
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="my-1.5 border-t border-(--color-border-light)"></div>
|
||||
|
||||
{/* Navigation links */}
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||
<span>Finance</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/staff/schedule"
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
|
||||
<span>Shifts</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition hover:bg-(--color-border-light)"
|
||||
>
|
||||
<i className="fa-solid fa-house w-4 text-center"></i>
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
|
||||
<div className="my-1.5 border-t border-(--color-border-light)"></div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket w-4 text-center"></i>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop actions */}
|
||||
{/* ── Desktop actions (lg+): sidebar handles nav, just show shortcut ── */}
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="flex items-center gap-1.5 rounded-xl bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
Thống kê tài chính
|
||||
Financial Analytics
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
Trang chủ
|
||||
Home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-5 md:p-8">
|
||||
{activeTab === "products" && <ProductsTab />}
|
||||
{activeTab === "combos" && <CombosTab />}
|
||||
{activeTab === "categories" && <CategoriesTab />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,23 +7,23 @@ import ShiftDetailModal from "@/components/organisms/shift-schedule/ShiftDetailM
|
||||
import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useShift } from "@/lib/shift-context";
|
||||
import type { ShiftSlot } from "@/lib/types";
|
||||
import type { ShiftEntity } from "@/lib/types";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
@@ -52,24 +52,24 @@ export default function StaffSchedulePage() {
|
||||
getWeeklyBudget,
|
||||
} = useShift();
|
||||
|
||||
const [selectedShift, setSelectedShift] = useState<ShiftSlot | null>(null);
|
||||
const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDate, setCreateDate] = useState<string | undefined>();
|
||||
const [createDate, setCreateDate] = useState<Date | undefined>();
|
||||
|
||||
const isManager = user?.role === "manager";
|
||||
|
||||
const handleShiftClick = (shift: ShiftSlot) => {
|
||||
const handleShiftClick = (shift: ShiftEntity) => {
|
||||
setSelectedShift(shift);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateShift = (date: string) => {
|
||||
const handleCreateShift = (date: Date) => {
|
||||
setCreateDate(date);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: string) => {
|
||||
const handleDateSelect = (date: Date) => {
|
||||
// In month view on desktop, clicking a date could open create modal for managers
|
||||
if (isManager) {
|
||||
setCreateDate(date);
|
||||
@@ -95,7 +95,7 @@ export default function StaffSchedulePage() {
|
||||
<i className="fa-solid fa-calendar-days text-sm text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-bold">Lịch làm việc</p>
|
||||
<p className="text-foreground text-sm font-bold">Work Schedule</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{isManager ? "Manager" : "Staff"}
|
||||
</p>
|
||||
@@ -105,7 +105,7 @@ export default function StaffSchedulePage() {
|
||||
{/* View toggle */}
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Chế độ xem
|
||||
View
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -117,7 +117,7 @@ export default function StaffSchedulePage() {
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-table-columns w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Theo tuần</span>
|
||||
<span className="flex-1 text-left">Weekly</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -129,13 +129,13 @@ export default function StaffSchedulePage() {
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-calendar w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Theo tháng</span>
|
||||
<span className="flex-1 text-left">Monthly</span>
|
||||
</button>
|
||||
|
||||
{/* Quick nav */}
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Điều hướng
|
||||
Navigation
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -143,7 +143,7 @@ export default function StaffSchedulePage() {
|
||||
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)"
|
||||
>
|
||||
<i className="fa-solid fa-crosshairs w-4 text-center"></i>
|
||||
<span className="flex-1 text-left">Hôm nay</span>
|
||||
<span className="flex-1 text-left">Today</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function StaffSchedulePage() {
|
||||
{isManager && (
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
||||
Quản lý
|
||||
Management
|
||||
</p>
|
||||
<Link
|
||||
href="/manager"
|
||||
@@ -167,7 +167,7 @@ export default function StaffSchedulePage() {
|
||||
<div className="mt-3 border-t border-(--color-border-light) pt-3">
|
||||
<div className="rounded-xl bg-(--color-primary)/5 p-3">
|
||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||
Ngân sách tuần
|
||||
Weekly Budget
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold text-(--color-primary)">
|
||||
{weeklyBudget.toLocaleString("vi-VN")}
|
||||
@@ -187,10 +187,10 @@ export default function StaffSchedulePage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-semibold">
|
||||
{user?.name ?? "Nhân viên"}
|
||||
{user?.name ?? "Staff"}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{isManager ? "Quản lý" : "Nhân viên"}
|
||||
{isManager ? "Manager" : "Staff"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +200,7 @@ export default function StaffSchedulePage() {
|
||||
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
|
||||
>
|
||||
<i className="fa-solid fa-house"></i>
|
||||
Trang chủ
|
||||
Home
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -208,7 +208,7 @@ export default function StaffSchedulePage() {
|
||||
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket"></i>
|
||||
Đăng xuất
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ export default function StaffSchedulePage() {
|
||||
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4">
|
||||
<div>
|
||||
<h1 className="text-foreground text-base font-bold md:text-lg">
|
||||
Đăng ký ca làm
|
||||
Register Shift
|
||||
</h1>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{view === "week"
|
||||
@@ -241,7 +241,7 @@ export default function StaffSchedulePage() {
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
Tuần
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -252,14 +252,14 @@ export default function StaffSchedulePage() {
|
||||
: "bg-transparent text-(--color-text-secondary)"
|
||||
}`}
|
||||
>
|
||||
Tháng
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<button
|
||||
title="Về trước"
|
||||
title="Previous"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
@@ -271,10 +271,10 @@ export default function StaffSchedulePage() {
|
||||
onClick={goToToday}
|
||||
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||
>
|
||||
Hôm nay
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
title="Tiếp theo"
|
||||
title="Next"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:bg-gray-50"
|
||||
@@ -294,14 +294,14 @@ export default function StaffSchedulePage() {
|
||||
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
Tạo ca
|
||||
Create Shift
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile nav */}
|
||||
<div className="flex items-center gap-1 md:hidden">
|
||||
<button
|
||||
title="Về trước"
|
||||
title="Previous"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
@@ -309,7 +309,7 @@ export default function StaffSchedulePage() {
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
title="Tiếp theo"
|
||||
title="Next"
|
||||
type="button"
|
||||
onClick={view === "week" ? goToNextWeek : goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
|
||||
@@ -353,7 +353,7 @@ export default function StaffSchedulePage() {
|
||||
{/* Mobile FAB for manager */}
|
||||
{isManager && (
|
||||
<button
|
||||
title="Tạo ca"
|
||||
title="Create Shift"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateDate(undefined);
|
||||
|
||||
+1
-4
@@ -2,7 +2,6 @@
|
||||
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { CartProvider } from "@/lib/cart-context";
|
||||
import { MenuProvider } from "@/lib/menu-context";
|
||||
|
||||
/**
|
||||
* Client-side providers wrapper.
|
||||
@@ -12,9 +11,7 @@ import { MenuProvider } from "@/lib/menu-context";
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MenuProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</MenuProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { BadgeProps } from "./Badge.types";
|
||||
|
||||
export default function Badge({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const variants = {
|
||||
primary: "bg-(--color-primary) text-white",
|
||||
secondary: "bg-(--color-border-light) text-(--color-text-secondary)",
|
||||
success: "bg-green-100 text-green-700",
|
||||
danger: "bg-red-100 text-red-700",
|
||||
warning: "bg-yellow-100 text-yellow-700",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-1.5 text-sm",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full font-semibold ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: "primary" | "secondary" | "success" | "danger" | "warning";
|
||||
size?: "sm" | "md";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface PriceBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
price: number;
|
||||
currency?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PriceBadge({
|
||||
price,
|
||||
currency = "VND",
|
||||
className = "",
|
||||
...props
|
||||
}: PriceBadgeProps) {
|
||||
const formattedPrice =
|
||||
currency === "VND"
|
||||
? price.toLocaleString("vi-VN", {
|
||||
style: "currency",
|
||||
currency: "VND",
|
||||
})
|
||||
: `${price.toFixed(2)} ${currency}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-bold text-(--color-primary) ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{formattedPrice}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as Badge } from "./Badge";
|
||||
export { default as PriceBadge } from "./PriceBadge";
|
||||
export type { BadgeProps } from "./Badge.types";
|
||||
export type { PriceBadgeProps } from "./PriceBadge";
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function Button({
|
||||
size = "md",
|
||||
icon,
|
||||
iconPosition = "left",
|
||||
className = "",
|
||||
disabled = false,
|
||||
children,
|
||||
...props
|
||||
@@ -43,7 +44,7 @@ export default function Button({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${styles[style]} ${variants[variant]} ${sizes[size]}`}
|
||||
className={`${styles[style]} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ButtonProps extends Omit<
|
||||
size?: "sm" | "md" | "lg";
|
||||
icon?: string; // FontAwesome class like "fa-solid fa-cart-plus"
|
||||
iconPosition?: "left" | "right";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonProps } from "./Button.types";
|
||||
|
||||
export default function IconButton({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
icon,
|
||||
disabled = false,
|
||||
className = "",
|
||||
children,
|
||||
style: _style,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
|
||||
|
||||
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
primary:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
secondary:
|
||||
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
|
||||
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
|
||||
primaryNoBorder:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
bgWhite:
|
||||
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 w-8 text-sm",
|
||||
md: "h-10 w-10 text-base",
|
||||
lg: "h-12 w-12 text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,13 @@ export { Button } from "./buttons";
|
||||
export type { ButtonProps } from "./buttons";
|
||||
|
||||
// Inputs
|
||||
export { TextInput, SearchInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs";
|
||||
export { TextInput, Textarea } from "./inputs";
|
||||
export type { TextInputProps, TextareaProps } from "./inputs";
|
||||
|
||||
// Typography
|
||||
export { Heading, Text, Caption } from "./typography";
|
||||
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
|
||||
|
||||
// Badges
|
||||
export { Badge, PriceBadge } from "./badges";
|
||||
export type { BadgeProps } from "./badges";
|
||||
|
||||
// Dividers
|
||||
export { Divider } from "./dividers";
|
||||
export type { DividerProps } from "./dividers";
|
||||
|
||||
@@ -13,17 +13,3 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
||||
error?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
onClear?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
errors?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LoginInputProps } from "./Input.types";
|
||||
|
||||
export default function LoginInput({
|
||||
label,
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
errors,
|
||||
onChange,
|
||||
...restProps
|
||||
}: LoginInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
function isPassword() {
|
||||
if (type === "password") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||
aria-label={showPassword ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
|
||||
></i>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-user absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id={name}
|
||||
type={showPassword ? "text" : type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={
|
||||
type === "password"
|
||||
? "Mật khẩu"
|
||||
: "admin / số điện thoại / tên nhân viên"
|
||||
}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors ? "border-red-400" : "border-(--color-border)"} `}
|
||||
/>
|
||||
{isPassword()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { SearchInputProps } from "./Input.types";
|
||||
|
||||
export default function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
className = "",
|
||||
...props
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={`w-full rounded-lg border border-(--color-border) bg-transparent py-2 pr-9 pl-9 text-sm transition-all duration-150 placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20 focus:outline-none ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{value && onClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
export { default as TextInput } from "./TextInput";
|
||||
export { default as SearchInput } from "./SearchInput";
|
||||
export { default as Textarea } from "./Textarea";
|
||||
export type {
|
||||
TextInputProps,
|
||||
SearchInputProps,
|
||||
TextareaProps,
|
||||
LoginInputProps,
|
||||
} from "./Input.types";
|
||||
export type { TextInputProps, TextareaProps } from "./Input.types";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Product } from "@/lib/types";
|
||||
|
||||
export interface ProductCardProps {
|
||||
image: string;
|
||||
imageAlt?: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatPrice } from "@/app/(main)/payment/page";
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import { ReviewModal } from "@/components/organisms/modals";
|
||||
import Link from "next/link";
|
||||
@@ -5,9 +6,6 @@ import { useState } from "react";
|
||||
|
||||
import type { PaymentSummaryCardProps } from "./Card.types";
|
||||
|
||||
const formatPrice = (value: number) =>
|
||||
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
|
||||
|
||||
export default function PaymentSummaryCard({
|
||||
totalPrice,
|
||||
isCustomer = false,
|
||||
@@ -23,10 +21,10 @@ export default function PaymentSummaryCard({
|
||||
return (
|
||||
<aside className="shrink-0 xl:w-85">
|
||||
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
|
||||
<h2 className="mb-4 text-lg font-bold">Hóa đơn</h2>
|
||||
<h2 className="mb-4 text-lg font-bold">Bill</h2>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
|
||||
<span className="text-(--color-text-muted)">Tổng cộng</span>
|
||||
<span className="text-(--color-text-muted)">Total</span>
|
||||
<span className="text-xl font-bold text-(--color-primary)">
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
@@ -40,7 +38,7 @@ export default function PaymentSummaryCard({
|
||||
size="md"
|
||||
variant="primary"
|
||||
>
|
||||
Tiền mặt
|
||||
Cash
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -61,7 +59,7 @@ export default function PaymentSummaryCard({
|
||||
size="md"
|
||||
variant="primary"
|
||||
>
|
||||
Đánh giá
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -77,7 +75,7 @@ export default function PaymentSummaryCard({
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
Quay về
|
||||
Return
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function ProductCard({
|
||||
</div>
|
||||
{/* Product image */}
|
||||
<Image
|
||||
src={image}
|
||||
src={image || "/"}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
className="z-1 object-cover"
|
||||
@@ -62,8 +62,14 @@ export default function ProductCard({
|
||||
<Text variant="body2" className="font-bold">
|
||||
{formattedPrice}
|
||||
</Text>
|
||||
<Button onClick={onBuy} variant="primary" size="sm" icon="fa-cart-plus">
|
||||
Mua
|
||||
<Button
|
||||
onClick={onBuy}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon="fa-cart-plus"
|
||||
aria-label={`Mua ${productName}`}
|
||||
>
|
||||
Buy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ShiftSlot } from "@/lib/types";
|
||||
import type { ShiftEntity } from "@/lib/types";
|
||||
|
||||
import type { ShiftCardProps } from "./ShiftCard.types";
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
ShiftSlot["status"],
|
||||
{ bg: string; text: string; label: string }
|
||||
> = {
|
||||
available: {
|
||||
bg: "bg-blue-50 border-blue-200",
|
||||
text: "text-blue-700",
|
||||
label: "Còn trống",
|
||||
},
|
||||
registered: {
|
||||
bg: "bg-blue-100 border-blue-400",
|
||||
text: "text-blue-900",
|
||||
label: "Đã đăng ký",
|
||||
},
|
||||
approved_leave: {
|
||||
bg: "bg-purple-50 border-purple-300",
|
||||
text: "text-purple-700",
|
||||
label: "Nghỉ phép",
|
||||
},
|
||||
absent: {
|
||||
bg: "bg-red-50 border-red-300",
|
||||
text: "text-red-700",
|
||||
label: "Vắng mặt",
|
||||
},
|
||||
};
|
||||
|
||||
function formatWage(wage: number): string {
|
||||
if (wage >= 1000) {
|
||||
return `${(wage / 1000).toFixed(0)}k`;
|
||||
@@ -42,20 +16,20 @@ export default function ShiftCard({
|
||||
compact = false,
|
||||
onClick,
|
||||
}: ShiftCardProps) {
|
||||
const style = STATUS_STYLES[shift.status];
|
||||
console.log(shift);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(shift)}
|
||||
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm ${style.bg} ${style.text}`}
|
||||
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm`}
|
||||
>
|
||||
<p className="font-semibold">
|
||||
{shift.startTime} – {shift.endTime}
|
||||
</p>
|
||||
<p className="mt-0.5 opacity-75">
|
||||
{shift.durationHours}h · {formatWage(shift.wage)}
|
||||
{}h · {formatWage(shift.wage)}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
@@ -65,7 +39,7 @@ export default function ShiftCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(shift)}
|
||||
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md ${style.bg} ${style.text}`}
|
||||
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -73,25 +47,26 @@ export default function ShiftCard({
|
||||
{shift.startTime} – {shift.endTime}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-75">
|
||||
{shift.durationHours}h · {formatWage(shift.wage)} VND
|
||||
{}h · {formatWage(shift.wage)} VND
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||
shift.status === "available"
|
||||
? "bg-blue-200 text-blue-800"
|
||||
: shift.status === "registered"
|
||||
? "bg-blue-300 text-blue-900"
|
||||
: shift.status === "approved_leave"
|
||||
? "bg-purple-200 text-purple-800"
|
||||
: "bg-red-200 text-red-800"
|
||||
// shift.status === "available"
|
||||
// ? "bg-blue-200 text-blue-800"
|
||||
// : shift.status === "registered"
|
||||
// ? "bg-blue-300 text-blue-900"
|
||||
// : shift.status === "approved_leave"
|
||||
// ? "bg-purple-200 text-purple-800"
|
||||
// : "bg-red-200 text-red-800"
|
||||
""
|
||||
}`}
|
||||
>
|
||||
{style.label}
|
||||
{/* {style.label} */}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{shift.registeredStaff.length > 0 && (
|
||||
{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})
|
||||
@@ -102,18 +77,18 @@ export default function ShiftCard({
|
||||
key={s.id}
|
||||
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
|
||||
>
|
||||
{s.name}
|
||||
{s.staffId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shift.status === "available" && shift.registeredStaff.length === 0 && (
|
||||
{/* {shift.status === "available" && shift.registeredStaff.length === 0 && (
|
||||
<p className="mt-2 text-[10px] italic opacity-50">
|
||||
{shift.maxStaff} vị trí còn trống
|
||||
</p>
|
||||
)}
|
||||
)} */}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ShiftSlot } from "@/lib/types";
|
||||
import type { ShiftEntity } from "@/lib/types";
|
||||
|
||||
export interface ShiftCardProps {
|
||||
shift: ShiftSlot;
|
||||
shift: ShiftEntity;
|
||||
compact?: boolean;
|
||||
onClick?: (shift: ShiftSlot) => void;
|
||||
onClick?: (shift: ShiftEntity) => void;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function ShopCard({ name, address, image }: ShopCardProps) {
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-book-open text-[10px]"></i>
|
||||
Xem menu
|
||||
View menu
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function SearchBar({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label="Tìm kiếm món ăn"
|
||||
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 && (
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatCurrency } from "@/lib/analytics-utils";
|
||||
import type { RevenueDataPoint } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
interface BarChartProps {
|
||||
current: RevenueDataPoint[];
|
||||
previous: RevenueDataPoint[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-SVG grouped bar chart comparing current vs previous period revenue.
|
||||
* Hover bars show tooltip with label, revenue, and order count.
|
||||
* Tooltip auto-flips above/below bar top to stay inside the viewBox.
|
||||
*/
|
||||
export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
||||
const [hovered, setHovered] = useState<{
|
||||
set: "cur" | "prev";
|
||||
idx: number;
|
||||
} | null>(null);
|
||||
const W = 800;
|
||||
const H = height;
|
||||
const padL = 56,
|
||||
padR = 16,
|
||||
padT = 16,
|
||||
padB = 40;
|
||||
const chartW = W - padL - padR;
|
||||
const chartH = H - padT - padB;
|
||||
|
||||
const n = current.length;
|
||||
const maxVal =
|
||||
Math.max(
|
||||
...current.map((d) => d.revenue),
|
||||
...previous.map((d) => d.revenue),
|
||||
) || 1;
|
||||
const groupW = chartW / n;
|
||||
const barW = groupW * 0.35;
|
||||
const gap = groupW * 0.05;
|
||||
|
||||
const yTicks = 5;
|
||||
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
|
||||
val: (maxVal / yTicks) * (yTicks - i),
|
||||
y: padT + (i / yTicks) * chartH,
|
||||
}));
|
||||
|
||||
const step = Math.ceil(n / 8);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full"
|
||||
style={{ height: H, minWidth: 320 }}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{gridLines.map((g, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={padL}
|
||||
y1={g.y}
|
||||
x2={W - padR}
|
||||
y2={g.y}
|
||||
stroke="#E2C9A8"
|
||||
strokeWidth="1"
|
||||
strokeDasharray={i === yTicks ? "0" : "4 3"}
|
||||
/>
|
||||
<text
|
||||
x={padL - 6}
|
||||
y={g.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fill="#A08060"
|
||||
>
|
||||
{formatCurrency(g.val)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{current.map((d, i) => {
|
||||
const groupX = padL + i * groupW;
|
||||
const curH = (d.revenue / maxVal) * chartH;
|
||||
const prevH = ((previous[i]?.revenue ?? 0) / maxVal) * chartH;
|
||||
const curX = groupX + gap;
|
||||
const prevX = curX + barW + gap;
|
||||
const isHovCur = hovered?.set === "cur" && hovered.idx === i;
|
||||
const isHovPrev = hovered?.set === "prev" && hovered.idx === i;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={prevX}
|
||||
y={padT + chartH - prevH}
|
||||
width={barW}
|
||||
height={prevH}
|
||||
rx="3"
|
||||
fill={isHovPrev ? "#A0785A" : "#E2C9A8"}
|
||||
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
||||
onMouseEnter={() => setHovered({ set: "prev", idx: i })}
|
||||
/>
|
||||
<rect
|
||||
x={curX}
|
||||
y={padT + chartH - curH}
|
||||
width={barW}
|
||||
height={curH}
|
||||
rx="3"
|
||||
fill={isHovCur ? "#4A3728" : "#6F4E37"}
|
||||
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
||||
onMouseEnter={() => setHovered({ set: "cur", idx: i })}
|
||||
/>
|
||||
{i % step === 0 && (
|
||||
<text
|
||||
x={groupX + groupW / 2}
|
||||
y={H - 8}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#A08060"
|
||||
>
|
||||
{d.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{hovered !== null &&
|
||||
(() => {
|
||||
const d =
|
||||
hovered.set === "cur"
|
||||
? current[hovered.idx]
|
||||
: previous[hovered.idx];
|
||||
if (!d) return null;
|
||||
const groupX = padL + hovered.idx * groupW;
|
||||
const tipW = 130,
|
||||
tipH = 50;
|
||||
const tipX = Math.min(
|
||||
Math.max(groupX - tipW / 2, padL),
|
||||
W - padR - tipW,
|
||||
);
|
||||
const barH = (d.revenue / maxVal) * chartH;
|
||||
const barTopY = padT + chartH - barH;
|
||||
const aboveY = barTopY - tipH - 8;
|
||||
const tipY = Math.min(
|
||||
Math.max(aboveY >= padT ? aboveY : barTopY + 8, padT),
|
||||
padT + chartH - tipH,
|
||||
);
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={tipX}
|
||||
y={tipY}
|
||||
width={tipW}
|
||||
height={tipH}
|
||||
rx="6"
|
||||
fill="#3D2B1F"
|
||||
opacity="0.92"
|
||||
/>
|
||||
<text
|
||||
x={tipX + tipW / 2}
|
||||
y={tipY + 15}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#F0D9A8"
|
||||
>
|
||||
{d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"})
|
||||
</text>
|
||||
<text
|
||||
x={tipX + tipW / 2}
|
||||
y={tipY + 30}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fill="#C8973A"
|
||||
>
|
||||
{formatCurrency(d.revenue)}
|
||||
</text>
|
||||
<text
|
||||
x={tipX + tipW / 2}
|
||||
y={tipY + 44}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#A08060"
|
||||
>
|
||||
{d.orders} đơn hàng
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Legend */}
|
||||
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
|
||||
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
|
||||
Hiện tại
|
||||
</text>
|
||||
<rect
|
||||
x={padL + 65}
|
||||
y={4}
|
||||
width={10}
|
||||
height={10}
|
||||
rx="2"
|
||||
fill="#E2C9A8"
|
||||
/>
|
||||
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
|
||||
Kỳ trước
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatCurrency } from "@/lib/analytics-utils";
|
||||
import type { RevenueDataPoint } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
interface LineChartProps {
|
||||
data: RevenueDataPoint[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-SVG interactive line chart for revenue over time.
|
||||
* Hover dots show tooltip with label, revenue, and order count.
|
||||
* Tooltip auto-flips above/below the dot to stay inside the viewBox.
|
||||
*/
|
||||
export function LineChart({ data, height = 200 }: LineChartProps) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const W = 800;
|
||||
const H = height;
|
||||
const padL = 56,
|
||||
padR = 16,
|
||||
padT = 16,
|
||||
padB = 40;
|
||||
const chartW = W - padL - padR;
|
||||
const chartH = H - padT - padB;
|
||||
|
||||
const maxRev = Math.max(...data.map((d) => d.revenue));
|
||||
const range = maxRev || 1;
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: padL + (i / (data.length - 1)) * chartW,
|
||||
y: padT + chartH - (d.revenue / range) * chartH,
|
||||
data: d,
|
||||
index: i,
|
||||
}));
|
||||
|
||||
const pathD = points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
|
||||
.join(" ");
|
||||
|
||||
const areaD =
|
||||
pathD +
|
||||
` L ${points[points.length - 1].x.toFixed(1)} ${(padT + chartH).toFixed(1)}` +
|
||||
` L ${points[0].x.toFixed(1)} ${(padT + chartH).toFixed(1)} Z`;
|
||||
|
||||
const yTicks = 5;
|
||||
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
|
||||
val: (range / yTicks) * (yTicks - i),
|
||||
y: padT + (i / yTicks) * chartH,
|
||||
}));
|
||||
|
||||
const step = Math.ceil(data.length / 10);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full"
|
||||
style={{ height: H, minWidth: 320 }}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#6F4E37" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#6F4E37" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{gridLines.map((g, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={padL}
|
||||
y1={g.y}
|
||||
x2={W - padR}
|
||||
y2={g.y}
|
||||
stroke="#E2C9A8"
|
||||
strokeWidth="1"
|
||||
strokeDasharray={i === yTicks ? "0" : "4 3"}
|
||||
/>
|
||||
<text
|
||||
x={padL - 6}
|
||||
y={g.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fill="#A08060"
|
||||
>
|
||||
{formatCurrency(g.val)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<path d={areaD} fill="url(#areaGrad)" />
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="#6F4E37"
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{points.map((p, i) =>
|
||||
i % step === 0 ? (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={H - 8}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#A08060"
|
||||
>
|
||||
{p.data.label}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
{points.map((p) => (
|
||||
<circle
|
||||
key={p.index}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={hovered === p.index ? 5 : 3}
|
||||
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
|
||||
stroke="#FDF6EC"
|
||||
strokeWidth="2"
|
||||
style={{ cursor: "pointer", transition: "r 150ms" }}
|
||||
onMouseEnter={() => setHovered(p.index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hovered !== null &&
|
||||
(() => {
|
||||
const p = points[hovered];
|
||||
const tipW = 120,
|
||||
tipH = 48;
|
||||
const tipX = Math.min(
|
||||
Math.max(p.x - tipW / 2, padL),
|
||||
W - padR - tipW,
|
||||
);
|
||||
const aboveY = p.y - tipH - 10;
|
||||
const tipY = Math.min(
|
||||
Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
|
||||
padT + chartH - tipH,
|
||||
);
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={tipX}
|
||||
y={tipY}
|
||||
width={tipW}
|
||||
height={tipH}
|
||||
rx="6"
|
||||
fill="#3D2B1F"
|
||||
opacity="0.92"
|
||||
/>
|
||||
<text
|
||||
x={tipX + tipW / 2}
|
||||
y={tipY + 16}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#F0D9A8"
|
||||
>
|
||||
{p.data.label}
|
||||
</text>
|
||||
<text
|
||||
x={tipX + tipW / 2}
|
||||
y={tipY + 30}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fill="#C8973A"
|
||||
>
|
||||
{formatCurrency(p.data.revenue)}
|
||||
</text>
|
||||
<text
|
||||
x={tipX + tipW / 2}
|
||||
y={tipY + 44}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#A08060"
|
||||
>
|
||||
{p.data.orders} đơn
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PieChartProps {
|
||||
data: PieSlice[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-SVG interactive pie chart.
|
||||
* Hover a slice or legend item to highlight it and show its percentage.
|
||||
*/
|
||||
export function PieChart({ data }: PieChartProps) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const R = 80;
|
||||
const CX = 110;
|
||||
const CY = 110;
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
||||
|
||||
const slices = useMemo(() => {
|
||||
type Acc = { items: ReturnType<typeof makeSlice>[]; angle: number };
|
||||
|
||||
const makeSlice = (d: PieSlice, i: number, startAngle: number) => {
|
||||
const angle = (d.value / total) * 2 * Math.PI;
|
||||
const endAngle = startAngle + angle;
|
||||
const midAngle = startAngle + angle / 2;
|
||||
const x1 = CX + R * Math.cos(startAngle);
|
||||
const y1 = CY + R * Math.sin(startAngle);
|
||||
const x2 = CX + R * Math.cos(endAngle);
|
||||
const y2 = CY + R * Math.sin(endAngle);
|
||||
const largeArc = angle > Math.PI ? 1 : 0;
|
||||
const pathD = `M ${CX} ${CY} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${R} ${R} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z`;
|
||||
return {
|
||||
...d,
|
||||
pathD,
|
||||
labelX: CX + R * 0.65 * Math.cos(midAngle),
|
||||
labelY: CY + R * 0.65 * Math.sin(midAngle),
|
||||
percent: (d.value / total) * 100,
|
||||
index: i,
|
||||
endAngle,
|
||||
};
|
||||
};
|
||||
|
||||
const { items } = data.reduce<Acc>(
|
||||
(acc, d, i) => {
|
||||
const slice = makeSlice(d, i, acc.angle);
|
||||
return { items: [...acc.items, slice], angle: slice.endAngle };
|
||||
},
|
||||
{ items: [], angle: -Math.PI / 2 },
|
||||
);
|
||||
return items;
|
||||
}, [data, total]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
||||
<svg
|
||||
viewBox="0 0 220 220"
|
||||
className="w-full max-w-55 shrink-0"
|
||||
style={{ height: 220 }}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{slices.map((s) => (
|
||||
<path
|
||||
key={s.index}
|
||||
d={s.pathD}
|
||||
fill={s.color}
|
||||
stroke="#FDF6EC"
|
||||
strokeWidth="2"
|
||||
style={{ cursor: "pointer", transition: "opacity 200ms" }}
|
||||
onMouseEnter={() => setHovered(s.index)}
|
||||
opacity={hovered !== null && hovered !== s.index ? 0.65 : 1}
|
||||
/>
|
||||
))}
|
||||
{hovered !== null && (
|
||||
<text
|
||||
x={CX}
|
||||
y={CY + 5}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
fill="#3D2B1F"
|
||||
>
|
||||
{slices[hovered].percent.toFixed(1)}%
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-col">
|
||||
{slices.map((s) => (
|
||||
<div
|
||||
key={s.index}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
onMouseEnter={() => setHovered(s.index)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span
|
||||
className="max-w-35 truncate"
|
||||
style={{
|
||||
color: hovered === s.index ? "#3D2B1F" : "#6F4E37",
|
||||
fontWeight: hovered === s.index ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="text-xs text-(--color-text-muted)">
|
||||
{s.percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatCurrencyFull } from "@/lib/analytics-utils";
|
||||
import { MENU_CATEGORIES } from "@/lib/constants";
|
||||
import type { ProductSalesStats } from "@/lib/types";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface ProductTableProps {
|
||||
data: ProductSalesStats[];
|
||||
}
|
||||
|
||||
const categoryName = (id: string) =>
|
||||
MENU_CATEGORIES.find((c) => c.id === id)?.name ?? id;
|
||||
|
||||
/**
|
||||
* Sortable product sales table.
|
||||
* Click column headers to sort ascending/descending.
|
||||
*/
|
||||
export function ProductTable({ data }: ProductTableProps) {
|
||||
const [sortKey, setSortKey] = useState<keyof ProductSalesStats>("revenue");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...data].sort((a, b) => {
|
||||
const av = a[sortKey] as number;
|
||||
const bv = b[sortKey] as number;
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
}),
|
||||
[data, sortKey, sortDir],
|
||||
);
|
||||
|
||||
const handleSort = (key: keyof ProductSalesStats) => {
|
||||
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
else {
|
||||
setSortKey(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortIcon = (col: keyof ProductSalesStats) => (
|
||||
<i
|
||||
className={`fa-solid ml-1 text-xs ${
|
||||
sortKey === col
|
||||
? sortDir === "desc"
|
||||
? "fa-sort-down text-(--color-primary)"
|
||||
: "fa-sort-up text-(--color-primary)"
|
||||
: "fa-sort text-(--color-text-muted)"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
|
||||
const SortTh = ({
|
||||
col,
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
col: keyof ProductSalesStats;
|
||||
label: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<th
|
||||
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
|
||||
onClick={() => handleSort(col)}
|
||||
>
|
||||
{label} {sortIcon(col)}
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-(--color-border-light)">
|
||||
<table className="w-full min-w-175 text-sm">
|
||||
<thead>
|
||||
<tr className="bg-background border-b border-(--color-border-light)">
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
#
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Sản phẩm
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Danh mục
|
||||
</th>
|
||||
<SortTh col="unitsSold" label="Số lượng" className="text-right" />
|
||||
<SortTh col="revenue" label="Doanh thu" className="text-right" />
|
||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||
Giá nhập
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||
Giá bán
|
||||
</th>
|
||||
<SortTh col="profit" label="Lợi nhuận" className="text-right" />
|
||||
<SortTh col="profitMargin" label="Biên LN" className="text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row, i) => (
|
||||
<tr
|
||||
key={row.productId}
|
||||
className="border-b border-(--color-border-light) bg-(--color-bg-card) transition-colors hover:bg-(--color-accent-light)/30"
|
||||
>
|
||||
<td className="px-4 py-3 text-(--color-text-muted)">{i + 1}</td>
|
||||
<td className="text-foreground px-4 py-3 font-medium">
|
||||
{row.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)">
|
||||
{categoryName(row.category)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-foreground px-4 py-3 text-right tabular-nums">
|
||||
{row.unitsSold.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">
|
||||
{formatCurrencyFull(row.revenue)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-(--color-text-muted) tabular-nums">
|
||||
{formatCurrencyFull(row.costPrice)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-(--color-text-secondary) tabular-nums">
|
||||
{formatCurrencyFull(row.sellingPrice)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-green-600 tabular-nums">
|
||||
{formatCurrencyFull(row.profit)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
row.profitMargin >= 70
|
||||
? "bg-green-100 text-green-700"
|
||||
: row.profitMargin >= 60
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{row.profitMargin.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { formatCurrency } from "@/lib/analytics-utils";
|
||||
|
||||
export interface SummaryCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
value: string;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
isPositive: boolean;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary metric card with period-over-period comparison indicator.
|
||||
* Used in the Financial Analytics dashboard header row.
|
||||
*/
|
||||
export function SummaryCard({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
changePercent,
|
||||
isPositive,
|
||||
subtitle,
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-(--color-accent-light) text-lg text-(--color-primary)">
|
||||
<i className={icon}></i>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-(--color-text-muted)">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 text-xs text-(--color-text-muted)">{subtitle}</p>
|
||||
)}
|
||||
<div
|
||||
className={`mt-3 flex items-center gap-1.5 text-sm font-medium ${
|
||||
isPositive ? "text-green-600" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid text-xs ${
|
||||
isPositive ? "fa-arrow-trend-up" : "fa-arrow-trend-down"
|
||||
}`}
|
||||
></i>
|
||||
<span>
|
||||
{isPositive ? "+" : ""}
|
||||
{changePercent.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs font-normal text-(--color-text-muted)">
|
||||
({isPositive ? "+" : ""}
|
||||
{formatCurrency(change)}) so với kỳ trước
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { BarChart } from "./BarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { PieChart } from "./PieChart";
|
||||
export type { PieSlice } from "./PieChart";
|
||||
export { ProductTable } from "./ProductTable";
|
||||
export { SummaryCard } from "./SummaryCard";
|
||||
export type { SummaryCardProps } from "./SummaryCard";
|
||||
@@ -1,122 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
|
||||
import LoginInput from "@/components/atoms/inputs/LoginInput";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "",
|
||||
});
|
||||
const [phone, setPhone] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ phone: "", general: "" });
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors = { username: "", password: "", general: "" };
|
||||
let isValid = true;
|
||||
|
||||
if (!username.trim()) {
|
||||
newErrors.username = "Vui lòng nhập tên đăng nhập";
|
||||
isValid = false;
|
||||
if (!phone.trim()) {
|
||||
setErrors({ phone: "Please enter your phone number", general: "" });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
newErrors.password = "Vui lòng nhập mật khẩu";
|
||||
isValid = false;
|
||||
} else if (password.length < 4) {
|
||||
newErrors.password = "Mật khẩu phải có ít nhất 4 ký tự";
|
||||
isValid = false;
|
||||
if (!PHONE_REGEX.test(phone)) {
|
||||
setErrors({
|
||||
phone: "Invalid phone number (e.g. 0987654321)",
|
||||
general: "",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
const success = login(username, password);
|
||||
setIsLoading(true);
|
||||
setErrors({ phone: "", general: "" });
|
||||
|
||||
if (success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setErrors({
|
||||
username: "",
|
||||
password: "",
|
||||
general: "Tên đăng nhập hoặc mật khẩu không đúng",
|
||||
try {
|
||||
const res = await fetch("/api/sms_otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
|
||||
const role = (await res.text().catch(() => "")).trim();
|
||||
|
||||
if (res.ok && (role === "customer" || role === "manager")) {
|
||||
sessionStorage.setItem("login_phone", phone);
|
||||
sessionStorage.setItem("login_role", role);
|
||||
router.push(role === "manager" ? "/login/password" : "/login/otp");
|
||||
} else if (res.status === 404) {
|
||||
setErrors({ phone: "", general: "Phone number not registered" });
|
||||
} else {
|
||||
setErrors({
|
||||
phone: "",
|
||||
general: "An error occurred, please try again",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setErrors({ phone: "", general: "Unable to connect, please try again" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Error Message */}
|
||||
{errors.general && <ErrorMessageLogin message={errors.general} />}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
{/* Username Input */}
|
||||
<LoginInput
|
||||
label="Tên đăng nhập"
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setErrors({ ...errors, username: "", general: "" });
|
||||
}}
|
||||
errors={errors.username}
|
||||
/>
|
||||
{errors.username && (
|
||||
<ErrorMessageLogin message={errors.username} type="secondary" />
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
|
||||
>
|
||||
Phone number
|
||||
</label>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
setErrors({ phone: "", general: "" });
|
||||
}}
|
||||
placeholder="0987654321"
|
||||
disabled={isLoading}
|
||||
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<ErrorMessageLogin message={errors.phone} type="secondary" />
|
||||
)}
|
||||
<p className="mt-2 text-xs text-(--color-text-muted)">
|
||||
Enter a Vietnamese phone number (10 digits, starting with 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<LoginInput
|
||||
label="Mật khẩu"
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setErrors({ ...errors, password: "", general: "" });
|
||||
}}
|
||||
errors={errors.password}
|
||||
/>
|
||||
{errors.password && (
|
||||
<ErrorMessageLogin message={errors.password} type="secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Login Button */}
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Đăng nhập
|
||||
{isLoading ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Register Button */}
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
|
||||
>
|
||||
Đăng ký tài khoản
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -15,7 +15,7 @@ export { ReviewModal } from "./modals";
|
||||
export type { ReviewModalProps, ConfirmModalProps } from "./modals";
|
||||
|
||||
// Shop Grid
|
||||
export { ShopGrid } from "./shop-grid";
|
||||
// export { ShopGrid } from "./shop-grid";
|
||||
export type { ShopGridProps } from "./shop-grid";
|
||||
|
||||
// Manager
|
||||
@@ -23,16 +23,10 @@ export {
|
||||
StatusBadge,
|
||||
DeleteConfirm,
|
||||
ProductModal,
|
||||
CategoryModal,
|
||||
ComboModal,
|
||||
ProductsTab,
|
||||
CategoriesTab,
|
||||
CombosTab,
|
||||
} from "./manager";
|
||||
export type {
|
||||
ProductModalProps,
|
||||
CategoryModalProps,
|
||||
ComboModalProps,
|
||||
DeleteConfirmProps,
|
||||
StatusBadgeProps,
|
||||
} from "./manager";
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
import type { MenuCategory } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
import CategoryModal from "./CategoryModal";
|
||||
import DeleteConfirm from "./DeleteConfirm";
|
||||
|
||||
export default function CategoriesTab() {
|
||||
const { categories, products, addCategory, updateCategory, deleteCategory } =
|
||||
useManager();
|
||||
|
||||
const [modalCategory, setModalCategory] = useState<
|
||||
MenuCategory | null | "new"
|
||||
>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MenuCategory | null>(null);
|
||||
|
||||
const getProductCount = (catId: string) =>
|
||||
products.filter((p) => p.category === catId).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<strong className="text-foreground">{categories.length}</strong> danh
|
||||
mục
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setModalCategory("new")}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
<span className="hidden sm:inline">Thêm danh mục</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{categories.map((cat) => {
|
||||
const count = getProductCount(cat.id);
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="group relative flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-4 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-(--color-accent-light)">
|
||||
<i className={`${cat.icon} text-xl text-(--color-primary)`}></i>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate font-semibold">
|
||||
{cat.name}
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">{count} món</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => setModalCategory(cat)}
|
||||
title="Chỉnh sửa"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-pen text-[11px]"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(cat)}
|
||||
title="Xóa"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<i className="fa-solid fa-trash text-[11px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
|
||||
<p className="text-sm">Chưa có danh mục nào</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalCategory !== null && (
|
||||
<CategoryModal
|
||||
category={modalCategory === "new" ? null : modalCategory}
|
||||
onSave={(data) => {
|
||||
if ("id" in data) {
|
||||
updateCategory(data as MenuCategory);
|
||||
} else {
|
||||
addCategory(data);
|
||||
}
|
||||
setModalCategory(null);
|
||||
}}
|
||||
onClose={() => setModalCategory(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget !== null && (
|
||||
<DeleteConfirm
|
||||
name={deleteTarget.name}
|
||||
onConfirm={() => {
|
||||
deleteCategory(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { MenuCategory } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CategoryModalProps } from "./Manager.types";
|
||||
|
||||
const FA_ICONS = [
|
||||
"fa-solid fa-mug-hot",
|
||||
"fa-solid fa-leaf",
|
||||
"fa-solid fa-jar",
|
||||
"fa-solid fa-blender",
|
||||
"fa-solid fa-mug-saucer",
|
||||
"fa-solid fa-ice-cream",
|
||||
"fa-solid fa-layer-group",
|
||||
"fa-solid fa-burger",
|
||||
"fa-solid fa-pizza-slice",
|
||||
"fa-solid fa-bowl-food",
|
||||
"fa-solid fa-candy-cane",
|
||||
"fa-solid fa-cookie",
|
||||
"fa-solid fa-cake-candles",
|
||||
"fa-solid fa-drumstick-bite",
|
||||
"fa-solid fa-fish",
|
||||
"fa-solid fa-carrot",
|
||||
];
|
||||
|
||||
export default function CategoryModal({
|
||||
category,
|
||||
onSave,
|
||||
onClose,
|
||||
}: CategoryModalProps) {
|
||||
const isEdit = category !== null;
|
||||
const [form, setForm] = useState<Omit<MenuCategory, "id">>({
|
||||
name: category?.name ?? "",
|
||||
icon: category?.icon ?? FA_ICONS[0],
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isEdit && category) {
|
||||
onSave({ ...form, id: category.id });
|
||||
} else {
|
||||
onSave(form);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
||||
<h2 className="text-foreground text-lg font-bold">
|
||||
{isEdit ? "Chỉnh sửa danh mục" : "Thêm danh mục mới"}
|
||||
</h2>
|
||||
<button
|
||||
title="Close"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Tên danh mục <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
placeholder="Ví dụ: Cà Phê"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Icon
|
||||
</label>
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{FA_ICONS.map((icon) => (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, icon })}
|
||||
title={icon}
|
||||
className={`flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition ${
|
||||
form.icon === icon
|
||||
? "border-(--color-primary) bg-(--color-primary) text-white"
|
||||
: "bg-background border-(--color-border-light) text-(--color-text-secondary) hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
<i className={`${icon} text-sm`}></i>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
{isEdit ? "Lưu thay đổi" : "Thêm danh mục"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Combo, Product } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ComboModalProps } from "./Manager.types";
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
export default function ComboModal({
|
||||
combo,
|
||||
products,
|
||||
onSave,
|
||||
onClose,
|
||||
}: ComboModalProps) {
|
||||
const isEdit = combo !== null;
|
||||
const [form, setForm] = useState<Omit<Combo, "id">>({
|
||||
name: combo?.name ?? "",
|
||||
description: combo?.description ?? "",
|
||||
price: combo?.price ?? 0,
|
||||
image: combo?.image ?? "/imgs/products/placeholder.jpg",
|
||||
items: combo?.items ?? [],
|
||||
available: combo?.available ?? true,
|
||||
});
|
||||
|
||||
const updateItemQty = (productId: number, qty: number) => {
|
||||
if (qty <= 0) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((i) => i.productId !== productId),
|
||||
}));
|
||||
} else {
|
||||
setForm((prev) => {
|
||||
const existing = prev.items.find((i) => i.productId === productId);
|
||||
if (existing) {
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((i) =>
|
||||
i.productId === productId ? { ...i, quantity: qty } : i,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
items: [...prev.items, { productId, quantity: qty }],
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getQty = (productId: number) =>
|
||||
form.items.find((i) => i.productId === productId)?.quantity ?? 0;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (form.items.length === 0) return;
|
||||
if (isEdit && combo) {
|
||||
onSave({ ...form, id: combo.id });
|
||||
} else {
|
||||
onSave(form);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="flex max-h-[90vh] w-full max-w-xl flex-col rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
||||
<h2 className="text-foreground text-lg font-bold">
|
||||
{isEdit ? "Chỉnh sửa combo" : "Thêm combo mới"}
|
||||
</h2>
|
||||
<button
|
||||
title="Close"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Tên combo <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className={inputCls}
|
||||
placeholder="Ví dụ: Combo Cà Phê Đôi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Giá combo (đ) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
title="Giá combo"
|
||||
required
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={form.price}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, price: Number(e.target.value) })
|
||||
}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Mô tả
|
||||
</label>
|
||||
<textarea
|
||||
title="Mô tả combo"
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, description: e.target.value })
|
||||
}
|
||||
className={`${inputCls} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Món trong combo{" "}
|
||||
{form.items.length === 0 && (
|
||||
<span className="text-xs text-red-500">
|
||||
(Chọn ít nhất 1 món)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="bg-background max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-(--color-border-light) p-2">
|
||||
{products.map((p) => {
|
||||
const qty = getQty(p.id);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between rounded-lg bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-foreground flex-1 truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className="mr-3 text-xs text-(--color-text-muted)">
|
||||
{formatPrice(p.price)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
title="Giảm"
|
||||
type="button"
|
||||
onClick={() => updateItemQty(p.id, qty - 1)}
|
||||
disabled={qty === 0}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary) disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<i className="fa-solid fa-minus"></i>
|
||||
</button>
|
||||
<span className="text-foreground w-5 text-center text-sm font-semibold">
|
||||
{qty}
|
||||
</span>
|
||||
<button
|
||||
title="Tăng"
|
||||
type="button"
|
||||
onClick={() => updateItemQty(p.id, qty + 1)}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
Trạng thái
|
||||
</p>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
{form.available ? "Còn hàng" : "Tạm hết"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
title="Chuyển đổi trạng thái"
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, available: !form.available })}
|
||||
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
|
||||
form.available ? "bg-(--color-primary)" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
|
||||
form.available ? "translate-x-5.5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 border-t border-(--color-border-light) px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={form.items.length === 0}
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isEdit ? "Lưu thay đổi" : "Thêm combo"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
import type { Combo } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
import ComboModal from "./ComboModal";
|
||||
import DeleteConfirm from "./DeleteConfirm";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
export default function CombosTab() {
|
||||
const {
|
||||
combos,
|
||||
products,
|
||||
addCombo,
|
||||
updateCombo,
|
||||
deleteCombo,
|
||||
toggleComboAvailability,
|
||||
} = useManager();
|
||||
|
||||
const [modalCombo, setModalCombo] = useState<Combo | null | "new">(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
|
||||
|
||||
const getProductName = (id: number) =>
|
||||
products.find((p) => p.id === id)?.name ?? `Món #${id}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<strong className="text-foreground">{combos.length}</strong> combo
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setModalCombo("new")}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
<span className="hidden sm:inline">Thêm combo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{combos.length === 0 ? (
|
||||
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-layer-group text-4xl opacity-30"></i>
|
||||
<p className="text-sm">Chưa có combo nào</p>
|
||||
</div>
|
||||
) : (
|
||||
combos.map((combo) => (
|
||||
<div
|
||||
key={combo.id}
|
||||
className="flex flex-col rounded-2xl border border-(--color-border-light) bg-white shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-foreground truncate font-semibold">
|
||||
{combo.name}
|
||||
</h3>
|
||||
{combo.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-(--color-text-muted)">
|
||||
{combo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleComboAvailability(combo.id)}
|
||||
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
|
||||
title="Đổi trạng thái"
|
||||
>
|
||||
<StatusBadge available={combo.available} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-background mx-4 mb-3 rounded-xl px-3 py-2">
|
||||
<p className="mb-1 text-[11px] font-semibold tracking-wide text-(--color-text-muted) uppercase">
|
||||
Bao gồm
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{combo.items.map((item) => (
|
||||
<li
|
||||
key={item.productId}
|
||||
className="flex items-center justify-between text-xs text-(--color-text-secondary)"
|
||||
>
|
||||
<span>{getProductName(item.productId)}</span>
|
||||
<span className="font-medium">×{item.quantity}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-(--color-border-light) px-4 py-3">
|
||||
<span className="text-base font-bold text-(--color-primary)">
|
||||
{formatPrice(combo.price)}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => setModalCombo(combo)}
|
||||
title="Chỉnh sửa"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-pen text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(combo)}
|
||||
title="Xóa"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<i className="fa-solid fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalCombo !== null && (
|
||||
<ComboModal
|
||||
combo={modalCombo === "new" ? null : modalCombo}
|
||||
products={products}
|
||||
onSave={(data) => {
|
||||
if ("id" in data) {
|
||||
updateCombo(data as Combo);
|
||||
} else {
|
||||
addCombo(data);
|
||||
}
|
||||
setModalCombo(null);
|
||||
}}
|
||||
onClose={() => setModalCombo(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget !== null && (
|
||||
<DeleteConfirm
|
||||
name={deleteTarget.name}
|
||||
onConfirm={() => {
|
||||
deleteCombo(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,8 @@
|
||||
import type { Combo, MenuCategory, Product } from "@/lib/types";
|
||||
import type { MenuItemEntity } from "@/lib/types";
|
||||
|
||||
export interface ProductModalProps {
|
||||
product: Product | null; // null = add mode
|
||||
categories: MenuCategory[];
|
||||
onSave: (p: Omit<Product, "id"> | Product) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface CategoryModalProps {
|
||||
category: MenuCategory | null;
|
||||
onSave: (c: Omit<MenuCategory, "id"> | MenuCategory) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface ComboModalProps {
|
||||
combo: Combo | null;
|
||||
products: Product[];
|
||||
onSave: (c: Omit<Combo, "id"> | Combo) => void;
|
||||
product: MenuItemEntity | null;
|
||||
onSave: (p: MenuItemEntity) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { eateryClient } from "@/lib/apollo-clients";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import {
|
||||
MenuItemEntity,
|
||||
addMenuItemMutation,
|
||||
allEateriesQuery,
|
||||
} from "@/lib/types";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const GET_EATERY_MENU = gql`
|
||||
query GetEateryMenu {
|
||||
allEateries {
|
||||
id
|
||||
menuItems {
|
||||
id
|
||||
name
|
||||
price
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ADD_MENU_ITEM = gql`
|
||||
mutation addMenuItem($menuItem: AddMenuItemInput!) {
|
||||
addMenuItem(menuItem: $menuItem) {
|
||||
id
|
||||
name
|
||||
price
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
export default function MenuItemsTab() {
|
||||
const { user } = useAuth();
|
||||
const [menuItems, setMenuItems] = useState<MenuItemEntity[]>([]);
|
||||
|
||||
const {
|
||||
data,
|
||||
loading: gqlLoading,
|
||||
error: gqlError,
|
||||
} = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
|
||||
client: eateryClient,
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newPrice, setNewPrice] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.allEateries?.[0]) {
|
||||
setMenuItems(data.allEateries[0].menuItems);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim() || !newPrice) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
const { data: mutationResult } = await mutateAddMenuItem({
|
||||
variables: {
|
||||
menuItem: {
|
||||
name: newName,
|
||||
price: parseFloat(newPrice),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (mutationResult?.addMenuItem) {
|
||||
setMenuItems((prev) => [...prev, mutationResult.addMenuItem]);
|
||||
closeForm();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Mutation Error:", err);
|
||||
setSubmitError(err.message || "Không thể thêm món. Vui lòng thử lại.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false);
|
||||
setSubmitError(null);
|
||||
setNewName("");
|
||||
setNewPrice("");
|
||||
};
|
||||
|
||||
if (gqlLoading && menuItems.length === 0)
|
||||
return <div className="py-16 text-center">Đang tải menu...</div>;
|
||||
if (gqlError)
|
||||
return (
|
||||
<div className="py-16 text-center text-red-500">
|
||||
Lỗi: {gqlError.message}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<strong className="text-foreground">{menuItems.length}</strong> món
|
||||
trong menu
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-plus"></i>
|
||||
<span className="hidden sm:inline">Thêm món</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-(--color-border-light) text-sm">
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Tên món
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||
Giá
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-(--color-border-light)">
|
||||
{menuItems.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="py-12 text-center text-(--color-text-muted)"
|
||||
>
|
||||
<i className="fa-solid fa-bowl-food mb-2 block text-3xl opacity-30"></i>
|
||||
Chưa có món nào trong menu
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
menuItems.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-background transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-foreground font-medium">{item.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
|
||||
{formatPrice(item.price)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add Item Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-foreground font-bold">Thêm món mới</h2>
|
||||
<button
|
||||
onClick={closeForm}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Tên món <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Nhập tên món..."
|
||||
required
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Giá (VNĐ) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newPrice}
|
||||
onChange={(e) => setNewPrice(e.target.value)}
|
||||
placeholder="Nhập giá..."
|
||||
required
|
||||
min={0}
|
||||
step={500}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<p className="text-sm text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation mr-1"></i>
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForm}
|
||||
className="hover:bg-background flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) disabled:opacity-60"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
"Thêm món"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { Product } from "@/lib/types";
|
||||
import type { MenuItemEntity } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ProductModalProps } from "./Manager.types";
|
||||
|
||||
export default function ProductModal({
|
||||
product,
|
||||
categories,
|
||||
onSave,
|
||||
onClose,
|
||||
}: ProductModalProps) {
|
||||
const isEdit = product !== null;
|
||||
const [form, setForm] = useState<Omit<Product, "id">>({
|
||||
const [form, setForm] = useState<MenuItemEntity>({
|
||||
name: product?.name ?? "",
|
||||
category: product?.category ?? categories[0]?.id ?? "",
|
||||
price: product?.price ?? 0,
|
||||
image: product?.image ?? "/imgs/products/placeholder.jpg",
|
||||
imageUrl: product?.imageUrl ?? "",
|
||||
description: product?.description ?? "",
|
||||
available: product?.available ?? true,
|
||||
});
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const inputCls =
|
||||
"text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
|
||||
|
||||
const uploadImage = async (file: File) => {
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
|
||||
const res = await fetch("/api/file", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Upload ảnh thất bại.");
|
||||
}
|
||||
|
||||
const raw = await res.text();
|
||||
|
||||
let filename = "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw.trim());
|
||||
filename = parsed.filename || "";
|
||||
} catch {
|
||||
const match = raw.match(/"filename"\s*:\s*"([^"]+)"/i);
|
||||
filename = match?.[1] || "";
|
||||
}
|
||||
|
||||
if (!filename) {
|
||||
throw new Error("Không nhận được filename ảnh từ server.");
|
||||
}
|
||||
|
||||
setForm((prev) => ({ ...prev, imageUrl: filename }));
|
||||
} catch (error: any) {
|
||||
setUploadError(error?.message || "Không thể upload ảnh.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isEdit && product) {
|
||||
onSave({ ...form, id: product.id });
|
||||
} else {
|
||||
@@ -30,14 +75,21 @@ export default function ProductModal({
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadImage(file);
|
||||
};
|
||||
|
||||
const toDisplayUrl = (filename: string) =>
|
||||
filename && !filename.startsWith("/") && !filename.startsWith("http")
|
||||
? `/api/file/${filename}`
|
||||
: filename;
|
||||
|
||||
const previewUrl = toDisplayUrl(form.imageUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
|
||||
<h2 className="text-foreground text-lg font-bold">
|
||||
@@ -67,42 +119,62 @@ export default function ProductModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Danh mục <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
title="Chọn danh mục"
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Giá (đ) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Giá (đ) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={form.price}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, price: Number(e.target.value) })
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="25000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
|
||||
Ảnh món
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={form.price}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, price: Number(e.target.value) })
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="25000"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
|
||||
Đang upload ảnh...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview ảnh món"
|
||||
className="h-24 w-24 rounded-lg border border-(--color-border-light) object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
<i className="fa-solid fa-circle-exclamation mr-1"></i>
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -153,7 +225,8 @@ export default function ProductModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
|
||||
disabled={uploading}
|
||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:opacity-60"
|
||||
>
|
||||
{isEdit ? "Lưu thay đổi" : "Thêm món"}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
import type { Product } from "@/lib/types";
|
||||
import type { MenuItemEntity } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
import DeleteConfirm from "./DeleteConfirm";
|
||||
@@ -12,28 +12,31 @@ function formatPrice(price: number) {
|
||||
return price.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
function toDisplayUrl(filename: string) {
|
||||
if (!filename) return "/imgs/products/placeholder.jpg";
|
||||
if (filename.startsWith("/") || filename.startsWith("http")) return filename;
|
||||
return `/api/file/${filename}`;
|
||||
}
|
||||
|
||||
export default function ProductsTab() {
|
||||
const {
|
||||
products,
|
||||
categories,
|
||||
addProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
toggleProductAvailability,
|
||||
} = useManager();
|
||||
|
||||
const [filterCategory, setFilterCategory] = useState("all");
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
"all" | "available" | "unavailable"
|
||||
>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [modalProduct, setModalProduct] = useState<Product | null | "new">(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
|
||||
const [modalProduct, setModalProduct] = useState<
|
||||
MenuItemEntity | null | "new"
|
||||
>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MenuItemEntity>(null!);
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
if (filterCategory !== "all" && p.category !== filterCategory) return false;
|
||||
if (filterStatus === "available" && p.available === false) return false;
|
||||
if (filterStatus === "unavailable" && p.available !== false) return false;
|
||||
if (
|
||||
@@ -45,9 +48,6 @@ export default function ProductsTab() {
|
||||
return true;
|
||||
});
|
||||
|
||||
const getCategoryName = (id: string) =>
|
||||
categories.find((c) => c.id === id)?.name ?? id;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
@@ -73,20 +73,6 @@ export default function ProductsTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
|
||||
title="Lọc theo danh mục"
|
||||
>
|
||||
<option value="all">Tất cả danh mục</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) =>
|
||||
@@ -123,10 +109,10 @@ export default function ProductsTab() {
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Tên món
|
||||
Ảnh
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
||||
Danh mục
|
||||
Tên món
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
||||
Giá
|
||||
@@ -156,6 +142,13 @@ export default function ProductsTab() {
|
||||
key={p.id}
|
||||
className="hover:bg-background transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<img
|
||||
src={toDisplayUrl(p.imageUrl)}
|
||||
alt={p.name}
|
||||
className="h-10 w-10 rounded-lg border border-(--color-border-light) object-cover"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-foreground font-medium">{p.name}</p>
|
||||
@@ -166,20 +159,12 @@ export default function ProductsTab() {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-(--color-accent-light) px-2.5 py-0.5 text-xs font-medium text-(--color-primary-dark)">
|
||||
<i
|
||||
className={`${categories.find((c) => c.id === p.category)?.icon ?? "fa-solid fa-tag"} text-[10px]`}
|
||||
></i>
|
||||
{getCategoryName(p.category)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
|
||||
{formatPrice(p.price)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => toggleProductAvailability(p.id)}
|
||||
onClick={() => toggleProductAvailability(p)}
|
||||
title="Nhấn để đổi trạng thái"
|
||||
className="cursor-pointer border-none bg-transparent"
|
||||
>
|
||||
@@ -214,10 +199,9 @@ export default function ProductsTab() {
|
||||
{modalProduct !== null && (
|
||||
<ProductModal
|
||||
product={modalProduct === "new" ? null : modalProduct}
|
||||
categories={categories}
|
||||
onSave={(data) => {
|
||||
onSave={(data: MenuItemEntity) => {
|
||||
if ("id" in data) {
|
||||
updateProduct(data as Product);
|
||||
updateProduct(data);
|
||||
} else {
|
||||
addProduct(data);
|
||||
}
|
||||
@@ -231,10 +215,10 @@ export default function ProductsTab() {
|
||||
<DeleteConfirm
|
||||
name={deleteTarget.name}
|
||||
onConfirm={() => {
|
||||
deleteProduct(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
deleteProduct(deleteTarget.id!);
|
||||
setDeleteTarget(null!);
|
||||
}}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onClose={() => setDeleteTarget(null!)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
|
||||
available ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`}
|
||||
/>
|
||||
{available ? "Còn hàng" : "Tạm hết"}
|
||||
{available ? "In stock" : "Out of stock"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
export { default as StatusBadge } from "./StatusBadge";
|
||||
export { default as DeleteConfirm } from "./DeleteConfirm";
|
||||
export { default as ProductModal } from "./ProductModal";
|
||||
export { default as CategoryModal } from "./CategoryModal";
|
||||
export { default as ComboModal } from "./ComboModal";
|
||||
export { default as ProductsTab } from "./ProductsTab";
|
||||
export { default as CategoriesTab } from "./CategoriesTab";
|
||||
export { default as CombosTab } from "./CombosTab";
|
||||
export { default as MenuItemsTab } from "./MenuItemsTab";
|
||||
export type {
|
||||
ProductModalProps,
|
||||
CategoryModalProps,
|
||||
ComboModalProps,
|
||||
DeleteConfirmProps,
|
||||
StatusBadgeProps,
|
||||
} from "./Manager.types";
|
||||
|
||||
@@ -49,13 +49,13 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
<i className="fa-solid fa-heart text-(--color-accent)"></i>
|
||||
</div>
|
||||
<Heading level={2} id="review-modal-title">
|
||||
Cảm ơn quý khách
|
||||
Thank you
|
||||
</Heading>
|
||||
<Text variant="body2" className="mt-2">
|
||||
Chúng tôi trân trọng đánh giá của bạn!
|
||||
We appreciate your feedback!
|
||||
</Text>
|
||||
<Button onClick={handleClose} variant="primary" className="mt-4">
|
||||
Đóng
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -65,21 +65,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
id="review-modal-title"
|
||||
className="text-foreground mb-1 text-xl font-bold"
|
||||
>
|
||||
Đánh giá của bạn
|
||||
Your Review
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-(--color-text-muted)">
|
||||
Hãy cho chúng tôi biết trải nghiệm của bạn hôm nay
|
||||
Tell us about your experience today
|
||||
</p>
|
||||
|
||||
{/* Star rating */}
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
|
||||
Mức độ hài lòng
|
||||
Satisfaction level
|
||||
</p>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Xếp hạng sao"
|
||||
aria-label="Star rating"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isActive = star <= (hovered || rating);
|
||||
@@ -90,7 +90,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHovered(star)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
aria-label={`${star} sao`}
|
||||
aria-label={`${star} star`}
|
||||
aria-pressed={rating === star}
|
||||
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
|
||||
>
|
||||
@@ -108,7 +108,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
{rating > 0 && (
|
||||
<p className="mt-1.5 text-xs text-(--color-text-muted)">
|
||||
{
|
||||
["", "Rất tệ", "Tệ", "Bình thường", "Tốt", "Xuất sắc"][
|
||||
["", "Very poor", "Poor", "Average", "Good", "Excellent"][
|
||||
rating
|
||||
]
|
||||
}
|
||||
@@ -120,10 +120,10 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
<div className="mb-6">
|
||||
<Textarea
|
||||
id="review-text"
|
||||
label="Nhận xét (tùy chọn)"
|
||||
label="Comment (optional)"
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
placeholder="Chia sẻ cảm nhận của bạn về đồ uống, dịch vụ..."
|
||||
placeholder="Share your thoughts on the drinks, service..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
className="flex-1"
|
||||
icon="fa-arrow-left"
|
||||
>
|
||||
Quay lại
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -146,7 +146,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
className="flex-1"
|
||||
icon="fa-check"
|
||||
>
|
||||
Xác nhận
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
|
||||
import type { MenuCategory } from "@/lib/types";
|
||||
import { SHOP_INFO } from "@/lib/constants";
|
||||
import type React from "react";
|
||||
|
||||
import type { CategorySidebarProps } from "./Navigation.types";
|
||||
@@ -18,8 +17,6 @@ import type { CategorySidebarProps } from "./Navigation.types";
|
||||
export default function CategorySidebar({
|
||||
isOpen,
|
||||
onToggle,
|
||||
activeCategory = "all",
|
||||
onCategoryChange,
|
||||
}: CategorySidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
@@ -47,6 +44,7 @@ export default function CategorySidebar({
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||
aria-label={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
|
||||
>
|
||||
<i
|
||||
@@ -57,40 +55,6 @@ export default function CategorySidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Category list ── */}
|
||||
<nav className="flex-1 py-2">
|
||||
<ul className="flex flex-col gap-0.5 px-2">
|
||||
{MENU_CATEGORIES.map((cat: MenuCategory) => {
|
||||
const isActive = activeCategory === cat.id;
|
||||
return (
|
||||
<li key={cat.id}>
|
||||
<button
|
||||
onClick={() => onCategoryChange?.(cat.id)}
|
||||
title={!isOpen ? cat.name : undefined}
|
||||
className={`flex w-full cursor-pointer items-center rounded-xl border-none text-sm font-medium transition-all duration-150 xl:justify-start xl:gap-3 xl:px-3 xl:py-2.5 ${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"} ${
|
||||
isActive
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
|
||||
} `}
|
||||
>
|
||||
{/* Icon */}
|
||||
<i
|
||||
className={` ${cat.icon} w-5 shrink-0 text-center text-base ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
||||
></i>
|
||||
|
||||
{/* Label — hidden when collapsed, always shown on xl+ */}
|
||||
<span
|
||||
className={`overflow-hidden text-ellipsis whitespace-nowrap ${isOpen ? "block" : "hidden"} xl:block`}
|
||||
>
|
||||
{cat.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* ── Sidebar footer: opening hours ── */}
|
||||
<div
|
||||
className={`shrink-0 border-t border-(--color-border) py-3 xl:px-4 ${isOpen ? "px-4" : "flex justify-center px-0"} `}
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import { ProductCard } from "@/components/molecules/cards";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
|
||||
function toDisplayUrl(filename: string) {
|
||||
if (!filename) return "/imgs/products/placeholder.jpg";
|
||||
if (filename.startsWith("/") || filename.startsWith("http")) return filename;
|
||||
return `/api/file/${filename}`;
|
||||
}
|
||||
|
||||
import type { ProductGridProps } from "./ProductGrid.types";
|
||||
|
||||
@@ -11,68 +16,40 @@ export default function ProductGrid({
|
||||
searchQuery = "",
|
||||
isSidebarOpen = false,
|
||||
}: ProductGridProps) {
|
||||
const { activeCategory, setActiveCategory } = useMenu();
|
||||
const { addToCart } = useCart();
|
||||
const { products } = useManager();
|
||||
|
||||
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
|
||||
const filteredProducts = products.filter((p) => {
|
||||
const isAvailable = p.available !== false;
|
||||
const matchesCategory =
|
||||
activeCategory === "all" || p.category === activeCategory;
|
||||
const matchesSearch =
|
||||
searchQuery.trim() === "" ||
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return isAvailable && matchesCategory && matchesSearch;
|
||||
return isAvailable && matchesSearch;
|
||||
});
|
||||
|
||||
const activeCategoryLabel =
|
||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
||||
|
||||
const gridCols = isSidebarOpen
|
||||
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Mobile category menu — visible only on < md ── */}
|
||||
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
|
||||
<div className="flex items-center gap-1.5 pb-1">
|
||||
{MENU_CATEGORIES.map((cat) => {
|
||||
const isActive = activeCategory === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
|
||||
} `}
|
||||
>
|
||||
<i
|
||||
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
||||
></i>
|
||||
<span>{cat.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Product grid ── */}
|
||||
{filteredProducts.length > 0 ? (
|
||||
<div className={`grid gap-4 ${gridCols}`}>
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
image={product.image}
|
||||
imageAlt={product.name}
|
||||
productName={product.name}
|
||||
price={product.price}
|
||||
description={product.description}
|
||||
onBuy={() => addToCart(product)}
|
||||
/>
|
||||
))}
|
||||
{filteredProducts.map(
|
||||
({ id, imageUrl, name, price, description }) => (
|
||||
<ProductCard
|
||||
key={id}
|
||||
image={toDisplayUrl(imageUrl)}
|
||||
imageAlt={name}
|
||||
productName={name}
|
||||
price={price}
|
||||
description={description}
|
||||
onBuy={() => addToCart({ productId: id!, quantity: 1 })}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state */
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import type { MobileShiftViewProps } from "./ShiftSchedule.types";
|
||||
|
||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
@@ -26,27 +26,25 @@ function isToday(d: Date): boolean {
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
export default function MobileShiftView({
|
||||
onShiftClick,
|
||||
}: MobileShiftViewProps) {
|
||||
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
formatDateISO(new Date(2026, 3, 10)),
|
||||
);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentDate.getFullYear();
|
||||
@@ -67,21 +65,20 @@ export default function MobileShiftView({
|
||||
}, [currentDate]);
|
||||
|
||||
const getDotColors = (date: Date): string[] => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||
const dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
|
||||
const dots: string[] = [];
|
||||
if (dayShifts.some((s) => s.status === "available"))
|
||||
dots.push("bg-amber-400");
|
||||
if (dayShifts.some((s) => s.status === "registered"))
|
||||
dots.push("bg-green-500");
|
||||
if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||
dots.push("bg-purple-400");
|
||||
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
|
||||
// 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(() => {
|
||||
return shifts.filter((s) => s.date === selectedDate);
|
||||
return shifts.filter((s) => s.date.getDate() === selectedDate.getDate());
|
||||
}, [shifts, selectedDate]);
|
||||
|
||||
const selectedDateObj = new Date(selectedDate + "T00:00:00");
|
||||
@@ -94,7 +91,7 @@ export default function MobileShiftView({
|
||||
{/* Month navigation */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
title="Trở lại tháng trước"
|
||||
title="Previous month"
|
||||
type="button"
|
||||
onClick={goToPrevMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -105,7 +102,7 @@ export default function MobileShiftView({
|
||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Trở lại tháng sau"
|
||||
title="Next month"
|
||||
type="button"
|
||||
onClick={goToNextMonth}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -133,16 +130,15 @@ export default function MobileShiftView({
|
||||
return <div key={`empty-${i}`} className="p-1" />;
|
||||
}
|
||||
|
||||
const dateStr = formatDateISO(date);
|
||||
const today = isToday(date);
|
||||
const selected = dateStr === selectedDate;
|
||||
const selected = date === selectedDate;
|
||||
const dots = getDotColors(date);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => setSelectedDate(dateStr)}
|
||||
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" : ""
|
||||
}`}
|
||||
@@ -173,22 +169,26 @@ export default function MobileShiftView({
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
Còn trống
|
||||
Available
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Đã ĐK</span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
Registered
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
Nghỉ phép
|
||||
On leave
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">Vắng</span>
|
||||
<span className="text-[10px] text-(--color-text-muted)">
|
||||
Absent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +199,8 @@ export default function MobileShiftView({
|
||||
{dayOfWeek}, {selectedDateObj.getDate()}/
|
||||
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
||||
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
||||
({selectedShifts.length} ca)
|
||||
({selectedShifts.length} shift
|
||||
{selectedShifts.length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -207,15 +208,13 @@ export default function MobileShiftView({
|
||||
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
||||
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
Không có ca làm trong ngày này
|
||||
No shifts for this day
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{DEPARTMENTS.map((dept) => {
|
||||
const deptShifts = selectedShifts.filter(
|
||||
(s) => s.department === dept.id,
|
||||
);
|
||||
const deptShifts = selectedShifts;
|
||||
if (deptShifts.length === 0) return null;
|
||||
return (
|
||||
<div key={dept.id}>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useMemo } from "react";
|
||||
|
||||
import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
||||
|
||||
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
export function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
@@ -60,15 +60,14 @@ export default function MonthlyCalendar({
|
||||
}, [currentDate]);
|
||||
|
||||
const getShiftSummary = (date: Date) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||
const available = dayShifts.filter((s) => s.status === "available").length;
|
||||
const registered = dayShifts.filter(
|
||||
(s) => s.status === "registered",
|
||||
).length;
|
||||
const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
|
||||
const absent = dayShifts.filter((s) => s.status === "absent").length;
|
||||
return { total: dayShifts.length, available, registered, leave, absent };
|
||||
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 (
|
||||
@@ -104,7 +103,7 @@ export default function MonthlyCalendar({
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => onDateSelect?.(formatDateISO(date))}
|
||||
onClick={() => onDateSelect?.(date)}
|
||||
className={`min-h-25 cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${
|
||||
today ? "bg-(--color-primary)/5" : ""
|
||||
}`}
|
||||
@@ -119,13 +118,13 @@ export default function MonthlyCalendar({
|
||||
{date.getDate()}
|
||||
</span>
|
||||
|
||||
{summary.total > 0 && (
|
||||
{/* {summary.total > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{summary.available > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400"></span>
|
||||
<span className="text-[10px] text-blue-600">
|
||||
{summary.available} trống
|
||||
{summary.available} available
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -133,7 +132,7 @@ export default function MonthlyCalendar({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-700"></span>
|
||||
<span className="text-[10px] text-blue-800">
|
||||
{summary.registered} đã ĐK
|
||||
{summary.registered} registered
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -141,7 +140,7 @@ export default function MonthlyCalendar({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||
<span className="text-[10px] text-purple-600">
|
||||
{summary.leave} nghỉ
|
||||
{summary.leave} on leave
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,12 +148,12 @@ export default function MonthlyCalendar({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||
<span className="text-[10px] text-red-600">
|
||||
{summary.absent} vắng
|
||||
{summary.absent} absent
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DEPARTMENTS } from "@/lib/constants";
|
||||
import { useShift } from "@/lib/shift-context";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { formatDateISO } from "./MonthlyCalendar";
|
||||
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
||||
|
||||
export default function ShiftCreateModal({
|
||||
@@ -13,7 +14,7 @@ export default function ShiftCreateModal({
|
||||
}: ShiftCreateModalProps) {
|
||||
const { createShift } = useShift();
|
||||
|
||||
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
||||
const [date, setDate] = useState<Date>(new Date(defaultDate ?? "2026-04-10"));
|
||||
const [startTime, setStartTime] = useState("08:00");
|
||||
const [endTime, setEndTime] = useState("12:00");
|
||||
const [department, setDepartment] = useState("bar");
|
||||
@@ -23,7 +24,7 @@ export default function ShiftCreateModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDate(defaultDate ?? "2026-04-10");
|
||||
setDate(new Date(defaultDate ?? "2026-04-10"));
|
||||
}
|
||||
}, [defaultDate, isOpen]);
|
||||
|
||||
@@ -35,7 +36,7 @@ export default function ShiftCreateModal({
|
||||
|
||||
// Validate
|
||||
if (!date || !startTime || !endTime) {
|
||||
setError("Vui lòng điền đầy đủ thông tin.");
|
||||
setError("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,22 +46,20 @@ export default function ShiftCreateModal({
|
||||
const endMinutes = eh * 60 + em;
|
||||
|
||||
if (endMinutes <= startMinutes) {
|
||||
setError("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||
setError("End time must be after start time.");
|
||||
return;
|
||||
}
|
||||
|
||||
const durationHours = (endMinutes - startMinutes) / 60;
|
||||
const deptName =
|
||||
DEPARTMENTS.find((d) => d.id === department)?.name ?? department;
|
||||
|
||||
createShift({
|
||||
name: `${deptName} - ${formatDateISO(date)}`,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
durationHours,
|
||||
wage,
|
||||
department,
|
||||
maxStaff,
|
||||
registeredStaff: [],
|
||||
status: "available",
|
||||
});
|
||||
|
||||
onClose();
|
||||
@@ -77,10 +76,10 @@ export default function ShiftCreateModal({
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-foreground text-base font-bold">
|
||||
Tạo ca làm mới
|
||||
Create New Shift
|
||||
</h2>
|
||||
<p className="text-xs text-(--color-text-muted)">
|
||||
Thêm khung giờ ca làm cho nhân viên
|
||||
Add a shift time slot for staff
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -98,13 +97,13 @@ export default function ShiftCreateModal({
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Ngày
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
title="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
value={formatDateISO(date)}
|
||||
onChange={(e) => setDate(new Date(e.target.value))}
|
||||
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
|
||||
/>
|
||||
</div>
|
||||
@@ -113,7 +112,7 @@ export default function ShiftCreateModal({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Giờ bắt đầu
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
title="Start Time"
|
||||
@@ -125,7 +124,7 @@ export default function ShiftCreateModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
|
||||
Giờ kết thúc
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
title="End Time"
|
||||
|
||||
@@ -19,12 +19,12 @@ export default function ShiftDetailModal({
|
||||
|
||||
if (!isOpen || !shift) return null;
|
||||
|
||||
const dept = DEPARTMENTS.find((d) => d.id === shift.department);
|
||||
// const dept = DEPARTMENTS.find((d) => d.id === shift.department);
|
||||
const isManager = user?.role === "manager";
|
||||
const isRegistered = user
|
||||
? shift.registeredStaff.some((s) => s.id === user.id)
|
||||
? shift.registeredStaff!.some((s) => s.id === user.id)
|
||||
: false;
|
||||
const isFull = shift.registeredStaff.length >= shift.maxStaff;
|
||||
const isFull = shift.registeredStaff!.length >= shift.maxStaff;
|
||||
|
||||
const handleRegister = () => {
|
||||
if (!user) return;
|
||||
@@ -47,7 +47,7 @@ export default function ShiftDetailModal({
|
||||
setTimeout(onClose, 1200);
|
||||
};
|
||||
|
||||
const handleManagerUnregister = (staffId: number) => {
|
||||
const handleManagerUnregister = (staffId: string) => {
|
||||
unregisterShift(shift.id, staffId);
|
||||
setSuccess("Đã xóa nhân viên khỏi ca.");
|
||||
};
|
||||
@@ -81,12 +81,12 @@ export default function ShiftDetailModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
|
||||
{/* {dept && <i className={`${dept.icon} text-(--color-primary)`}></i>} */}
|
||||
<div>
|
||||
<h2 className="text-foreground text-base font-bold">
|
||||
Chi tiết ca làm
|
||||
</h2>
|
||||
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
|
||||
{/* <p className="text-xs text-(--color-text-muted)">{dept?.name}</p> */}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -102,13 +102,13 @@ export default function ShiftDetailModal({
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
|
||||
>
|
||||
{statusLabel[shift.status]}
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Shift info grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -140,9 +140,7 @@ export default function ShiftDetailModal({
|
||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||
Thời lượng
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm font-bold">
|
||||
{shift.durationHours} giờ
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm font-bold">{""} giờ</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 p-3">
|
||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||
@@ -157,16 +155,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ý ({shift.registeredStaff!.length}/
|
||||
{shift.maxStaff})
|
||||
</p>
|
||||
{shift.registeredStaff.length === 0 ? (
|
||||
{shift.registeredStaff && shift.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) => (
|
||||
{shift.registeredStaff!.map((staff) => (
|
||||
<div
|
||||
key={staff.id}
|
||||
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
|
||||
@@ -176,7 +174,7 @@ export default function ShiftDetailModal({
|
||||
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
|
||||
</div>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{staff.name}
|
||||
{staff.staffId}
|
||||
</span>
|
||||
</div>
|
||||
{isManager && (
|
||||
@@ -212,7 +210,7 @@ export default function ShiftDetailModal({
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
|
||||
{!isRegistered &&
|
||||
{/* {!isRegistered &&
|
||||
!isFull &&
|
||||
shift.status !== "approved_leave" &&
|
||||
shift.status !== "absent" && (
|
||||
@@ -224,7 +222,7 @@ export default function ShiftDetailModal({
|
||||
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||
Đăng ký ca
|
||||
</button>
|
||||
)}
|
||||
)} */}
|
||||
{isRegistered && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { ShiftSlot } from "@/lib/types";
|
||||
import type { ShiftEntity } from "@/lib/types";
|
||||
|
||||
export interface WeeklyScheduleProps {
|
||||
onShiftClick: (shift: ShiftSlot) => void;
|
||||
onCreateShift?: (date: string) => void;
|
||||
onShiftClick: (shift: ShiftEntity) => void;
|
||||
onCreateShift?: (date: Date) => void;
|
||||
mobileCalendarHeader?: boolean;
|
||||
}
|
||||
|
||||
export interface MonthlyCalendarProps {
|
||||
onShiftClick: (shift: ShiftSlot) => void;
|
||||
onDateSelect?: (date: string) => void;
|
||||
onShiftClick: (shift: ShiftEntity) => void;
|
||||
onDateSelect?: (date: Date) => void;
|
||||
}
|
||||
|
||||
export interface MobileShiftViewProps {
|
||||
onShiftClick: (shift: ShiftSlot) => void;
|
||||
onShiftClick: (shift: ShiftEntity) => void;
|
||||
}
|
||||
|
||||
export interface ShiftDetailModalProps {
|
||||
shift: ShiftSlot | null;
|
||||
shift: ShiftEntity | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -24,5 +24,5 @@ export interface ShiftDetailModalProps {
|
||||
export interface ShiftCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
defaultDate?: string;
|
||||
defaultDate?: Date;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const MONTH_NAMES_EN = [
|
||||
"December",
|
||||
];
|
||||
|
||||
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
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 {
|
||||
@@ -37,7 +37,7 @@ function formatDateISO(d: Date): string {
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const today = new Date(2026, 3, 10);
|
||||
const today = new Date(Date.now());
|
||||
return (
|
||||
d.getDate() === today.getDate() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
@@ -59,42 +59,38 @@ export default function WeeklySchedule({
|
||||
} = useShift();
|
||||
const weekDates = getWeekDates();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
formatDateISO(weekDates[0] ?? currentDate),
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(
|
||||
weekDates[0] ?? currentDate,
|
||||
);
|
||||
|
||||
const statusDotsByDate = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
weekDates.forEach((date) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const dayShifts = getShiftsForDate(dateStr);
|
||||
const dots: string[] = [];
|
||||
if (dayShifts.some((s) => s.status === "available"))
|
||||
dots.push("bg-sky-300");
|
||||
if (dayShifts.some((s) => s.status === "registered"))
|
||||
dots.push("bg-blue-600");
|
||||
if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||
dots.push("bg-purple-400");
|
||||
if (dayShifts.some((s) => s.status === "absent"))
|
||||
dots.push("bg-rose-400");
|
||||
map[dateStr] = dots.slice(0, 3);
|
||||
// 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 selectedDateObj = useMemo(() => {
|
||||
const inWeek = weekDates.find((d) => formatDateISO(d) === selectedDate);
|
||||
const selectedDateStr = useMemo(() => {
|
||||
const inWeek = weekDates.find((d) => d === selectedDate);
|
||||
return inWeek ?? weekDates[0] ?? currentDate;
|
||||
}, [selectedDate, weekDates, currentDate]);
|
||||
|
||||
const selectedDateStr = formatDateISO(selectedDateObj);
|
||||
|
||||
const renderMobileDayView = mobileCalendarHeader && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
title="Tuần trước"
|
||||
title="Previous week"
|
||||
type="button"
|
||||
onClick={goToPrevWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -105,7 +101,7 @@ export default function WeeklySchedule({
|
||||
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Tuần sau"
|
||||
title="Next week"
|
||||
type="button"
|
||||
onClick={goToNextWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
@@ -116,14 +112,13 @@ export default function WeeklySchedule({
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{weekDates.map((date, i) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const active = dateStr === selectedDateStr;
|
||||
const dots = statusDotsByDate[dateStr] ?? [];
|
||||
const active = date === selectedDateStr;
|
||||
const dots = statusDotsByDate[date.toISOString()] ?? [];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(dateStr)}
|
||||
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"
|
||||
}`}
|
||||
@@ -135,14 +130,14 @@ export default function WeeklySchedule({
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
active || isToday(date)
|
||||
active || isToday(new Date(date))
|
||||
? "bg-indigo-500 text-white"
|
||||
: i >= 5
|
||||
? "text-pink-500"
|
||||
: "text-sky-500"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
{new Date(date).getDate()}
|
||||
</span>
|
||||
<div className="flex min-h-2 items-center gap-0.5">
|
||||
{dots.map((dot, idx) => (
|
||||
@@ -160,9 +155,10 @@ export default function WeeklySchedule({
|
||||
|
||||
<div className="space-y-3">
|
||||
{DEPARTMENTS.map((dept) => {
|
||||
const deptShifts = getShiftsForDate(selectedDateStr).filter(
|
||||
(s) => s.department === dept.id,
|
||||
);
|
||||
const deptShifts = getShiftsForDate(new Date(selectedDateStr));
|
||||
// .filter(
|
||||
// (s) => s.department === dept.id,
|
||||
// );
|
||||
if (deptShifts.length === 0 && !onCreateShift) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -192,7 +188,7 @@ export default function WeeklySchedule({
|
||||
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>
|
||||
Thêm ca
|
||||
Add shift
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -213,20 +209,20 @@ export default function WeeklySchedule({
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
||||
Bộ phận
|
||||
Department
|
||||
</th>
|
||||
{weekDates.map((date, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
|
||||
isToday(date)
|
||||
isToday(new Date(date))
|
||||
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
|
||||
: "bg-gray-50 font-semibold text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
<span className="block uppercase">{DAY_LABELS[i]}</span>
|
||||
<span className="mt-0.5 block text-[11px] font-normal">
|
||||
{formatDateShort(date)}
|
||||
{date.toISOString()}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
@@ -246,15 +242,16 @@ export default function WeeklySchedule({
|
||||
</div>
|
||||
</td>
|
||||
{weekDates.map((date, i) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const shifts = getShiftsForDate(dateStr).filter(
|
||||
(s) => s.department === dept.id,
|
||||
);
|
||||
const dateStr = date;
|
||||
const shifts = getShiftsForDate(new Date(date));
|
||||
// .filter(
|
||||
// (s) => s.department === dept.id,
|
||||
// );
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
|
||||
isToday(date) ? "bg-(--color-primary)/5" : ""
|
||||
isToday(new Date(date)) ? "bg-(--color-primary)/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-h-20 flex-col gap-1">
|
||||
@@ -273,7 +270,7 @@ export default function WeeklySchedule({
|
||||
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus mr-1"></i>
|
||||
Thêm ca
|
||||
Add shift
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { ShopCard } from "@/components/molecules/cards";
|
||||
import { MOCK_SHOPS } from "@/lib/constants";
|
||||
// import { ShopCard } from "@/components/molecules/cards";
|
||||
// import { MOCK_SHOPS } from "@/lib/constants";
|
||||
|
||||
import type { ShopGridProps } from "./ShopGrid.types";
|
||||
// import type { ShopGridProps } from "./ShopGrid.types";
|
||||
|
||||
export default function ShopGrid({
|
||||
searchName = "",
|
||||
searchAddress = "",
|
||||
}: ShopGridProps) {
|
||||
const filtered = MOCK_SHOPS.filter((shop) => {
|
||||
const matchesName =
|
||||
searchName.trim() === "" ||
|
||||
shop.name.toLowerCase().includes(searchName.toLowerCase());
|
||||
const matchesAddress =
|
||||
searchAddress.trim() === "" ||
|
||||
shop.address.toLowerCase().includes(searchAddress.toLowerCase());
|
||||
return matchesName && matchesAddress;
|
||||
});
|
||||
// export default function ShopGrid({
|
||||
// searchName = "",
|
||||
// searchAddress = "",
|
||||
// }: ShopGridProps) {
|
||||
// const filtered = MOCK_SHOPS.filter((shop) => {
|
||||
// const matchesName =
|
||||
// searchName.trim() === "" ||
|
||||
// shop.name.toLowerCase().includes(searchName.toLowerCase());
|
||||
// const matchesAddress =
|
||||
// searchAddress.trim() === "" ||
|
||||
// shop.address.toLowerCase().includes(searchAddress.toLowerCase());
|
||||
// return matchesName && matchesAddress;
|
||||
// });
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||
<i className="fa-solid fa-store text-5xl opacity-30"></i>
|
||||
<p className="text-base font-medium">Không tìm thấy quán nào phù hợp</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// if (filtered.length === 0) {
|
||||
// return (
|
||||
// <div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||
// <i className="fa-solid fa-store text-5xl opacity-30"></i>
|
||||
// <p className="text-base font-medium">
|
||||
// No shops found matching your search
|
||||
// </p>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((shop) => (
|
||||
<ShopCard
|
||||
key={shop.id}
|
||||
id={shop.id}
|
||||
name={shop.name}
|
||||
address={shop.address}
|
||||
image={shop.image}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// return (
|
||||
// <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
// {filtered.map((shop) => (
|
||||
// <ShopCard
|
||||
// key={shop.id}
|
||||
// id={shop.id}
|
||||
// name={shop.name}
|
||||
// address={shop.address}
|
||||
// image={shop.image}
|
||||
// />
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as ShopGrid } from "./ShopGrid";
|
||||
// export { default as ShopGrid } from "./ShopGrid";
|
||||
export type { ShopGridProps } from "./ShopGrid.types";
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { AuthLayoutProps } from "./AuthLayout.types";
|
||||
|
||||
/**
|
||||
* Auth layout template — centers content in the screen.
|
||||
* Used by login and register pages.
|
||||
*/
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as AuthLayout } from "./AuthLayout";
|
||||
export type { AuthLayoutProps } from "./AuthLayout.types";
|
||||
@@ -2,10 +2,6 @@
|
||||
export { MainLayout } from "./main-layout";
|
||||
export type { MainLayoutProps } from "./main-layout";
|
||||
|
||||
// Auth Layout
|
||||
export { AuthLayout } from "./auth-layout";
|
||||
export type { AuthLayoutProps } from "./auth-layout";
|
||||
|
||||
// Feed Layout
|
||||
export { FeedLayout } from "./feed-layout";
|
||||
export type { FeedLayoutProps } from "./feed-layout";
|
||||
|
||||
@@ -13,16 +13,16 @@ import type { ManagerLayoutProps } from "./ManagerLayout.types";
|
||||
* Redirects non-managers away; shows loading state while auth resolves.
|
||||
*/
|
||||
export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
||||
const { user } = useAuth();
|
||||
const { user, isInitialized } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null && user.role !== "manager") {
|
||||
if (isInitialized && user !== null && user.role !== "manager") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [user, router]);
|
||||
}, [user, isInitialized, router]);
|
||||
|
||||
if (user === null) {
|
||||
if (!isInitialized || user === null) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Cài đặt pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN npm install -g pnpm@10.9.0
|
||||
|
||||
# Copy file định nghĩa package để tận dụng cache của Docker
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
@@ -16,7 +16,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend-container
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.2
|
||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.9
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
resources:
|
||||
|
||||
+9
-9
@@ -48,7 +48,7 @@ export default function Footer() {
|
||||
<ul className="flex flex-col gap-2 text-sm opacity-80">
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="fa-solid fa-location-dot mt-0.5 w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||
<span>Địa chỉ: {SHOP_INFO.address}</span>
|
||||
<span>Address: {SHOP_INFO.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i>
|
||||
@@ -56,7 +56,7 @@ export default function Footer() {
|
||||
href={`tel:${SHOP_INFO.phone}`}
|
||||
className="transition-colors duration-150 hover:text-(--color-accent)"
|
||||
>
|
||||
Số điện thoại: {SHOP_INFO.phone}
|
||||
Phone: {SHOP_INFO.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
@@ -83,7 +83,7 @@ export default function Footer() {
|
||||
{/* ── 2. Social links ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
Kết nối
|
||||
Follow Us
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<li>
|
||||
@@ -129,20 +129,20 @@ export default function Footer() {
|
||||
{/* ── 3. WiFi card ── */}
|
||||
<div className="col-span-1">
|
||||
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
|
||||
WiFi Miễn Phí
|
||||
Free WiFi
|
||||
</h3>
|
||||
<div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
|
||||
<span className="text-sm font-semibold">
|
||||
Kết nối miễn phí
|
||||
Connect for free
|
||||
</span>
|
||||
</div>
|
||||
{/* Stacked label + value rows — no overflow risk */}
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||
Tên mạng
|
||||
Network name
|
||||
</span>
|
||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)">
|
||||
{SHOP_INFO.wifi.name}
|
||||
@@ -150,7 +150,7 @@ export default function Footer() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs tracking-wide uppercase opacity-60">
|
||||
Mật khẩu
|
||||
Password
|
||||
</span>
|
||||
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)">
|
||||
{SHOP_INFO.wifi.password}
|
||||
@@ -170,9 +170,9 @@ export default function Footer() {
|
||||
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
Được vận hành{" "}
|
||||
Powered by{" "}
|
||||
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
|
||||
bằng Drinkool
|
||||
Drinkool
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-7
@@ -72,11 +72,11 @@ export default function Header() {
|
||||
/* Guest: sign-in button */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Đăng nhập"
|
||||
title="Sign in"
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-right-to-bracket"></i>
|
||||
<span className="hidden sm:inline">Đăng nhập</span>
|
||||
<span className="hidden sm:inline">Sign in</span>
|
||||
</button>
|
||||
) : user.role === "manager" ? (
|
||||
/* Manager: dashboard link + logout */
|
||||
@@ -90,7 +90,8 @@ export default function Header() {
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Đăng xuất"
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-right-from-bracket text-base"></i>
|
||||
@@ -104,11 +105,12 @@ export default function Header() {
|
||||
className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-check text-base"></i>
|
||||
<span className="hidden sm:inline">Ca làm</span>
|
||||
<span className="hidden sm:inline">My Shifts</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title="Nhấn để đăng xuất"
|
||||
title="Click to sign out"
|
||||
aria-label="Sign out"
|
||||
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
|
||||
>
|
||||
{/* Avatar circle */}
|
||||
@@ -122,14 +124,14 @@ export default function Header() {
|
||||
/* Customer: phone icon + label */
|
||||
<button
|
||||
onClick={handleAuthClick}
|
||||
title={`Khách hàng - ${user.phone || ""} - Nhấn để đăng xuất`}
|
||||
title={`Customer - ${user.phone || ""} - Click to sign out`}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary-light) px-4 py-2 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary) active:scale-95"
|
||||
>
|
||||
{/* Customer icon */}
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)">
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
<span className="hidden sm:inline">Khách hàng</span>
|
||||
<span className="hidden sm:inline">Customer</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
ApolloClient,
|
||||
ApolloLink,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
Observable,
|
||||
} from "@apollo/client";
|
||||
|
||||
const responseWrapperLink = new ApolloLink((operation, forward) => {
|
||||
return new Observable((observer) => {
|
||||
const handle = forward(operation).subscribe({
|
||||
next: (response) => {
|
||||
// Kiểm tra nếu dữ liệu trả về bị "trần" (thiếu data property)
|
||||
if (
|
||||
response &&
|
||||
!Object.prototype.hasOwnProperty.call(response, "data")
|
||||
) {
|
||||
// Khởi tạo một object mới theo chuẩn GraphQL
|
||||
const formattedResponse = {
|
||||
data: response,
|
||||
errors: (response as any).errors,
|
||||
};
|
||||
observer.next(formattedResponse);
|
||||
} else {
|
||||
observer.next(response);
|
||||
}
|
||||
},
|
||||
error: observer.error.bind(observer),
|
||||
complete: observer.complete.bind(observer),
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (handle) handle.unsubscribe();
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
export const cartClient = new ApolloClient({
|
||||
link: ApolloLink.from([
|
||||
responseWrapperLink,
|
||||
new HttpLink({ uri: "/api/cart/graphql" }),
|
||||
]),
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
|
||||
export const eateryClient = new ApolloClient({
|
||||
link: ApolloLink.from([
|
||||
responseWrapperLink,
|
||||
new HttpLink({ uri: "/api/eatery/graphql" }),
|
||||
]),
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
+35
-114
@@ -12,95 +12,21 @@ import type { User } from "./types";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
isInitialized: boolean;
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<{ ok: boolean; status?: number }>;
|
||||
logout: () => void;
|
||||
registerPhone: string | null;
|
||||
setRegisterPhone: (phone: string | null) => void;
|
||||
completeRegistration: (phone: string) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// Mock user database
|
||||
const MOCK_AUTH_DB = {
|
||||
// Admin
|
||||
admin: {
|
||||
username: "admin",
|
||||
password: "admin",
|
||||
user: {
|
||||
id: 1,
|
||||
name: "Quản lý",
|
||||
role: "manager" as const,
|
||||
avatar: null,
|
||||
phone: "0912345678",
|
||||
},
|
||||
},
|
||||
|
||||
// Staff (username and password are their names)
|
||||
"Nguyễn Văn An": {
|
||||
username: "Nguyễn Văn An",
|
||||
password: "Nguyễn Văn An",
|
||||
user: {
|
||||
id: 2,
|
||||
name: "Nguyễn Văn An",
|
||||
role: "staff" as const,
|
||||
avatar: null,
|
||||
phone: "0901234567",
|
||||
},
|
||||
},
|
||||
"Trần Thị Bình": {
|
||||
username: "Trần Thị Bình",
|
||||
password: "Trần Thị Bình",
|
||||
user: {
|
||||
id: 3,
|
||||
name: "Trần Thị Bình",
|
||||
role: "staff" as const,
|
||||
avatar: null,
|
||||
phone: "0902345678",
|
||||
},
|
||||
},
|
||||
"Lê Văn Cường": {
|
||||
username: "Lê Văn Cường",
|
||||
password: "Lê Văn Cường",
|
||||
user: {
|
||||
id: 4,
|
||||
name: "Lê Văn Cường",
|
||||
role: "staff" as const,
|
||||
avatar: null,
|
||||
phone: "0903456789",
|
||||
},
|
||||
},
|
||||
|
||||
// Customers (username is phone number, password is custom)
|
||||
"0987654321": {
|
||||
username: "0987654321",
|
||||
password: "user1",
|
||||
user: {
|
||||
id: 5,
|
||||
name: "Khách hàng",
|
||||
role: "customer" as const,
|
||||
avatar: null,
|
||||
phone: "0987654321",
|
||||
},
|
||||
},
|
||||
"0976543210": {
|
||||
username: "0976543210",
|
||||
password: "user1",
|
||||
user: {
|
||||
id: 6,
|
||||
name: "Khách hàng",
|
||||
role: "customer" as const,
|
||||
avatar: null,
|
||||
phone: "0976543210",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("coffee-shop-user");
|
||||
if (savedUser) {
|
||||
@@ -110,18 +36,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.error("Failed to parse saved user", e);
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const login = (username: string, password: string): boolean => {
|
||||
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB];
|
||||
const login = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ ok: boolean; status?: number }> => {
|
||||
const isPhone = /^0\d{9}$/.test(username.trim());
|
||||
const role = isPhone ? "customer" : "manager";
|
||||
|
||||
if (authEntry && authEntry.password === password) {
|
||||
setUser(authEntry.user);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user));
|
||||
return true;
|
||||
try {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: username, password, role }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
setUser(userData);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
|
||||
return { ok: true };
|
||||
} else {
|
||||
console.error("Đăng nhập thất bại:", response.status);
|
||||
return { ok: false, status: response.status };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi kết nối API:", error);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
@@ -129,37 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.removeItem("coffee-shop-user");
|
||||
};
|
||||
|
||||
const completeRegistration = (phone: string) => {
|
||||
// Create new customer account
|
||||
const newUser: User = {
|
||||
id: Date.now(),
|
||||
name: "Khách hàng",
|
||||
role: "customer",
|
||||
avatar: null,
|
||||
phone,
|
||||
};
|
||||
|
||||
// Add to mock database (in real app, this would be API call)
|
||||
(MOCK_AUTH_DB as any)[phone] = {
|
||||
username: phone,
|
||||
password: "user1", // Default password for new customers
|
||||
user: newUser,
|
||||
};
|
||||
|
||||
setUser(newUser);
|
||||
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser));
|
||||
setRegisterPhone(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
setUser,
|
||||
isInitialized,
|
||||
login,
|
||||
logout,
|
||||
registerPhone,
|
||||
setRegisterPhone,
|
||||
completeRegistration,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+152
-90
@@ -1,134 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import type { Product } from "@/lib/types";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
import { cartClient, eateryClient } from "./apollo-clients";
|
||||
import {
|
||||
CartEntity,
|
||||
CartItemEntity,
|
||||
addMenuItemMutation,
|
||||
allEateriesQuery,
|
||||
createCartMutation,
|
||||
getCartQuery,
|
||||
} from "./types";
|
||||
|
||||
interface CartContextValue {
|
||||
items: CartItem[];
|
||||
items: CartItemEntity[];
|
||||
totalItems: number;
|
||||
totalPrice: number;
|
||||
addToCart: (product: Product) => void;
|
||||
increaseQty: (id: number) => void;
|
||||
decreaseQty: (id: number) => void;
|
||||
removeFromCart: (id: number) => void;
|
||||
setQuantity: (id: number, quantity: number) => void;
|
||||
addToCart: (product: CartItemEntity) => void;
|
||||
increaseQty: (id: string) => void;
|
||||
decreaseQty: (id: string) => void;
|
||||
removeFromCart: (id: string) => void;
|
||||
setQuantity: (id: string, quantity: number) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "coffee-shop-cart";
|
||||
const CART_ID = "cartId";
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
const GET_CART_ITEMS = gql`
|
||||
query getCart($cartId: String!) {
|
||||
getCart(cartId: $cartId) {
|
||||
Id
|
||||
userId
|
||||
eateryId
|
||||
items {
|
||||
productId
|
||||
quantity
|
||||
priceAtTimeOfAdding
|
||||
subTotal
|
||||
}
|
||||
totalAmount
|
||||
paymentQrUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GET_EATERY = gql`
|
||||
query GetEateryMenu {
|
||||
allEateries {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CREATE_CART = gql`
|
||||
mutation createCart($eateryId: String!) {
|
||||
createCart(eateryId: $eateryId)
|
||||
}
|
||||
`;
|
||||
|
||||
const ADD_ITEM = gql`
|
||||
mutation addItem(
|
||||
$cartId: String!
|
||||
$menuItemId: String!
|
||||
$quantity: BigInteger!
|
||||
) {
|
||||
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
|
||||
Id
|
||||
userId
|
||||
eateryId
|
||||
items {
|
||||
productId
|
||||
quantity
|
||||
priceAtTimeOfAdding
|
||||
subTotal
|
||||
}
|
||||
totalAmount
|
||||
paymentQrUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
const [cart, setCart] = useState<CartEntity>(null!);
|
||||
const [cartId, setCartId] = useState<string | null>(null);
|
||||
|
||||
const { data: eateryData } = useQuery<allEateriesQuery>(GET_EATERY, {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
const [createCart] = useMutation<createCartMutation>(CREATE_CART, {
|
||||
client: cartClient,
|
||||
});
|
||||
|
||||
const { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, {
|
||||
client: cartClient,
|
||||
variables: { cartId },
|
||||
});
|
||||
|
||||
const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, {
|
||||
client: cartClient,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as CartItem[];
|
||||
if (Array.isArray(parsed)) {
|
||||
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
|
||||
const createCartFunc = async () => {
|
||||
if (eateryData && eateryData.allEateries?.length > 0) {
|
||||
try {
|
||||
const firstEateryId = eateryData.allEateries[0].id;
|
||||
const { data: mutationResult } = await createCart({
|
||||
variables: { eateryId: firstEateryId },
|
||||
});
|
||||
|
||||
const newCartId = mutationResult!.createCart;
|
||||
if (newCartId) {
|
||||
localStorage.setItem(CART_ID, newCartId);
|
||||
setCartId(newCartId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Lỗi khi tạo giỏ hàng:", err);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
createCartFunc();
|
||||
} else if (!cartId) {
|
||||
const localCartId = localStorage.getItem(CART_ID);
|
||||
if (localCartId) setCartId(localCartId);
|
||||
else createCartFunc();
|
||||
}
|
||||
}, []);
|
||||
}, [eateryData, createCart, data, loading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
if (data?.getCart) setCart(data.getCart);
|
||||
}, [data]);
|
||||
|
||||
const addToCart = (product: Product) => {
|
||||
setItems((prev) => {
|
||||
const index = prev.findIndex((i) => i.id === product.id);
|
||||
if (index === -1) {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
const addToCart = async (product: CartItemEntity) => {
|
||||
if (!cartId) return;
|
||||
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
|
||||
return next;
|
||||
const { data: result } = await addMenuItem({
|
||||
variables: {
|
||||
cartId,
|
||||
menuItemId: product.productId!,
|
||||
quantity: product.quantity,
|
||||
},
|
||||
});
|
||||
|
||||
if (result) setCart(result.addItem);
|
||||
};
|
||||
|
||||
const increaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
|
||||
),
|
||||
);
|
||||
const setQuantity = async (id: string, newQuantity: number) => {
|
||||
if (!cartId) return;
|
||||
|
||||
const currentItem = cart.items.find((i) => i.productId == id);
|
||||
|
||||
const { data: result } = await addMenuItem({
|
||||
variables: {
|
||||
cartId,
|
||||
menuItemId: id,
|
||||
quantity: newQuantity - currentItem!.quantity,
|
||||
},
|
||||
});
|
||||
|
||||
if (result) setCart(result.addItem);
|
||||
};
|
||||
|
||||
const decreaseQty = (id: number) => {
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, quantity: Math.max(0, item.quantity - 1) }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => item.quantity > 0),
|
||||
);
|
||||
};
|
||||
const removeFromCart = (id: string) => setQuantity(id, 0);
|
||||
|
||||
const removeFromCart = (id: number) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
const increaseQty = (id: string) =>
|
||||
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity + 1);
|
||||
|
||||
const setQuantity = (id: number, quantity: number) => {
|
||||
const safeQty = Number.isFinite(quantity)
|
||||
? Math.max(0, Math.floor(quantity))
|
||||
: 0;
|
||||
if (safeQty === 0) {
|
||||
removeFromCart(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, quantity: safeQty } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
const decreaseQty = (id: string) =>
|
||||
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity - 1);
|
||||
|
||||
const totalItems = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
||||
[items],
|
||||
() => cart?.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[cart],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
items,
|
||||
items: cart?.items,
|
||||
totalItems,
|
||||
totalPrice,
|
||||
totalPrice: cart?.totalAmount,
|
||||
addToCart,
|
||||
increaseQty,
|
||||
decreaseQty,
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
}),
|
||||
[items, totalItems, totalPrice],
|
||||
[cart?.items, cart?.totalAmount],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
|
||||
+51
-561
@@ -1,23 +1,17 @@
|
||||
import type {
|
||||
Combo,
|
||||
Department,
|
||||
MenuCategory,
|
||||
Product,
|
||||
ProductSalesStats,
|
||||
RevenueDataPoint,
|
||||
ShiftSlot,
|
||||
Shop,
|
||||
ShiftEntity,
|
||||
ShopInfo,
|
||||
SocialLinks,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
// ===== SHOP INFORMATION =====
|
||||
export const SHOP_INFO: ShopInfo = {
|
||||
name: "Coffee Shop",
|
||||
tagline: "Hương vị đậm đà – Khoảnh khắc thư giãn",
|
||||
tagline: "Rich Flavors – Moments of Relaxation",
|
||||
logo: "/imgs/logo.png",
|
||||
address: "123 Đường Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
|
||||
address: "123 Nguyen Hue Street, District 1, Ho Chi Minh City",
|
||||
phone: "0901 234 567",
|
||||
managerPhone: "0912 345 678",
|
||||
email: "contact@coffeeshop.vn",
|
||||
@@ -25,7 +19,7 @@ export const SHOP_INFO: ShopInfo = {
|
||||
name: "CoffeeShop_Free",
|
||||
password: "coffee2024",
|
||||
},
|
||||
openHours: "07:00 – 22:00 (Thứ 2 – Chủ nhật)",
|
||||
openHours: "07:00 – 22:00 (Monday – Sunday)",
|
||||
};
|
||||
|
||||
// ===== SOCIAL LINKS =====
|
||||
@@ -35,287 +29,6 @@ export const SOCIAL_LINKS: SocialLinks = {
|
||||
website: "/",
|
||||
};
|
||||
|
||||
// ===== MENU CATEGORIES =====
|
||||
// Each category has a unique FontAwesome icon representing the item type
|
||||
export const MENU_CATEGORIES: MenuCategory[] = [
|
||||
{ id: "all", name: "Tất cả", icon: "fa-solid fa-border-all" },
|
||||
{ id: "cafe", name: "Cà Phê", icon: "fa-solid fa-mug-hot" },
|
||||
{ id: "tra", name: "Trà", icon: "fa-solid fa-leaf" },
|
||||
{ id: "sua-chua", name: "Sữa Chua", icon: "fa-solid fa-jar" },
|
||||
{ id: "nuoc-ep", name: "Nước Ép", icon: "fa-solid fa-blender" },
|
||||
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
|
||||
{
|
||||
id: "giai-khat",
|
||||
name: "Giải Khát / Ăn Vặt",
|
||||
icon: "fa-solid fa-ice-cream",
|
||||
},
|
||||
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
|
||||
];
|
||||
|
||||
// ===== MOCK PRODUCTS =====
|
||||
// Placeholder data – replace with real API calls when backend is ready
|
||||
export const MOCK_PRODUCTS: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Cà Phê Đen",
|
||||
category: "cafe",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Cà Phê Sữa",
|
||||
category: "cafe",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Cà phê sữa đặc thơm ngon, béo ngậy, kết hợp hoàn hảo giữa cà phê và sữa đặc.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Bạc Xỉu",
|
||||
category: "cafe",
|
||||
price: 32000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Bạc xỉu nhẹ nhàng, ít cà phê nhiều sữa, thích hợp cho người mới uống cà phê.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Cà Phê Trứng",
|
||||
category: "cafe",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Cà phê trứng đặc sản Hà Nội, lớp kem trứng mịn màng phủ trên nền cà phê đậm đà.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Trà Đào Cam Sả",
|
||||
category: "tra",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Trà đào thơm mát kết hợp cam tươi và sả, thanh mát và giải nhiệt tuyệt vời.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Trà Xanh Matcha",
|
||||
category: "tra",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Matcha Nhật Bản nguyên chất, vị đắng nhẹ đặc trưng, thơm mát và bổ dưỡng.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Trà Vải Hoa Nhài",
|
||||
category: "tra",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Trà vải thanh ngọt kết hợp hương hoa nhài dịu dàng, thư giãn tâm hồn.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Sữa Chua Trân Châu",
|
||||
category: "sua-chua",
|
||||
price: 38000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Sữa chua mịn màng kết hợp trân châu đen dẻo dai, chua ngọt hài hòa.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Sữa Chua Dâu",
|
||||
category: "sua-chua",
|
||||
price: 40000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Sữa chua mát lạnh với dâu tươi ngọt chua, giàu vitamin và khoáng chất.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "Nước Ép Cam",
|
||||
category: "nuoc-ep",
|
||||
price: 35000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Nước ép cam tươi nguyên chất, giàu vitamin C, tốt cho sức khỏe.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: "Nước Ép Dưa Hấu",
|
||||
category: "nuoc-ep",
|
||||
price: 30000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Nước ép dưa hấu mát lạnh, giải nhiệt tức thì trong những ngày hè oi bức.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "Latte Caramel",
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Latte caramel ngọt ngào, thơm béo với lớp foam sữa mịn và sốt caramel.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: "Latte Vanilla",
|
||||
category: "latte",
|
||||
price: 45000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: "Bánh Mì Nướng Bơ",
|
||||
category: "giai-khat",
|
||||
price: 20000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Bánh mì nướng giòn rụm, phết bơ thơm và mứt dâu, ăn kèm cà phê tuyệt vời.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: "Bánh Flan",
|
||||
category: "giai-khat",
|
||||
price: 25000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Bánh flan mềm mịn, ngọt ngào với lớp caramel vàng óng, tan chảy trong miệng.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
name: "Trân Châu Đen",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Trân châu đen dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
name: "Thạch Cà Phê",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Thạch cà phê mát lạnh, thêm hương vị đặc biệt cho đồ uống của bạn.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
name: "Trân Châu Trắng",
|
||||
category: "topping",
|
||||
price: 10000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
description:
|
||||
"Trân châu trắng dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.",
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK COMBOS =====
|
||||
export const MOCK_COMBOS: Combo[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Combo Cà Phê Đôi",
|
||||
description: "2 ly cà phê đen + 2 bánh mì nướng bơ, tiết kiệm 15%.",
|
||||
price: 75000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
{ productId: 1, quantity: 2 },
|
||||
{ productId: 14, quantity: 2 },
|
||||
],
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Combo Trà Sữa Nhóm",
|
||||
description: "2 trà đào cam sả + 2 trà xanh matcha, dành cho nhóm bạn.",
|
||||
price: 130000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
{ productId: 5, quantity: 2 },
|
||||
{ productId: 6, quantity: 2 },
|
||||
],
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Combo Buổi Sáng",
|
||||
description: "1 cà phê sữa + 1 bánh flan, khởi đầu ngày mới ngọt ngào.",
|
||||
price: 48000,
|
||||
image: "/imgs/products/placeholder.jpg",
|
||||
items: [
|
||||
{ productId: 2, quantity: 1 },
|
||||
{ productId: 15, quantity: 1 },
|
||||
],
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK SHOPS (for Feed page) =====
|
||||
export const MOCK_SHOPS: Shop[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "The Coffee House",
|
||||
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1554118811-1e0d58224f24?w=600&h=400&fit=crop",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Highlands Coffee",
|
||||
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1559305616-3f99cd43e353?w=600&h=400&fit=crop",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Phúc Long Heritage",
|
||||
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=600&h=400&fit=crop",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Katinat Saigon Kafe",
|
||||
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600&h=400&fit=crop",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Trung Nguyên E-Coffee",
|
||||
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1453614512568-c4024d13c247?w=600&h=400&fit=crop",
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK FINANCIAL DATA =====
|
||||
|
||||
// Daily revenue for the last 30 days (current month)
|
||||
@@ -354,34 +67,34 @@ export const MOCK_REVENUE_DAILY: RevenueDataPoint[] = [
|
||||
|
||||
// Weekly revenue (last 12 weeks)
|
||||
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
|
||||
{ label: "T1/W1", revenue: 8200000, orders: 295 },
|
||||
{ label: "T1/W2", revenue: 9450000, orders: 340 },
|
||||
{ label: "T1/W3", revenue: 10100000, orders: 362 },
|
||||
{ label: "T1/W4", revenue: 8750000, orders: 315 },
|
||||
{ label: "T2/W1", revenue: 9200000, orders: 330 },
|
||||
{ label: "T2/W2", revenue: 10500000, orders: 378 },
|
||||
{ label: "T2/W3", revenue: 11200000, orders: 400 },
|
||||
{ label: "T2/W4", revenue: 9800000, orders: 352 },
|
||||
{ label: "T3/W1", revenue: 10400000, orders: 374 },
|
||||
{ label: "T3/W2", revenue: 11800000, orders: 424 },
|
||||
{ label: "T3/W3", revenue: 12500000, orders: 448 },
|
||||
{ label: "T3/W4", revenue: 10900000, orders: 392 },
|
||||
{ label: "Jan/W1", revenue: 8200000, orders: 295 },
|
||||
{ label: "Jan/W2", revenue: 9450000, orders: 340 },
|
||||
{ label: "Jan/W3", revenue: 10100000, orders: 362 },
|
||||
{ label: "Jan/W4", revenue: 8750000, orders: 315 },
|
||||
{ label: "Feb/W1", revenue: 9200000, orders: 330 },
|
||||
{ label: "Feb/W2", revenue: 10500000, orders: 378 },
|
||||
{ label: "Feb/W3", revenue: 11200000, orders: 400 },
|
||||
{ label: "Feb/W4", revenue: 9800000, orders: 352 },
|
||||
{ label: "Mar/W1", revenue: 10400000, orders: 374 },
|
||||
{ label: "Mar/W2", revenue: 11800000, orders: 424 },
|
||||
{ label: "Mar/W3", revenue: 12500000, orders: 448 },
|
||||
{ label: "Mar/W4", revenue: 10900000, orders: 392 },
|
||||
];
|
||||
|
||||
// Monthly revenue (last 12 months)
|
||||
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
|
||||
{ label: "T4/2025", revenue: 42000000, orders: 1512 },
|
||||
{ label: "T5/2025", revenue: 45500000, orders: 1638 },
|
||||
{ label: "T6/2025", revenue: 48000000, orders: 1728 },
|
||||
{ label: "T7/2025", revenue: 52000000, orders: 1872 },
|
||||
{ label: "T8/2025", revenue: 49500000, orders: 1782 },
|
||||
{ label: "T9/2025", revenue: 46800000, orders: 1685 },
|
||||
{ label: "T10/2025", revenue: 51200000, orders: 1843 },
|
||||
{ label: "T11/2025", revenue: 55000000, orders: 1980 },
|
||||
{ label: "T12/2025", revenue: 62000000, orders: 2232 },
|
||||
{ label: "T1/2026", revenue: 44000000, orders: 1584 },
|
||||
{ label: "T2/2026", revenue: 47500000, orders: 1710 },
|
||||
{ label: "T3/2026", revenue: 53500000, orders: 1926 },
|
||||
{ label: "Apr/2025", revenue: 42000000, orders: 1512 },
|
||||
{ label: "May/2025", revenue: 45500000, orders: 1638 },
|
||||
{ label: "Jun/2025", revenue: 48000000, orders: 1728 },
|
||||
{ label: "Jul/2025", revenue: 52000000, orders: 1872 },
|
||||
{ label: "Aug/2025", revenue: 49500000, orders: 1782 },
|
||||
{ label: "Sep/2025", revenue: 46800000, orders: 1685 },
|
||||
{ label: "Oct/2025", revenue: 51200000, orders: 1843 },
|
||||
{ label: "Nov/2025", revenue: 55000000, orders: 1980 },
|
||||
{ label: "Dec/2025", revenue: 62000000, orders: 2232 },
|
||||
{ label: "Jan/2026", revenue: 44000000, orders: 1584 },
|
||||
{ label: "Feb/2026", revenue: 47500000, orders: 1710 },
|
||||
{ label: "Mar/2026", revenue: 53500000, orders: 1926 },
|
||||
];
|
||||
|
||||
// Yearly revenue (last 5 years)
|
||||
@@ -393,240 +106,13 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
|
||||
{ label: "2026", revenue: 180000000, orders: 6480 },
|
||||
];
|
||||
|
||||
// Product sales statistics (with cost price for profit analysis)
|
||||
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
|
||||
{
|
||||
productId: 12,
|
||||
name: "Latte Caramel",
|
||||
category: "latte",
|
||||
unitsSold: 487,
|
||||
revenue: 21915000,
|
||||
costPrice: 18000,
|
||||
sellingPrice: 45000,
|
||||
profit: 13185000,
|
||||
profitMargin: 60.2,
|
||||
},
|
||||
{
|
||||
productId: 6,
|
||||
name: "Trà Xanh Matcha",
|
||||
category: "tra",
|
||||
unitsSold: 412,
|
||||
revenue: 16480000,
|
||||
costPrice: 15000,
|
||||
sellingPrice: 40000,
|
||||
profit: 10300000,
|
||||
profitMargin: 62.5,
|
||||
},
|
||||
{
|
||||
productId: 5,
|
||||
name: "Trà Đào Cam Sả",
|
||||
category: "tra",
|
||||
unitsSold: 398,
|
||||
revenue: 13930000,
|
||||
costPrice: 12000,
|
||||
sellingPrice: 35000,
|
||||
profit: 9153000,
|
||||
profitMargin: 65.7,
|
||||
},
|
||||
{
|
||||
productId: 13,
|
||||
name: "Latte Vanilla",
|
||||
category: "latte",
|
||||
unitsSold: 356,
|
||||
revenue: 16020000,
|
||||
costPrice: 18000,
|
||||
sellingPrice: 45000,
|
||||
profit: 9612000,
|
||||
profitMargin: 60.0,
|
||||
},
|
||||
{
|
||||
productId: 4,
|
||||
name: "Cà Phê Trứng",
|
||||
category: "cafe",
|
||||
unitsSold: 340,
|
||||
revenue: 15300000,
|
||||
costPrice: 16000,
|
||||
sellingPrice: 45000,
|
||||
profit: 9860000,
|
||||
profitMargin: 64.4,
|
||||
},
|
||||
{
|
||||
productId: 2,
|
||||
name: "Cà Phê Sữa",
|
||||
category: "cafe",
|
||||
unitsSold: 325,
|
||||
revenue: 9750000,
|
||||
costPrice: 10000,
|
||||
sellingPrice: 30000,
|
||||
profit: 6500000,
|
||||
profitMargin: 66.7,
|
||||
},
|
||||
{
|
||||
productId: 3,
|
||||
name: "Bạc Xỉu",
|
||||
category: "cafe",
|
||||
unitsSold: 298,
|
||||
revenue: 9536000,
|
||||
costPrice: 11000,
|
||||
sellingPrice: 32000,
|
||||
profit: 6259000,
|
||||
profitMargin: 65.6,
|
||||
},
|
||||
{
|
||||
productId: 1,
|
||||
name: "Cà Phê Đen",
|
||||
category: "cafe",
|
||||
unitsSold: 285,
|
||||
revenue: 7125000,
|
||||
costPrice: 8000,
|
||||
sellingPrice: 25000,
|
||||
profit: 4845000,
|
||||
profitMargin: 68.0,
|
||||
},
|
||||
{
|
||||
productId: 9,
|
||||
name: "Sữa Chua Dâu",
|
||||
category: "sua-chua",
|
||||
unitsSold: 267,
|
||||
revenue: 10680000,
|
||||
costPrice: 15000,
|
||||
sellingPrice: 40000,
|
||||
profit: 6675000,
|
||||
profitMargin: 62.5,
|
||||
},
|
||||
{
|
||||
productId: 10,
|
||||
name: "Nước Ép Cam",
|
||||
category: "nuoc-ep",
|
||||
unitsSold: 241,
|
||||
revenue: 8435000,
|
||||
costPrice: 12000,
|
||||
sellingPrice: 35000,
|
||||
profit: 5543000,
|
||||
profitMargin: 65.7,
|
||||
},
|
||||
{
|
||||
productId: 8,
|
||||
name: "Sữa Chua Trân Châu",
|
||||
category: "sua-chua",
|
||||
unitsSold: 228,
|
||||
revenue: 8664000,
|
||||
costPrice: 14000,
|
||||
sellingPrice: 38000,
|
||||
profit: 5472000,
|
||||
profitMargin: 63.2,
|
||||
},
|
||||
{
|
||||
productId: 7,
|
||||
name: "Trà Vải Hoa Nhài",
|
||||
category: "tra",
|
||||
unitsSold: 215,
|
||||
revenue: 8170000,
|
||||
costPrice: 13000,
|
||||
sellingPrice: 38000,
|
||||
profit: 5375000,
|
||||
profitMargin: 65.8,
|
||||
},
|
||||
{
|
||||
productId: 15,
|
||||
name: "Bánh Flan",
|
||||
category: "giai-khat",
|
||||
unitsSold: 198,
|
||||
revenue: 4950000,
|
||||
costPrice: 8000,
|
||||
sellingPrice: 25000,
|
||||
profit: 3366000,
|
||||
profitMargin: 68.0,
|
||||
},
|
||||
{
|
||||
productId: 11,
|
||||
name: "Nước Ép Dưa Hấu",
|
||||
category: "nuoc-ep",
|
||||
unitsSold: 182,
|
||||
revenue: 5460000,
|
||||
costPrice: 10000,
|
||||
sellingPrice: 30000,
|
||||
profit: 3640000,
|
||||
profitMargin: 66.7,
|
||||
},
|
||||
{
|
||||
productId: 14,
|
||||
name: "Bánh Mì Nướng Bơ",
|
||||
category: "giai-khat",
|
||||
unitsSold: 175,
|
||||
revenue: 3500000,
|
||||
costPrice: 6000,
|
||||
sellingPrice: 20000,
|
||||
profit: 2450000,
|
||||
profitMargin: 70.0,
|
||||
},
|
||||
{
|
||||
productId: 16,
|
||||
name: "Trân Châu Đen",
|
||||
category: "topping",
|
||||
unitsSold: 456,
|
||||
revenue: 4560000,
|
||||
costPrice: 2000,
|
||||
sellingPrice: 10000,
|
||||
profit: 3648000,
|
||||
profitMargin: 80.0,
|
||||
},
|
||||
{
|
||||
productId: 17,
|
||||
name: "Thạch Cà Phê",
|
||||
category: "topping",
|
||||
unitsSold: 389,
|
||||
revenue: 3890000,
|
||||
costPrice: 2000,
|
||||
sellingPrice: 10000,
|
||||
profit: 3112000,
|
||||
profitMargin: 80.0,
|
||||
},
|
||||
{
|
||||
productId: 18,
|
||||
name: "Trân Châu Trắng",
|
||||
category: "topping",
|
||||
unitsSold: 342,
|
||||
revenue: 3420000,
|
||||
costPrice: 2000,
|
||||
sellingPrice: 10000,
|
||||
profit: 2736000,
|
||||
profitMargin: 80.0,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== MOCK USERS (for UI demo – replace with real auth) =====
|
||||
export const MOCK_USERS: Record<string, User> = {
|
||||
manager: {
|
||||
id: 1,
|
||||
name: "Nguyễn Văn An",
|
||||
role: "manager",
|
||||
avatar: null,
|
||||
},
|
||||
staff: {
|
||||
id: 2,
|
||||
name: "Trần Thị Bình",
|
||||
role: "staff",
|
||||
avatar: null,
|
||||
},
|
||||
};
|
||||
|
||||
// ===== SHIFT / SCHEDULE DATA =====
|
||||
|
||||
export const DEPARTMENTS: Department[] = [
|
||||
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" },
|
||||
{ id: "kitchen", name: "Kitchen", icon: "fa-solid fa-kitchen-set" },
|
||||
{ id: "cashier", name: "Cashier", icon: "fa-solid fa-cash-register" },
|
||||
{ id: "janitor", name: "Janitor", icon: "fa-solid fa-broom" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate mock shift slots for the weeks around today (April 2026).
|
||||
* Covers Mon 6 Apr – Sun 26 Apr 2026.
|
||||
*/
|
||||
function generateMockShifts(): ShiftSlot[] {
|
||||
const shifts: ShiftSlot[] = [];
|
||||
const departments = ["bar", "kitchen", "cashier", "janitor"];
|
||||
function generateMockShifts(): ShiftEntity[] {
|
||||
const shifts: ShiftEntity[] = [];
|
||||
const departments = ["waiter"];
|
||||
const timeSlots = [
|
||||
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 },
|
||||
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 },
|
||||
@@ -635,9 +121,9 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
];
|
||||
|
||||
const staffPool = [
|
||||
{ id: 2, name: "Nguyễn Văn An" },
|
||||
{ id: 3, name: "Trần Thị Bình" },
|
||||
{ id: 4, name: "Lê Văn Cường" },
|
||||
{ id: 2, name: "Alex Nguyen" },
|
||||
{ id: 3, name: "Binh Tran" },
|
||||
{ id: 4, name: "Cuong Le" },
|
||||
];
|
||||
|
||||
// Generate shifts from April 6 to April 26, 2026
|
||||
@@ -660,7 +146,7 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
|
||||
// Determine registration status
|
||||
const registered: { id: number; name: string }[] = [];
|
||||
let status: ShiftSlot["status"] = "available";
|
||||
// let status: ShiftEntity["status"] = "available";
|
||||
|
||||
// Past shifts (before April 10) are mostly registered
|
||||
if (day < 10) {
|
||||
@@ -669,37 +155,37 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
if (shiftCounter % 7 === 0) {
|
||||
registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
|
||||
}
|
||||
status = "registered";
|
||||
// status = "registered";
|
||||
// Some past shifts have leave/absent
|
||||
if (shiftCounter % 11 === 0) status = "approved_leave";
|
||||
if (shiftCounter % 13 === 0) status = "absent";
|
||||
// if (shiftCounter % 11 === 0) status = "approved_leave";
|
||||
// if (shiftCounter % 13 === 0) status = "absent";
|
||||
}
|
||||
// Current week (April 6-12): mix of registered and available
|
||||
else if (day >= 10 && day <= 12) {
|
||||
if (shiftCounter % 3 === 0) {
|
||||
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||
status = "registered";
|
||||
// status = "registered";
|
||||
}
|
||||
}
|
||||
// Future shifts: mostly available, some registered
|
||||
else {
|
||||
if (shiftCounter % 5 === 0) {
|
||||
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||
status = "registered";
|
||||
// status = "registered";
|
||||
}
|
||||
}
|
||||
|
||||
shifts.push({
|
||||
id: shiftId,
|
||||
date: dateStr,
|
||||
id: "shiftId",
|
||||
date: new Date(dateStr),
|
||||
startTime: slot.start,
|
||||
endTime: slot.end,
|
||||
durationHours: slot.hours,
|
||||
// durationHours: slot.hours,
|
||||
wage: slot.wage,
|
||||
department: dept,
|
||||
// department: dept,
|
||||
maxStaff: dept === "bar" ? 3 : 2,
|
||||
registeredStaff: registered,
|
||||
status,
|
||||
// registeredStaff: registered,
|
||||
// status,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -708,4 +194,8 @@ function generateMockShifts(): ShiftSlot[] {
|
||||
return shifts;
|
||||
}
|
||||
|
||||
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
|
||||
export const MOCK_SHIFT_SLOTS: ShiftEntity[] = generateMockShifts();
|
||||
|
||||
export const DEPARTMENTS: Department[] = [
|
||||
{ id: "waiter", name: "Bar Staff", icon: "fa-brands fa-jenkins" },
|
||||
];
|
||||
|
||||
+154
-101
@@ -1,149 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext, useContext, useState } from "react";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MENU_CATEGORIES, MOCK_COMBOS, MOCK_PRODUCTS } from "./constants";
|
||||
import type { Combo, ComboItem, MenuCategory, Product } from "./types";
|
||||
import { eateryClient } from "./apollo-clients";
|
||||
import {
|
||||
type MenuItemEntity,
|
||||
type addMenuItemMutation,
|
||||
type allEateriesQuery,
|
||||
deleteMenuItemMutation,
|
||||
updateMenuItemMutation,
|
||||
} from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ManagerTab = "products" | "combos" | "categories";
|
||||
export type ManagerTab = "products";
|
||||
|
||||
interface ManagerContextType {
|
||||
// Data
|
||||
products: Product[];
|
||||
combos: Combo[];
|
||||
categories: MenuCategory[];
|
||||
products: MenuItemEntity[];
|
||||
|
||||
// Active tab
|
||||
activeTab: ManagerTab;
|
||||
setActiveTab: (tab: ManagerTab) => void;
|
||||
|
||||
// Product actions
|
||||
addProduct: (product: Omit<Product, "id">) => void;
|
||||
updateProduct: (product: Product) => void;
|
||||
deleteProduct: (id: number) => void;
|
||||
toggleProductAvailability: (id: number) => void;
|
||||
|
||||
// Combo actions
|
||||
addCombo: (combo: Omit<Combo, "id">) => void;
|
||||
updateCombo: (combo: Combo) => void;
|
||||
deleteCombo: (id: number) => void;
|
||||
toggleComboAvailability: (id: number) => void;
|
||||
|
||||
// Category actions
|
||||
addCategory: (category: Omit<MenuCategory, "id">) => void;
|
||||
updateCategory: (category: MenuCategory) => void;
|
||||
deleteCategory: (id: string) => void;
|
||||
// MenuItemEntity actions
|
||||
addProduct: (product: MenuItemEntity) => void;
|
||||
updateProduct: (product: MenuItemEntity) => void;
|
||||
deleteProduct: (id: string) => void;
|
||||
toggleProductAvailability: (product: MenuItemEntity) => void;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ManagerContext = createContext<ManagerContextType | undefined>(undefined);
|
||||
|
||||
// ___ Graphql __________________________________________________________________
|
||||
|
||||
const GET_EATERY_MENU = gql`
|
||||
query GetEateryMenu {
|
||||
allEateries {
|
||||
id
|
||||
menuItems {
|
||||
id
|
||||
name
|
||||
imageUrl
|
||||
available
|
||||
description
|
||||
price
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ADD_MENU_ITEM = gql`
|
||||
mutation addMenuItem($menuItem: AddMenuItemInput!) {
|
||||
addMenuItem(menuItem: $menuItem) {
|
||||
id
|
||||
name
|
||||
imageUrl
|
||||
available
|
||||
description
|
||||
price
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const UPDATE_MENU_ITEM = gql`
|
||||
mutation updateMenuItem($menuItem: UpdateMenuItemInput!) {
|
||||
updateMenuItem(menuItem: $menuItem) {
|
||||
id
|
||||
name
|
||||
imageUrl
|
||||
available
|
||||
description
|
||||
price
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DELETE_MENU_ITEM = gql`
|
||||
mutation deleteMenuItem($input: String!) {
|
||||
deleteMenuItem(menuItemId: $input)
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ManagerProvider({ children }: { children: ReactNode }) {
|
||||
const [products, setProducts] = useState<Product[]>(MOCK_PRODUCTS);
|
||||
const [combos, setCombos] = useState<Combo[]>(MOCK_COMBOS);
|
||||
// Filter out the "all" pseudo-category — managers manage real categories only
|
||||
const [categories, setCategories] = useState<MenuCategory[]>(
|
||||
MENU_CATEGORIES.filter((c) => c.id !== "all"),
|
||||
);
|
||||
const [products, setProducts] = useState<MenuItemEntity[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<ManagerTab>("products");
|
||||
|
||||
// ── Product actions ──────────────────────────────────────────────────────
|
||||
const { data } = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
|
||||
client: eateryClient,
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const addProduct = (product: Omit<Product, "id">) => {
|
||||
const newProduct: Product = {
|
||||
...product,
|
||||
id: Date.now(),
|
||||
};
|
||||
setProducts((prev) => [...prev, newProduct]);
|
||||
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
const [mutateUpdateMenuItem] = useMutation<updateMenuItemMutation>(
|
||||
UPDATE_MENU_ITEM,
|
||||
{ client: eateryClient },
|
||||
);
|
||||
|
||||
const [mutateDeleteMenuItem] = useMutation<deleteMenuItemMutation>(
|
||||
DELETE_MENU_ITEM,
|
||||
{ client: eateryClient },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.allEateries?.[0]) {
|
||||
setProducts(data.allEateries[0].menuItems);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// ── MenuItemEntity actions ──────────────────────────────────────────────────────
|
||||
|
||||
const addProduct = async (product: MenuItemEntity) => {
|
||||
const { data } = await mutateAddMenuItem({
|
||||
variables: {
|
||||
menuItem: product,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
|
||||
// addMenuItem backend does not persist imageUrl (constructor only maps name+price).
|
||||
// If the caller provided an imageUrl, patch it immediately via updateMenuItem
|
||||
// which uses MapStruct and correctly saves all fields.
|
||||
if (product.imageUrl && data.addMenuItem?.id) {
|
||||
const { data: updated } = await mutateUpdateMenuItem({
|
||||
variables: {
|
||||
menuItem: { id: data.addMenuItem.id, imageUrl: product.imageUrl },
|
||||
},
|
||||
});
|
||||
if (updated) {
|
||||
setProducts((prev) => [...prev, updated.updateMenuItem]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setProducts((prev) => [...prev, data.addMenuItem]);
|
||||
};
|
||||
|
||||
const updateProduct = (product: Product) => {
|
||||
setProducts((prev) => prev.map((p) => (p.id === product.id ? product : p)));
|
||||
const updateProduct = async (product: MenuItemEntity) => {
|
||||
const { data } = await mutateUpdateMenuItem({
|
||||
variables: {
|
||||
menuItem: product,
|
||||
},
|
||||
});
|
||||
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteProduct = (id: number) => {
|
||||
setProducts((prev) => prev.filter((p) => p.id !== id));
|
||||
const deleteProduct = async (id: string) => {
|
||||
const { data } = await mutateDeleteMenuItem({ variables: { input: id } });
|
||||
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.filter((p) => !(p.id == id && data.deleteMenuItem)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleProductAvailability = (id: number) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id ? { ...p, available: !(p.available ?? true) } : p,
|
||||
),
|
||||
);
|
||||
};
|
||||
const toggleProductAvailability = async (product: MenuItemEntity) => {
|
||||
const { data } = await mutateUpdateMenuItem({
|
||||
variables: {
|
||||
menuItem: { ...product, available: !product.available },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Combo actions ────────────────────────────────────────────────────────
|
||||
|
||||
const addCombo = (combo: Omit<Combo, "id">) => {
|
||||
const newCombo: Combo = { ...combo, id: Date.now() };
|
||||
setCombos((prev) => [...prev, newCombo]);
|
||||
};
|
||||
|
||||
const updateCombo = (combo: Combo) => {
|
||||
setCombos((prev) => prev.map((c) => (c.id === combo.id ? combo : c)));
|
||||
};
|
||||
|
||||
const deleteCombo = (id: number) => {
|
||||
setCombos((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const toggleComboAvailability = (id: number) => {
|
||||
setCombos((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, available: !c.available } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
// ── Category actions ─────────────────────────────────────────────────────
|
||||
|
||||
const addCategory = (category: Omit<MenuCategory, "id">) => {
|
||||
const slug = category.name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const newCategory: MenuCategory = {
|
||||
...category,
|
||||
id: `${slug}-${Date.now()}`,
|
||||
};
|
||||
setCategories((prev) => [...prev, newCategory]);
|
||||
};
|
||||
|
||||
const updateCategory = (category: MenuCategory) => {
|
||||
setCategories((prev) =>
|
||||
prev.map((c) => (c.id === category.id ? category : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteCategory = (id: string) => {
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
if (data)
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerContext.Provider
|
||||
value={{
|
||||
products,
|
||||
combos,
|
||||
categories,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
addProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
toggleProductAvailability,
|
||||
addCombo,
|
||||
updateCombo,
|
||||
deleteCombo,
|
||||
toggleComboAvailability,
|
||||
addCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface MenuContextType {
|
||||
/** Currently selected category id */
|
||||
activeCategory: string;
|
||||
/** Update the active category */
|
||||
setActiveCategory: (id: string) => void;
|
||||
}
|
||||
|
||||
const MenuContext = createContext<MenuContextType>({
|
||||
activeCategory: "all",
|
||||
setActiveCategory: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides shared activeCategory state to both the Header (mobile scrollable menu)
|
||||
* and the Navbar sidebar (md+), so both always reflect the same selection.
|
||||
*/
|
||||
export function MenuProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeCategory, setActiveCategory] = useState("all");
|
||||
|
||||
return (
|
||||
<MenuContext.Provider value={{ activeCategory, setActiveCategory }}>
|
||||
{children}
|
||||
</MenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Consume the shared menu state anywhere inside MenuProvider */
|
||||
export function useMenu() {
|
||||
return useContext(MenuContext);
|
||||
}
|
||||
+108
-51
@@ -1,15 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { gql } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MOCK_SHIFT_SLOTS } from "./constants";
|
||||
import type { ShiftSlot, ShiftStatus } from "./types";
|
||||
import { eateryClient } from "./apollo-clients";
|
||||
import {
|
||||
type ShiftEntity,
|
||||
type allShiftsQuery,
|
||||
createShiftMutation,
|
||||
} from "./types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,7 +24,7 @@ export type ScheduleView = "week" | "month";
|
||||
|
||||
interface ShiftContextType {
|
||||
// Data
|
||||
shifts: ShiftSlot[];
|
||||
shifts: ShiftEntity[];
|
||||
view: ScheduleView;
|
||||
setView: (view: ScheduleView) => void;
|
||||
|
||||
@@ -32,23 +39,23 @@ interface ShiftContextType {
|
||||
// Shift actions
|
||||
registerShift: (
|
||||
shiftId: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
staffName: string,
|
||||
) => { success: boolean; error?: string };
|
||||
unregisterShift: (shiftId: string, staffId: number) => void;
|
||||
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
||||
updateShift: (shift: ShiftSlot) => void;
|
||||
unregisterShift: (shiftId: string, staffId: string) => void;
|
||||
createShift: (shift: Omit<ShiftEntity, "id">) => void;
|
||||
updateShift: (shift: ShiftEntity) => void;
|
||||
deleteShift: (shiftId: string) => void;
|
||||
|
||||
// Helpers
|
||||
getShiftsForDate: (date: string) => ShiftSlot[];
|
||||
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
||||
getShiftsForDate: (date: Date) => ShiftEntity[];
|
||||
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
|
||||
getWeekDates: () => Date[];
|
||||
hasConflict: (
|
||||
date: string,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
excludeShiftId?: string,
|
||||
) => boolean;
|
||||
getWeeklyBudget: () => number;
|
||||
@@ -94,13 +101,50 @@ function timesOverlap(
|
||||
return s1 < e2 && s2 < e1;
|
||||
}
|
||||
|
||||
// ___ GraphQL __________________________________________________________________
|
||||
|
||||
const GET_ALL_SHIFTS = gql`
|
||||
query allShifts {
|
||||
allShifts {
|
||||
id
|
||||
startTime
|
||||
endTime
|
||||
maxStaff
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CREATE_SHIFT = gql`
|
||||
mutation createShift($shiftInput: CreateShiftInput!) {
|
||||
createShift(shiftInput: $shiftInput) {
|
||||
id
|
||||
startTime
|
||||
endTime
|
||||
maxStaff
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
const [shifts, setShifts] = useState<ShiftSlot[]>(MOCK_SHIFT_SLOTS);
|
||||
const [shifts, setShifts] = useState<ShiftEntity[]>([]);
|
||||
const [view, setView] = useState<ScheduleView>("week");
|
||||
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
|
||||
|
||||
const { data } = useQuery<allShiftsQuery>(GET_ALL_SHIFTS, {
|
||||
client: eateryClient,
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const [mutateCreateShift] = useMutation<createShiftMutation>(CREATE_SHIFT, {
|
||||
client: eateryClient,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setShifts(data.allShifts);
|
||||
}, [data]);
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────────
|
||||
|
||||
const goToNextWeek = useCallback(() => {
|
||||
@@ -151,18 +195,20 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
}, [currentDate]);
|
||||
|
||||
const getShiftsForDate = useCallback(
|
||||
(date: string): ShiftSlot[] => {
|
||||
return shifts.filter((s) => s.date === date);
|
||||
(date: Date): ShiftEntity[] => {
|
||||
return shifts.filter((s) => {
|
||||
return new Date(s.date).getDate() === date.getDate();
|
||||
});
|
||||
},
|
||||
[shifts],
|
||||
);
|
||||
|
||||
const getShiftsForWeek = useCallback(
|
||||
(weekStart: Date): ShiftSlot[] => {
|
||||
(weekStart: Date): ShiftEntity[] => {
|
||||
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(weekStart);
|
||||
d.setDate(weekStart.getDate() + i);
|
||||
return formatDate(d);
|
||||
return d;
|
||||
});
|
||||
return shifts.filter((s) => dates.includes(s.date));
|
||||
},
|
||||
@@ -171,17 +217,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const hasConflict = useCallback(
|
||||
(
|
||||
date: string,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
excludeShiftId?: string,
|
||||
): boolean => {
|
||||
return shifts.some(
|
||||
(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),
|
||||
);
|
||||
},
|
||||
@@ -189,10 +235,12 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
const getWeeklyBudget = useCallback((): number => {
|
||||
const weekDates = getWeekDates().map(formatDate);
|
||||
const weekDates = getWeekDates();
|
||||
return shifts
|
||||
.filter((s) => weekDates.includes(s.date) && s.registeredStaff.length > 0)
|
||||
.reduce((sum, s) => sum + s.wage * s.registeredStaff.length, 0);
|
||||
.filter(
|
||||
(s) => weekDates.includes(s.date) && s.registeredStaff!.length > 0,
|
||||
)
|
||||
.reduce((sum, s) => sum + s.wage * s.registeredStaff!.length, 0);
|
||||
}, [shifts, getWeekDates]);
|
||||
|
||||
// ── Shift actions ───────────────────────────────────────────────────────
|
||||
@@ -200,17 +248,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
const registerShift = useCallback(
|
||||
(
|
||||
shiftId: string,
|
||||
staffId: number,
|
||||
staffId: string,
|
||||
staffName: string,
|
||||
): { success: boolean; error?: string } => {
|
||||
const shift = shifts.find((s) => s.id === shiftId);
|
||||
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
|
||||
|
||||
if (shift.registeredStaff.length >= shift.maxStaff) {
|
||||
if (shift.registeredStaff!.length >= shift.maxStaff) {
|
||||
return { success: false, error: "Ca làm đã đủ người." };
|
||||
}
|
||||
|
||||
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
|
||||
if (shift.registeredStaff!.some((rs) => rs.id === staffId)) {
|
||||
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
|
||||
}
|
||||
|
||||
@@ -229,50 +277,59 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}
|
||||
|
||||
setShifts((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === shiftId
|
||||
? {
|
||||
...s,
|
||||
registeredStaff: [
|
||||
...s.registeredStaff,
|
||||
{ id: staffId, name: staffName },
|
||||
],
|
||||
status: "registered" as ShiftStatus,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
// setShifts((prev) =>
|
||||
// prev.map((s) =>
|
||||
// s.id === shiftId
|
||||
// ? {
|
||||
// ...s,
|
||||
// registeredStaff: [
|
||||
// ...s.registeredStaff,
|
||||
// { staffId, name: staffName },
|
||||
// ],
|
||||
// status: "registered" as ShiftStatus,
|
||||
// }
|
||||
// : s,
|
||||
// ),
|
||||
// );
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
[shifts, hasConflict],
|
||||
);
|
||||
|
||||
const unregisterShift = useCallback((shiftId: string, staffId: number) => {
|
||||
const unregisterShift = useCallback((shiftId: string, staffId: string) => {
|
||||
setShifts((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== shiftId) return s;
|
||||
const updated = s.registeredStaff.filter((rs) => rs.id !== staffId);
|
||||
const updated = s.registeredStaff!.filter((rs) => rs.id !== staffId);
|
||||
return {
|
||||
...s,
|
||||
registeredStaff: updated,
|
||||
status:
|
||||
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => {
|
||||
const newShift: ShiftSlot = {
|
||||
...shift,
|
||||
id: `shift_${Date.now()}`,
|
||||
};
|
||||
setShifts((prev) => [...prev, newShift]);
|
||||
}, []);
|
||||
const createShift = useCallback(async (shift: Omit<ShiftEntity, "id">) => {
|
||||
const { data } = await mutateCreateShift({
|
||||
variables: {
|
||||
shiftInput: {
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
maxStaff: shift.maxStaff,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updateShift = useCallback((shift: ShiftSlot) => {
|
||||
if (data) setShifts((prev) => [...prev, {
|
||||
...data.createShift,
|
||||
date: shift.date,
|
||||
wage: shift.wage,
|
||||
registeredStaff: [],
|
||||
}]);
|
||||
}, [mutateCreateShift]);
|
||||
|
||||
const updateShift = useCallback((shift: ShiftEntity) => {
|
||||
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
||||
}, []);
|
||||
|
||||
|
||||
+84
-48
@@ -2,31 +2,13 @@
|
||||
export type UserRole = "manager" | "staff" | "customer";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar: string | null;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
// ===== MENU TYPES =====
|
||||
export interface MenuCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // FontAwesome class e.g. "fa-solid fa-mug-hot"
|
||||
}
|
||||
|
||||
// ===== PRODUCT TYPES =====
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string; // matches MenuCategory.id
|
||||
price: number;
|
||||
image: string;
|
||||
description: string;
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
// ===== SHOP INFO TYPES =====
|
||||
export interface WifiInfo {
|
||||
name: string;
|
||||
@@ -99,22 +81,6 @@ export interface FinancialSummary {
|
||||
profitComparison: PeriodComparison;
|
||||
}
|
||||
|
||||
// ===== COMBO TYPES =====
|
||||
export interface ComboItem {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Combo {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
image: string;
|
||||
items: ComboItem[]; // list of products + quantities in this combo
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// ===== SHIFT / SCHEDULE TYPES =====
|
||||
export type ShiftStatus =
|
||||
| "available"
|
||||
@@ -122,26 +88,96 @@ export type ShiftStatus =
|
||||
| "approved_leave"
|
||||
| "absent";
|
||||
|
||||
export interface RegisteredStaff {
|
||||
id: number;
|
||||
name: string;
|
||||
export interface ShiftRegistrationEntity {
|
||||
id: string;
|
||||
staffId: string;
|
||||
shift: ShiftEntity;
|
||||
}
|
||||
|
||||
export interface ShiftSlot {
|
||||
export interface ShiftEntity {
|
||||
id: string;
|
||||
date: string; // ISO date string "YYYY-MM-DD"
|
||||
startTime: string; // "HH:mm"
|
||||
endTime: string; // "HH:mm"
|
||||
durationHours: number;
|
||||
wage: number; // VND per shift
|
||||
department: string;
|
||||
name?: string;
|
||||
date?: Date;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
wage?: number;
|
||||
maxStaff: number;
|
||||
registeredStaff: RegisteredStaff[];
|
||||
status: ShiftStatus;
|
||||
registeredStaff?: ShiftRegistrationEntity[];
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // FontAwesome class
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface MenuItemEntity {
|
||||
id?: string;
|
||||
name: string;
|
||||
price: number;
|
||||
eatery?: EateryEntity;
|
||||
imageUrl: string;
|
||||
available: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface EateryEntity {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
name: string;
|
||||
menuItems: MenuItemEntity[];
|
||||
shifts: ShiftEntity[];
|
||||
}
|
||||
|
||||
export interface allEateriesQuery {
|
||||
allEateries: EateryEntity[];
|
||||
}
|
||||
|
||||
export interface addMenuItemMutation {
|
||||
addMenuItem: MenuItemEntity;
|
||||
}
|
||||
|
||||
export interface updateMenuItemMutation {
|
||||
updateMenuItem: MenuItemEntity;
|
||||
}
|
||||
|
||||
export interface deleteMenuItemMutation {
|
||||
deleteMenuItem: boolean;
|
||||
}
|
||||
|
||||
export interface CartItemEntity {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
priceAtTimeOfAdding?: number;
|
||||
}
|
||||
|
||||
export interface CartEntity {
|
||||
Id: string;
|
||||
items: CartItemEntity[];
|
||||
totalAmount: number;
|
||||
paymentQrUrl: string;
|
||||
}
|
||||
|
||||
export interface getCartQuery {
|
||||
getCart: CartEntity;
|
||||
}
|
||||
|
||||
export interface createCartMutation {
|
||||
createCart: string;
|
||||
}
|
||||
|
||||
export interface addMenuItemMutation {
|
||||
addItem: CartEntity;
|
||||
}
|
||||
|
||||
export interface allRegistrationsQuery {
|
||||
allRegistrations: ShiftRegistrationEntity[];
|
||||
}
|
||||
|
||||
export interface allShiftsQuery {
|
||||
allShifts: ShiftEntity[];
|
||||
}
|
||||
|
||||
export interface createShiftMutation {
|
||||
createShift: ShiftEntity;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const gatewayUrl = "http://localhost:32080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${gatewayUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+238
-106
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temp",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.9",
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20.19.37",
|
||||
"@apollo/client": "^4.1.9",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@types/node": "^20.19.39",
|
||||
"@types/react": "^19.2.14",
|
||||
"graphql": "^16.14.0",
|
||||
"next": "16.1.7",
|
||||
"nextjs": "^0.0.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,10 +28,10 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.4",
|
||||
"semantic-release": "^25.0.3"
|
||||
}
|
||||
},
|
||||
@@ -95,6 +96,43 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@apollo/client": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.1.9.tgz",
|
||||
"integrity": "sha512-qfpkQD51tdU/7iAR6aLb4w9o/L7I475DluWHRb61U/3Q0AH29nNOxOBHjBbWDdf16ncPOoQuxne1sEs2NjqBFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@wry/caches": "^1.0.0",
|
||||
"@wry/equality": "^0.5.6",
|
||||
"@wry/trie": "^0.5.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"optimism": "^0.18.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphql": "^16.0.0",
|
||||
"graphql-ws": "^5.5.5 || ^6.0.3",
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
|
||||
"rxjs": "^7.3.0",
|
||||
"subscriptions-transport-ws": "^0.9.0 || ^0.11.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"graphql-ws": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"subscriptions-transport-ws": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -598,6 +636,15 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@graphql-typed-document-node/core": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
|
||||
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -2119,47 +2166,47 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"enhanced-resolve": "^5.21.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
|
||||
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
|
||||
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.0",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.0",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
|
||||
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2173,9 +2220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
|
||||
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2189,9 +2236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
|
||||
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2205,9 +2252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
|
||||
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2221,9 +2268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
|
||||
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
|
||||
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2237,9 +2284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
|
||||
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2253,9 +2300,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
|
||||
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2269,9 +2316,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
|
||||
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2285,9 +2332,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
|
||||
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2301,9 +2348,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
|
||||
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
|
||||
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -2318,10 +2365,10 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.8.1",
|
||||
"@emnapi/runtime": "^1.8.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@emnapi/core": "^1.10.0",
|
||||
"@emnapi/runtime": "^1.10.0",
|
||||
"@emnapi/wasi-threads": "^1.2.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
@@ -2330,9 +2377,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2346,9 +2393,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
|
||||
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2362,16 +2409,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz",
|
||||
"integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz",
|
||||
"integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.2.2",
|
||||
"@tailwindcss/oxide": "4.2.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "4.2.2"
|
||||
"@tailwindcss/node": "4.3.0",
|
||||
"@tailwindcss/oxide": "4.3.0",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trivago/prettier-plugin-sort-imports": {
|
||||
@@ -2495,9 +2542,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -3090,6 +3137,54 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@wry/caches": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz",
|
||||
"integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@wry/context": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz",
|
||||
"integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@wry/equality": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz",
|
||||
"integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@wry/trie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz",
|
||||
"integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -4864,13 +4959,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
||||
"version": "5.21.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||
"integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -6715,6 +6810,30 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphql": {
|
||||
"version": "16.14.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz",
|
||||
"integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphql-tag": {
|
||||
"version": "2.12.6",
|
||||
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz",
|
||||
"integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
@@ -8825,15 +8944,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/nextjs": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nextjs/-/nextjs-0.0.3.tgz",
|
||||
"integrity": "sha512-mYbDUo4/sRAZ8TqK63PCpYnFiLg7BICG/ot9+guOrUKd4/Fo71ZmEQ41IZbH6nqbQvG7SXTBuofJXAIWfNho0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.21"
|
||||
}
|
||||
},
|
||||
"node_modules/nocache": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz",
|
||||
@@ -11083,6 +11193,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/optimism": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
|
||||
"integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@wry/caches": "^1.0.0",
|
||||
"@wry/context": "^0.7.0",
|
||||
"@wry/trie": "^0.5.0",
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -11573,9 +11695,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -11611,9 +11733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -11671,9 +11793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
|
||||
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.4.tgz",
|
||||
"integrity": "sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -12228,6 +12350,16 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -13383,15 +13515,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -11,14 +11,16 @@
|
||||
"release": "semantic-release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20.19.39",
|
||||
"@apollo/client": "^4.1.9",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/node": "^20.19.41",
|
||||
"@types/react": "^19.2.14",
|
||||
"graphql": "^16.14.0",
|
||||
"next": "16.1.7",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -29,10 +31,10 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"prettier": "^3.8.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-embed": "^0.5.1",
|
||||
"prettier-plugin-groovy": "^0.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.4",
|
||||
"semantic-release": "^25.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+546
-482
File diff suppressed because it is too large
Load Diff
@@ -1,156 +0,0 @@
|
||||
# Feature Prompt: Register Shift (Đăng Ký Ca Làm)
|
||||
|
||||
## Tổng Quan
|
||||
|
||||
Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng ký ca làm
|
||||
việc theo khung thời gian do quản lý tạo ra. Tính năng hỗ trợ cả hai vai trò:
|
||||
**Manager** và **Staff**, với giao diện responsive thích ứng theo kích thước màn
|
||||
hình.
|
||||
|
||||
---
|
||||
|
||||
## Mục Đích
|
||||
|
||||
- Cho phép nhân viên xem danh sách các khung thời gian ca làm khả dụng và đăng
|
||||
ký vào ca mà họ muốn làm.
|
||||
- Quản lý (Manager) có toàn quyền tạo, chỉnh sửa và xóa các khung thời gian ca
|
||||
làm.
|
||||
- Nhân viên có thể thấy ca làm của đồng nghiệp để tránh xung đột hoặc điều chỉnh
|
||||
lịch phù hợp.
|
||||
- Cả Manager và Staff đều có thể chỉnh sửa hoặc hủy ca đã đăng ký của nhân viên.
|
||||
|
||||
---
|
||||
|
||||
## Vai Trò & Phân Quyền
|
||||
|
||||
### Manager
|
||||
|
||||
- Tạo, chỉnh sửa, xóa khung thời gian ca làm (shift slots).
|
||||
- Xem lịch làm việc của toàn bộ nhân viên theo tuần hoặc tháng.
|
||||
- Phê duyệt hoặc từ chối yêu cầu đăng ký ca.
|
||||
- Xóa hoặc thay đổi ca làm của bất kỳ nhân viên nào.
|
||||
- Gán ca làm cho nhân viên cụ thể nếu cần.
|
||||
|
||||
### Staff (Nhân Viên)
|
||||
|
||||
- Xem danh sách ca làm khả dụng trong tuần hoặc tháng.
|
||||
- Đăng ký vào ca mà mình muốn làm.
|
||||
- Xem ca đã được đăng ký bởi các nhân viên khác.
|
||||
- Hủy hoặc chỉnh sửa ca đã đăng ký của bản thân (trong giới hạn thời gian cho
|
||||
phép).
|
||||
|
||||
---
|
||||
|
||||
## Yêu Cầu Giao Diện
|
||||
|
||||
### Desktop (≥ 1024px) — Dạng Bảng Lịch Tuần/Tháng
|
||||
|
||||
- Hiển thị lịch làm việc dạng **table theo tuần** (mặc định), với khả năng
|
||||
chuyển sang **xem theo tháng**.
|
||||
- Mỗi cột đại diện cho một ngày trong tuần (Mon–Sun).
|
||||
- Mỗi hàng đại diện cho một nhân viên hoặc một khung giờ.
|
||||
- Các ca làm được hiển thị dưới dạng **card màu** trong ô tương ứng, gồm:
|
||||
- Tên ca / Khung giờ (ví dụ: `08:00 – 12:00`)
|
||||
- Số giờ làm & mức lương dự kiến (ví dụ: `4h · 120k`)
|
||||
- Trạng thái: `Available`, `Registered`, `Approved Leave`, `Absent`
|
||||
- **Bộ lọc tuần/tháng** ở góc trên, cho phép điều hướng qua lại giữa các
|
||||
tuần/tháng.
|
||||
- Hiển thị **tổng ngân sách tuần** (Weekly Budget) ở đầu bảng.
|
||||
- Nhóm nhân viên theo **department/role** (ví dụ: Bar Staff, Janitors,...).
|
||||
- Mỗi ca làm có thể click để xem chi tiết, chỉnh sửa hoặc xóa.
|
||||
|
||||
#### Chế Độ Xem Theo Tháng (Month View)
|
||||
|
||||
- Hiển thị dạng **calendar grid** (lưới 7 cột × ~5 hàng).
|
||||
- Mỗi ô ngày hiển thị số ca đã đăng ký hoặc dấu chấm màu trạng thái (tương tự
|
||||
ảnh tham khảo).
|
||||
- Cho phép nhân viên và manager tính toán ngày có thể nghỉ hoặc đăng ký ca xa
|
||||
hơn trong tháng.
|
||||
|
||||
---
|
||||
|
||||
### Mobile (< 768px) — Dạng Lịch Dọc + Dot Indicator
|
||||
|
||||
- Hiển thị **calendar theo tháng dạng nhỏ gọn** (compact calendar) ở đầu màn
|
||||
hình.
|
||||
- Các ngày có ca làm được đánh dấu bằng **dot màu** phía dưới số ngày:
|
||||
- 🟡 Vàng: Ca đang mở / khả dụng
|
||||
- 🟢 Xanh lá: Ca đã được đăng ký
|
||||
- ⚫ Xám: Không có ca
|
||||
- Ngày hiện tại được highlight bằng vòng tròn (dark circle).
|
||||
- Khi chọn một ngày, hiển thị danh sách ca làm của ngày đó bên dưới.
|
||||
- Mỗi ca làm hiển thị dưới dạng **card dọc**:
|
||||
- Khung giờ, số giờ, mức lương
|
||||
- Tên nhân viên đã đăng ký (nếu có)
|
||||
- Nút **Đăng ký** hoặc **Hủy đăng ký**
|
||||
- Điều hướng tháng bằng nút `<` và `>` ở hai bên tiêu đề tháng.
|
||||
|
||||
---
|
||||
|
||||
## Trạng Thái Ca Làm (Shift Status)
|
||||
|
||||
| Trạng Thái | Màu | Mô Tả |
|
||||
| -------------- | --------------- | --------------------------------- |
|
||||
| Available | Xanh dương nhạt | Ca đang mở, chưa có ai đăng ký |
|
||||
| Registered | Xanh dương đậm | Nhân viên đã đăng ký ca này |
|
||||
| Approved Leave | Tím/Lavender | Nhân viên đã được duyệt nghỉ phép |
|
||||
| Absent | Đỏ/Hồng | Nhân viên vắng mặt không có lý do |
|
||||
|
||||
---
|
||||
|
||||
## Luồng Chức Năng Chính
|
||||
|
||||
### Staff — Đăng Ký Ca
|
||||
|
||||
1. Truy cập màn hình Register Shift.
|
||||
2. Chọn tuần hoặc tháng muốn xem.
|
||||
3. Xem các ca làm khả dụng (Available).
|
||||
4. Click vào ca → Xem thông tin chi tiết (giờ, lương, số người đã đăng ký).
|
||||
5. Nhấn **Register** để đăng ký ca.
|
||||
6. Ca chuyển sang trạng thái **Registered**.
|
||||
|
||||
### Manager — Tạo Ca Làm Mới
|
||||
|
||||
1. Vào giao diện dashboard của manager.
|
||||
2. Vào giao diện lịch của nhóm/department.
|
||||
3. Click vào ô ngày muốn tạo ca.
|
||||
4. Nhập thông tin: Khung giờ bắt đầu – kết thúc, số lượng nhân viên cần, mức
|
||||
lương.
|
||||
5. Lưu → Ca hiển thị trên lịch với trạng thái **Available**.
|
||||
|
||||
### Xóa / Chỉnh Sửa Ca
|
||||
|
||||
- **Manager**: Có thể chỉnh sửa hoặc xóa bất kỳ ca nào của bất kỳ nhân viên nào.
|
||||
- **Staff**: Chỉ có thể hủy ca của bản thân, trong phạm vi thời gian cho phép
|
||||
(ví dụ: trước 24h so với giờ bắt đầu ca).
|
||||
|
||||
---
|
||||
|
||||
## Dữ Liệu & Logic
|
||||
|
||||
### Shift Slot Object
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "shift_001",
|
||||
"date": "2025-04-22",
|
||||
"startTime": "08:00",
|
||||
"endTime": "12:00",
|
||||
"durationHours": 3.5,
|
||||
"wage": 80,
|
||||
"department": "Bar Staff",
|
||||
"maxStaff": 2,
|
||||
"registeredStaff": [{ "id": "staff_01", "name": "Carol Saragosa" }],
|
||||
"status": "registered"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ghi Chú Bổ Sung
|
||||
|
||||
- Nhân viên **không được đăng ký 2 ca trùng giờ** trong cùng một ngày.
|
||||
- Hệ thống cần hiển thị **cảnh báo xung đột** khi nhân viên cố đăng ký ca bị
|
||||
trùng lịch.
|
||||
- Cần hỗ trợ **thông báo (notification)** khi ca làm bị thay đổi hoặc bị hủy bởi
|
||||
manager.
|
||||
Reference in New Issue
Block a user