chore: release [ci skip]
This commit is contained in:
+42
-25
@@ -1,7 +1,7 @@
|
||||
# Components Documentation
|
||||
|
||||
> This project follows **Atomic Design** pattern.
|
||||
> See `Atomic.md` at project root for full structure guide.
|
||||
> This project follows **Atomic Design** pattern. See `Atomic.md` at project
|
||||
> root for full structure guide.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -19,17 +19,21 @@ components/
|
||||
|
||||
### ProductCard (`molecules/cards/ProductCard.tsx`)
|
||||
|
||||
**Description:** Product card molecule. Displays product image, name, description, formatted price, and a Buy button. Width is controlled by the parent grid (w-full).
|
||||
**Description:** Product card molecule. Displays product image, name,
|
||||
description, formatted price, and a Buy button. Width is controlled by the
|
||||
parent grid (w-full).
|
||||
|
||||
**Atomic level:** Molecule (composed of Button, Caption, Text atoms)
|
||||
|
||||
### ShopCard (`molecules/cards/ShopCard.tsx`)
|
||||
|
||||
**Description:** Shop discovery card. Displays shop image, name, address, and a link to the menu.
|
||||
**Description:** Shop discovery card. Displays shop image, name, address, and a
|
||||
link to the menu.
|
||||
|
||||
### SearchBar (`molecules/search-bar/SearchBar.tsx`)
|
||||
|
||||
**Description:** Search input with clear button. Controlled component — value and onChange come from parent.
|
||||
**Description:** Search input with clear button. Controlled component — value
|
||||
and onChange come from parent.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,35 +41,41 @@ components/
|
||||
|
||||
### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`)
|
||||
|
||||
**Description:** Left sidebar with collapsible category filter. Sticky below header, full viewport height minus header.
|
||||
**Description:** Left sidebar with collapsible category filter. Sticky below
|
||||
header, full viewport height minus header.
|
||||
|
||||
### CartFab (`organisms/cart/CartFab.tsx`)
|
||||
|
||||
**Description:** Floating Action Button displaying cart item count. Links to /payment page.
|
||||
**Description:** Floating Action Button displaying cart item count. Links to
|
||||
/payment page.
|
||||
|
||||
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
|
||||
|
||||
**Description:** Full product grid organism with mobile category tabs, filtering, and empty state. Uses useCart and useMenu contexts.
|
||||
**Description:** Full product grid organism with mobile category tabs,
|
||||
filtering, and empty state. Uses useCart and useMenu contexts.
|
||||
|
||||
### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`)
|
||||
|
||||
**Description:** Responsive grid of ShopCard molecules for the feed/discovery page.
|
||||
**Description:** Responsive grid of ShopCard molecules for the feed/discovery
|
||||
page.
|
||||
|
||||
### ReviewModal (`organisms/modals/ReviewModal.tsx`)
|
||||
|
||||
**Description:** Modal for customer reviews. Shows 5-star rating and textarea. After submission, displays thank-you message.
|
||||
**Description:** Modal for customer reviews. Shows 5-star rating and textarea.
|
||||
After submission, displays thank-you message.
|
||||
|
||||
### Analytics Charts (`organisms/analytics/`)
|
||||
|
||||
**Description:** Pure-SVG chart and table components for the Financial Analytics dashboard. All components are interactive with hover tooltips.
|
||||
**Description:** Pure-SVG chart and table components for the Financial Analytics
|
||||
dashboard. All components are interactive with hover tooltips.
|
||||
|
||||
| Component | File | Description |
|
||||
|-----------|------|-------------|
|
||||
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
|
||||
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
|
||||
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
|
||||
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
|
||||
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
|
||||
| Component | File | Description |
|
||||
| ------------ | ---------------- | ---------------------------------------------------------- |
|
||||
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
|
||||
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
|
||||
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
|
||||
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
|
||||
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
|
||||
|
||||
**Usage:** Imported via `@/components/organisms/analytics` barrel index.
|
||||
|
||||
@@ -75,11 +85,13 @@ components/
|
||||
|
||||
### MainLayout (`templates/main-layout/MainLayout.tsx`)
|
||||
|
||||
**Description:** Main layout template — wraps content with Header, Footer, and CartFab. Used by (main) route group.
|
||||
**Description:** Main layout template — wraps content with Header, Footer, and
|
||||
CartFab. Used by (main) route group.
|
||||
|
||||
### AuthLayout (`templates/auth-layout/AuthLayout.tsx`)
|
||||
|
||||
**Description:** Auth layout template — centers content in screen. Used by login/register pages.
|
||||
**Description:** Auth layout template — centers content in screen. Used by
|
||||
login/register pages.
|
||||
|
||||
### FeedLayout (`templates/feed-layout/FeedLayout.tsx`)
|
||||
|
||||
@@ -87,7 +99,8 @@ components/
|
||||
|
||||
### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`)
|
||||
|
||||
**Description:** Manager layout template — auth guard + ManagerProvider. Used by the manager route group.
|
||||
**Description:** Manager layout template — auth guard + ManagerProvider. Used by
|
||||
the manager route group.
|
||||
|
||||
---
|
||||
|
||||
@@ -95,16 +108,20 @@ components/
|
||||
|
||||
### AuthContext (`lib/auth-context.tsx`)
|
||||
|
||||
Manages user authentication state including login, logout, and registration. Uses localStorage for persistence.
|
||||
Manages user authentication state including login, logout, and registration.
|
||||
Uses localStorage for persistence.
|
||||
|
||||
### CartContext (`lib/cart-context.tsx`)
|
||||
|
||||
Manages shopping cart state with localStorage persistence. Tracks items, quantities, and totals.
|
||||
Manages shopping cart state with localStorage persistence. Tracks items,
|
||||
quantities, and totals.
|
||||
|
||||
### MenuContext (`lib/menu-context.tsx`)
|
||||
|
||||
Provides shared activeCategory state across components. Synchronizes Header mobile menu and CategorySidebar selection.
|
||||
Provides shared activeCategory state across components. Synchronizes Header
|
||||
mobile menu and CategorySidebar selection.
|
||||
|
||||
### ManagerContext (`lib/manager-context.tsx`)
|
||||
|
||||
Manages menu CRUD state (products, combos, categories) for the manager dashboard.
|
||||
Manages menu CRUD state (products, combos, categories) for the manager
|
||||
dashboard.
|
||||
|
||||
+79
-29
@@ -1,16 +1,18 @@
|
||||
# Atoms Components Library
|
||||
|
||||
**Status:** ✅ Phase 1 Implementation Complete
|
||||
**Created:** 2026-04-03
|
||||
**Status:** ✅ Phase 1 Implementation Complete **Created:** 2026-04-03
|
||||
**Foundation:** Atomic Design Pattern
|
||||
|
||||
---
|
||||
|
||||
## 📚 Overview
|
||||
|
||||
Atoms are the basic building blocks of your UI. They are small, focused, reusable components that have no dependencies on other components (except potentially other atoms).
|
||||
Atoms are the basic building blocks of your UI. They are small, focused,
|
||||
reusable components that have no dependencies on other components (except
|
||||
potentially other atoms).
|
||||
|
||||
**Key Principles:**
|
||||
|
||||
- ✅ No business logic
|
||||
- ✅ No context/hooks (useAuth, useCart, etc.)
|
||||
- ✅ Pure props-based
|
||||
@@ -25,9 +27,11 @@ Atoms are the basic building blocks of your UI. They are small, focused, reusabl
|
||||
### Buttons (`buttons/`)
|
||||
|
||||
#### **Button.tsx**
|
||||
|
||||
Main interactive button component with multiple variants and sizes.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant` - "primary" | "secondary" | "danger" | "ghost" (default: "primary")
|
||||
- `size` - "sm" | "md" | "lg" (default: "md")
|
||||
- `icon` - FontAwesome icon class (optional)
|
||||
@@ -38,6 +42,7 @@ Main interactive button component with multiple variants and sizes.
|
||||
- All standard HTML button attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/atoms";
|
||||
|
||||
@@ -55,6 +60,7 @@ import { Button } from "@/components/atoms";
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Primary: Branded color with dark hover
|
||||
- Secondary: Border style with light background on hover
|
||||
- Danger: Red for destructive actions
|
||||
@@ -63,15 +69,18 @@ import { Button } from "@/components/atoms";
|
||||
---
|
||||
|
||||
#### **IconButton.tsx**
|
||||
|
||||
Button designed specifically for icon-only interactions.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant` - "primary" | "secondary" | "danger" | "ghost"
|
||||
- `size` - "sm" (8x8) | "md" (10x10) | "lg" (12x12)
|
||||
- `icon` - FontAwesome icon class (required)
|
||||
- All standard HTML button attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { IconButton } from "@/components/atoms";
|
||||
|
||||
@@ -84,9 +93,11 @@ import { IconButton } from "@/components/atoms";
|
||||
### Inputs (`inputs/`)
|
||||
|
||||
#### **TextInput.tsx**
|
||||
|
||||
General text input field with optional label, error, and icon.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `label` - string (optional)
|
||||
- `error` - string (optional, shows red border and error text)
|
||||
- `icon` - FontAwesome icon class (optional)
|
||||
@@ -94,6 +105,7 @@ General text input field with optional label, error, and icon.
|
||||
- All standard HTML input attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { TextInput } from "@/components/atoms";
|
||||
|
||||
@@ -115,9 +127,11 @@ import { TextInput } from "@/components/atoms";
|
||||
---
|
||||
|
||||
#### **SearchInput.tsx**
|
||||
|
||||
Search input with built-in search icon and clear button.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `value` - string (controlled input)
|
||||
- `onChange` - callback when text changes
|
||||
- `onClear` - callback for clear button (required for button to show)
|
||||
@@ -125,6 +139,7 @@ Search input with built-in search icon and clear button.
|
||||
- All standard HTML input attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { SearchInput } from "@/components/atoms";
|
||||
|
||||
@@ -135,20 +150,23 @@ const [query, setQuery] = useState("");
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onClear={() => setQuery("")}
|
||||
placeholder="Search products..."
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **Textarea.tsx**
|
||||
|
||||
Multi-line text input with optional label and error.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `label` - string (optional)
|
||||
- `error` - string (optional)
|
||||
- All standard HTML textarea attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Textarea } from "@/components/atoms";
|
||||
|
||||
@@ -158,7 +176,7 @@ import { Textarea } from "@/components/atoms";
|
||||
rows={4}
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -166,14 +184,17 @@ import { Textarea } from "@/components/atoms";
|
||||
### Typography (`typography/`)
|
||||
|
||||
#### **Heading.tsx**
|
||||
|
||||
Semantic heading component with level-based sizing.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `level` - 1 | 2 | 3 | 4 | 5 | 6 (default: 2)
|
||||
- `children` - ReactNode
|
||||
- All standard HTML heading attributes
|
||||
|
||||
**Sizing:**
|
||||
|
||||
- Level 1: text-3xl font-bold
|
||||
- Level 2: text-2xl font-bold
|
||||
- Level 3: text-xl font-bold
|
||||
@@ -182,6 +203,7 @@ Semantic heading component with level-based sizing.
|
||||
- Level 6: text-sm font-semibold
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Heading } from "@/components/atoms";
|
||||
|
||||
@@ -193,20 +215,24 @@ import { Heading } from "@/components/atoms";
|
||||
---
|
||||
|
||||
#### **Text.tsx**
|
||||
|
||||
Paragraph text with semantic variants.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant` - "body1" | "body2" | "caption" | "label" (default: "body1")
|
||||
- `children` - ReactNode
|
||||
- All standard HTML paragraph attributes
|
||||
|
||||
**Variants:**
|
||||
|
||||
- body1: text-base (main content)
|
||||
- body2: text-sm (secondary content)
|
||||
- caption: text-xs (smallest text)
|
||||
- label: text-sm font-medium (form labels)
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Text } from "@/components/atoms";
|
||||
|
||||
@@ -219,13 +245,16 @@ import { Text } from "@/components/atoms";
|
||||
---
|
||||
|
||||
#### **Caption.tsx**
|
||||
|
||||
Small caption/note text component.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `children` - ReactNode
|
||||
- All standard HTML span attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Caption } from "@/components/atoms";
|
||||
|
||||
@@ -238,15 +267,19 @@ import { Caption } from "@/components/atoms";
|
||||
### Badges (`badges/`)
|
||||
|
||||
#### **Badge.tsx**
|
||||
|
||||
Labeled badge component for highlighting information.
|
||||
|
||||
**Props:**
|
||||
- `variant` - "primary" | "secondary" | "success" | "danger" | "warning" (default: "primary")
|
||||
|
||||
- `variant` - "primary" | "secondary" | "success" | "danger" | "warning"
|
||||
(default: "primary")
|
||||
- `size` - "sm" | "md" (default: "md")
|
||||
- `children` - ReactNode
|
||||
- All standard HTML span attributes
|
||||
|
||||
**Variants:**
|
||||
|
||||
- primary: Branded color
|
||||
- secondary: Light gray
|
||||
- success: Green
|
||||
@@ -254,6 +287,7 @@ Labeled badge component for highlighting information.
|
||||
- warning: Yellow
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Badge } from "@/components/atoms";
|
||||
|
||||
@@ -265,14 +299,17 @@ import { Badge } from "@/components/atoms";
|
||||
---
|
||||
|
||||
#### **PriceBadge.tsx**
|
||||
|
||||
Specialized badge for displaying formatted prices.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `price` - number
|
||||
- `currency` - string (default: "VND")
|
||||
- All standard HTML span attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { PriceBadge } from "@/components/atoms";
|
||||
|
||||
@@ -281,6 +318,7 @@ import { PriceBadge } from "@/components/atoms";
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
- VND: "50.000 ₫"
|
||||
- USD: "$99.99"
|
||||
|
||||
@@ -289,13 +327,16 @@ import { PriceBadge } from "@/components/atoms";
|
||||
### Dividers (`dividers/`)
|
||||
|
||||
#### **Divider.tsx**
|
||||
|
||||
Visual separator element.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `orientation` - "horizontal" | "vertical" (default: "horizontal")
|
||||
- All standard HTML hr attributes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Divider } from "@/components/atoms";
|
||||
|
||||
@@ -332,34 +373,41 @@ Change one variable in `globals.css` to update all atoms across the app.
|
||||
## 🔄 Import Patterns
|
||||
|
||||
### Individual Imports
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/atoms/buttons";
|
||||
import { TextInput, SearchInput } from "@/components/atoms/inputs";
|
||||
import { Heading, Text, Caption } from "@/components/atoms/typography";
|
||||
import { Badge, PriceBadge } from "@/components/atoms/badges";
|
||||
import { Button } from "@/components/atoms/buttons";
|
||||
import { Divider } from "@/components/atoms/dividers";
|
||||
import { SearchInput, TextInput } from "@/components/atoms/inputs";
|
||||
import { Caption, Heading, Text } from "@/components/atoms/typography";
|
||||
```
|
||||
|
||||
### Barrel Exports (Recommended)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
TextInput,
|
||||
SearchInput,
|
||||
Textarea,
|
||||
Heading,
|
||||
Text,
|
||||
Caption,
|
||||
Badge,
|
||||
PriceBadge,
|
||||
Button,
|
||||
Caption,
|
||||
Divider,
|
||||
Heading,
|
||||
IconButton,
|
||||
PriceBadge,
|
||||
SearchInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@/components/atoms";
|
||||
```
|
||||
|
||||
### Type Imports
|
||||
|
||||
```tsx
|
||||
import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/atoms";
|
||||
import type {
|
||||
ButtonProps,
|
||||
HeadingProps,
|
||||
TextInputProps,
|
||||
} from "@/components/atoms";
|
||||
```
|
||||
|
||||
---
|
||||
@@ -367,13 +415,10 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
||||
## ✅ Usage Examples
|
||||
|
||||
### Form Field
|
||||
|
||||
```tsx
|
||||
<div className="space-y-4">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Enter your name"
|
||||
type="text"
|
||||
/>
|
||||
<TextInput label="Name" placeholder="Enter your name" type="text" />
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="user@example.com"
|
||||
@@ -385,11 +430,12 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
||||
```
|
||||
|
||||
### Product Card
|
||||
|
||||
```tsx
|
||||
<div className="rounded-lg border p-4">
|
||||
<Heading level={3}>Product Name</Heading>
|
||||
<Text variant="body2">Product description</Text>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<PriceBadge price={50000} />
|
||||
<Button icon="fa-cart-plus">Add to Cart</Button>
|
||||
</div>
|
||||
@@ -397,6 +443,7 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
||||
```
|
||||
|
||||
### Rating Display
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
<Heading level={4}>Reviews</Heading>
|
||||
@@ -410,6 +457,7 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
||||
## 🧪 Testing Atoms
|
||||
|
||||
### Props Validation
|
||||
|
||||
```tsx
|
||||
// ✅ Valid
|
||||
<Button variant="primary" size="sm">Click</Button>
|
||||
@@ -420,7 +468,9 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
All atoms include:
|
||||
|
||||
- Semantic HTML
|
||||
- ARIA labels where appropriate
|
||||
- Keyboard navigation support
|
||||
@@ -466,7 +516,8 @@ components/atoms/
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
Once atoms are comfortable, the next phase is creating **Molecules** - combinations of atoms:
|
||||
Once atoms are comfortable, the next phase is creating **Molecules** -
|
||||
combinations of atoms:
|
||||
|
||||
- **ProductCard** - Image + Text + Badge + Button
|
||||
- **FormField** - Label + Input + Error Text
|
||||
@@ -489,6 +540,5 @@ See `OPTIMIZATION_PLAN.md` for the full roadmap.
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Complete & Ready for Use
|
||||
**Last Updated:** 2026-04-03
|
||||
**Phase:** 1 of 5
|
||||
**Status:** ✅ Complete & Ready for Use **Last Updated:** 2026-04-03 **Phase:**
|
||||
1 of 5
|
||||
|
||||
@@ -14,10 +14,12 @@ export default function Button({
|
||||
}: ButtonProps) {
|
||||
const styles = {
|
||||
base: "font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5",
|
||||
payment: "inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl transition-colors",
|
||||
login: "w-full cursor-pointer rounded-xl py-3 font-semibold transition-all duration-150",
|
||||
}
|
||||
|
||||
payment:
|
||||
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl transition-colors",
|
||||
login:
|
||||
"w-full cursor-pointer rounded-xl py-3 font-semibold transition-all duration-150",
|
||||
};
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
|
||||
@@ -25,8 +27,10 @@ export default function Button({
|
||||
"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: "border-none bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-98",
|
||||
bgWhite: "border-2 border-(--color-primary) bg-white text-(--color-primary) hover:bg-(--color-primary) hover:text-white active:scale-98",
|
||||
primaryNoBorder:
|
||||
"border-none bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-98",
|
||||
bgWhite:
|
||||
"border-2 border-(--color-primary) bg-white text-(--color-primary) hover:bg-(--color-primary) hover:text-white active:scale-98",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'style'> {
|
||||
export interface ButtonProps extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"style"
|
||||
> {
|
||||
style?: "base" | "payment" | "login";
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost" | "primaryNoBorder" | "bgWhite";
|
||||
variant?:
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "danger"
|
||||
| "ghost"
|
||||
| "primaryNoBorder"
|
||||
| "bgWhite";
|
||||
size?: "sm" | "md" | "lg";
|
||||
icon?: string; // FontAwesome class like "fa-solid fa-cart-plus"
|
||||
iconPosition?: "left" | "right";
|
||||
|
||||
@@ -3,4 +3,4 @@ import { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
export interface ErrorMessageLoginProps {
|
||||
message: string;
|
||||
type?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,5 @@ export default function ErrorMessageLogin({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
type === "primary" ? primaryType() : secondaryType()
|
||||
);
|
||||
return type === "primary" ? primaryType() : secondaryType();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export { default as ErrorMessageLogin } from "./ErrorMessageLogin";
|
||||
export type {
|
||||
ErrorMessageLoginProps,
|
||||
} from "./Error.types";
|
||||
export type { ErrorMessageLoginProps } from "./Error.types";
|
||||
|
||||
@@ -46,7 +46,11 @@ export default function LoginInput({
|
||||
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"}
|
||||
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()}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ReviewModal } from "@/components/organisms/modals";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PaymentSummaryCardProps } from "./Card.types";
|
||||
@@ -14,14 +13,13 @@ export default function PaymentSummaryCard({
|
||||
isCustomer = false,
|
||||
backHref,
|
||||
}: PaymentSummaryCardProps) {
|
||||
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const handlePayment = () => {
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const handlePayment = () => {
|
||||
// UI-only: open review modal after "payment"
|
||||
if (isCustomer) {
|
||||
setIsReviewOpen(true);
|
||||
}
|
||||
};
|
||||
setIsReviewOpen(true);
|
||||
}
|
||||
};
|
||||
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">
|
||||
@@ -67,7 +65,10 @@ export default function PaymentSummaryCard({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link href={backHref || "/"} className={isCustomer ? "" : "col-span-2"}>
|
||||
<Link
|
||||
href={backHref || "/"}
|
||||
className={isCustomer ? "" : "col-span-2"}
|
||||
>
|
||||
<Button
|
||||
style="payment"
|
||||
onClick={() => setIsReviewOpen(false)}
|
||||
|
||||
@@ -16,15 +16,25 @@ interface BarChartProps {
|
||||
* 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 [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 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 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;
|
||||
@@ -47,8 +57,24 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -62,48 +88,121 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
||||
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"
|
||||
<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"
|
||||
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 })} />
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,10 @@ 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 padL = 56,
|
||||
padR = 16,
|
||||
padT = 16,
|
||||
padB = 40;
|
||||
const chartW = W - padL - padR;
|
||||
const chartH = H - padT - padB;
|
||||
|
||||
@@ -66,47 +69,122 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
|
||||
|
||||
{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>
|
||||
<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" />
|
||||
<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>
|
||||
<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}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={hovered === p.index ? 5 : 3}
|
||||
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
|
||||
stroke="#FDF6EC" strokeWidth="2"
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
{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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export interface PieSlice {
|
||||
label: string;
|
||||
@@ -78,7 +78,14 @@ export function PieChart({ data }: PieChartProps) {
|
||||
/>
|
||||
))}
|
||||
{hovered !== null && (
|
||||
<text x={CX} y={CY + 5} textAnchor="middle" fontSize="12" fontWeight="bold" fill="#3D2B1F">
|
||||
<text
|
||||
x={CX}
|
||||
y={CY + 5}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
fill="#3D2B1F"
|
||||
>
|
||||
{slices[hovered].percent.toFixed(1)}%
|
||||
</text>
|
||||
)}
|
||||
@@ -93,14 +100,22 @@ export function PieChart({ data }: PieChartProps) {
|
||||
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="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 }}
|
||||
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>
|
||||
<span className="text-xs text-(--color-text-muted)">
|
||||
{s.percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -32,18 +32,33 @@ export function ProductTable({ data }: ProductTableProps) {
|
||||
|
||||
const handleSort = (key: keyof ProductSalesStats) => {
|
||||
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
else { setSortKey(key); setSortDir("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)"
|
||||
}`} />
|
||||
<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 }) => (
|
||||
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)}
|
||||
@@ -57,38 +72,67 @@ export function ProductTable({ data }: ProductTableProps) {
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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="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="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"
|
||||
}`}>
|
||||
<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>
|
||||
|
||||
@@ -29,7 +29,9 @@ export function SummaryCard({
|
||||
<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>
|
||||
<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 && (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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";
|
||||
import Button from "@/components/atoms/buttons/Button";
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
@@ -102,7 +102,12 @@ export default function LoginForm() {
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Login Button */}
|
||||
<Button variant="primaryNoBorder" type="submit" style="login" size="lg">
|
||||
<Button
|
||||
variant="primaryNoBorder"
|
||||
type="submit"
|
||||
style="login"
|
||||
size="lg"
|
||||
>
|
||||
Đăng nhập
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -152,7 +152,10 @@ export default function ProductsTab() {
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-background transition-colors">
|
||||
<tr
|
||||
key={p.id}
|
||||
className="hover:bg-background transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-foreground font-medium">{p.name}</p>
|
||||
|
||||
Reference in New Issue
Block a user