chore: release [ci skip]

This commit is contained in:
gitea-actions
2026-04-06 15:19:02 +00:00
parent fd57a35820
commit 78f3d6bbe9
27 changed files with 811 additions and 367 deletions
+42 -25
View File
@@ -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
View File
@@ -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
+10 -6
View File
@@ -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 = {
+11 -2
View File
@@ -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";
+1 -1
View File
@@ -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 -3
View File
@@ -1,4 +1,2 @@
export { default as ErrorMessageLogin } from "./ErrorMessageLogin";
export type {
ErrorMessageLoginProps,
} from "./Error.types";
export type { ErrorMessageLoginProps } from "./Error.types";
+5 -1
View File
@@ -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)}
+133 -34
View File
@@ -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>
);
+100 -22
View File
@@ -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>
);
+20 -5
View File
@@ -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>
+68 -24
View File
@@ -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 && (
+7 -2
View File
@@ -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>
+4 -1
View File
@@ -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>