@goplusvn/core 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,263 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { Download, Filter, MoreHorizontal, Plus, Search, X } from "lucide-react"
5
+
6
+ import {
7
+ Button,
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ Input,
15
+ } from "../primitives"
16
+ import {
17
+ Sheet,
18
+ SheetClose,
19
+ SheetContent,
20
+ SheetDescription,
21
+ SheetHeader,
22
+ SheetTitle,
23
+ SheetTrigger,
24
+ } from "../feedback"
25
+
26
+ /**
27
+ * Mô tả 1 hành động phụ trong menu "…" (data-driven để toolbar tự render đúng
28
+ * DropdownMenuItem — tránh lỗi Radix context khi truyền JSX từ nguồn khác).
29
+ */
30
+ export interface ListToolbarAction {
31
+ key: string
32
+ label: string
33
+ icon?: ReactNode
34
+ onClick: () => void
35
+ disabled?: boolean
36
+ destructive?: boolean
37
+ /** Ẩn mục (vd thiếu quyền) — toolbar tự lọc, caller cứ truyền cả danh sách. */
38
+ hidden?: boolean
39
+ }
40
+
41
+ interface ListToolbarProps {
42
+ search: string
43
+ onSearchChange: (value: string) => void
44
+ searchPlaceholder?: string
45
+ /** Bộ lọc chính — render inline cạnh ô tìm kiếm (vừa đủ 1 dòng). */
46
+ children?: ReactNode
47
+ /** Bộ lọc nâng cao — render trong Sheet; badge = advancedCount. */
48
+ advanced?: ReactNode
49
+ advancedCount?: number
50
+ /** Xóa lọc (chỉ hiện khi đang lọc). */
51
+ isFiltered?: boolean
52
+ onReset?: () => void
53
+ /** Xuất Excel — nằm trong menu "Khác" (…). */
54
+ onExport?: () => void
55
+ canExport?: boolean
56
+ /** Tạo mới — luôn ở cuối toolbar. */
57
+ onCreate?: () => void
58
+ canCreate?: boolean
59
+ createLabel?: string
60
+ /** Nút điều khiển bảng (dropdown "Hiển thị cột") — xem useTableControls. */
61
+ tableControls?: ReactNode
62
+ /** Hành động phụ trong menu "…" (vd Cấu hình, In hàng loạt). Ít dùng. */
63
+ moreActions?: ListToolbarAction[]
64
+ }
65
+
66
+ /**
67
+ * Toolbar danh sách dùng chung — khớp phong cách trang đơn bán hàng:
68
+ * [tìm kiếm] [lọc chính 1 dòng] · [Bộ lọc nâng cao + badge] [Xóa lọc] [… Xuất] [+ Tạo mới].
69
+ * Toolbar trần (không khung card), bộ lọc tràn dòng → đưa vào slot `advanced` (Sheet).
70
+ */
71
+ export function ListToolbar({
72
+ search,
73
+ onSearchChange,
74
+ searchPlaceholder = "Tìm kiếm...",
75
+ children,
76
+ advanced,
77
+ advancedCount = 0,
78
+ isFiltered,
79
+ onReset,
80
+ onExport,
81
+ canExport = true,
82
+ onCreate,
83
+ canCreate = true,
84
+ createLabel = "Tạo mới",
85
+ tableControls,
86
+ moreActions,
87
+ }: ListToolbarProps) {
88
+ const showExport = canExport && !!onExport
89
+ const actions = (moreActions ?? []).filter((a) => !a.hidden)
90
+ const showMoreMenu = showExport || actions.length > 0
91
+
92
+ return (
93
+ <div className="flex flex-col xl:flex-row items-start xl:items-center justify-between w-full gap-3 xl:gap-4">
94
+ {/* Tìm kiếm + bộ lọc chính */}
95
+ <div className="flex w-full xl:w-auto flex-1 flex-wrap items-center gap-2 xl:gap-3">
96
+ <div className="relative w-full sm:flex-1 sm:min-w-[220px] xl:flex-[2] xl:min-w-[300px]">
97
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
98
+ <Input
99
+ placeholder={searchPlaceholder}
100
+ value={search}
101
+ onChange={(e) => onSearchChange(e.target.value)}
102
+ className="pl-9 h-9 w-full bg-secondary/30 focus:bg-background shadow-sm focus-visible:ring-inset focus-visible:ring-offset-0"
103
+ />
104
+ </div>
105
+ {children}
106
+ </div>
107
+
108
+ {/* Hành động */}
109
+ <div className="flex flex-wrap sm:flex-nowrap items-center justify-between xl:justify-end gap-2 w-full xl:w-auto shrink-0">
110
+ {/* Bộ lọc nâng cao + Xóa lọc */}
111
+ <div className="flex items-center">
112
+ {advanced && (
113
+ <Sheet>
114
+ <SheetTrigger asChild>
115
+ <Button
116
+ variant="outline"
117
+ className="h-9 min-w-9 shrink-0 shadow-sm relative"
118
+ title="Bộ lọc nâng cao"
119
+ >
120
+ <Filter className="h-4 w-4 sm:mr-2" />
121
+ <span className="hidden sm:inline font-medium">Bộ lọc</span>
122
+ {advancedCount > 0 && (
123
+ <span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground border-2 border-background">
124
+ {advancedCount}
125
+ </span>
126
+ )}
127
+ </Button>
128
+ </SheetTrigger>
129
+ <SheetContent
130
+ side="right"
131
+ className="flex flex-col h-full w-full sm:max-w-md p-0 gap-0"
132
+ >
133
+ <SheetHeader className="text-left px-6 py-4 border-b flex flex-row items-center justify-between space-y-0">
134
+ <div className="space-y-1">
135
+ <SheetTitle className="text-lg font-bold">
136
+ Bộ lọc nâng cao
137
+ </SheetTitle>
138
+ <SheetDescription>
139
+ Tinh chỉnh danh sách theo các tiêu chí bên dưới.
140
+ </SheetDescription>
141
+ </div>
142
+ <SheetClose asChild>
143
+ <Button
144
+ variant="ghost"
145
+ size="icon"
146
+ className="h-8 w-8 rounded-full shrink-0"
147
+ >
148
+ <X className="h-5 w-5" />
149
+ <span className="sr-only">Đóng</span>
150
+ </Button>
151
+ </SheetClose>
152
+ </SheetHeader>
153
+ <div className="flex-1 overflow-y-auto px-6 py-5">
154
+ <div className="flex flex-col gap-5">{advanced}</div>
155
+ </div>
156
+ <div className="border-t px-6 py-4 flex items-center gap-2">
157
+ {onReset && (
158
+ <Button
159
+ variant="outline"
160
+ onClick={onReset}
161
+ className="flex-1 text-muted-foreground hover:text-destructive"
162
+ >
163
+ <X className="mr-1 h-4 w-4" /> Xóa lọc
164
+ </Button>
165
+ )}
166
+ <SheetClose asChild>
167
+ <Button className="flex-1">Xem kết quả</Button>
168
+ </SheetClose>
169
+ </div>
170
+ </SheetContent>
171
+ </Sheet>
172
+ )}
173
+ {isFiltered && onReset && (
174
+ <Button
175
+ onClick={onReset}
176
+ variant="ghost"
177
+ size="sm"
178
+ className="h-9 ml-1 text-muted-foreground hover:text-destructive hidden sm:flex"
179
+ title="Xóa tất cả bộ lọc"
180
+ >
181
+ Xóa lọc
182
+ </Button>
183
+ )}
184
+ </div>
185
+
186
+ {/* Điều khiển cột + Khác (…) + Tạo mới */}
187
+ <div className="flex items-center gap-2">
188
+ {tableControls}
189
+ {showMoreMenu && (
190
+ <DropdownMenu modal={false}>
191
+ <DropdownMenuTrigger asChild>
192
+ <Button
193
+ variant="outline"
194
+ size="sm"
195
+ className="h-9 w-9 p-0 shadow-sm"
196
+ title="Khác"
197
+ >
198
+ <MoreHorizontal className="h-4 w-4 text-muted-foreground" />
199
+ </Button>
200
+ </DropdownMenuTrigger>
201
+ <DropdownMenuContent align="end" className="w-[200px]">
202
+ <DropdownMenuLabel className="text-xs text-muted-foreground uppercase">
203
+ Hành động
204
+ </DropdownMenuLabel>
205
+ <DropdownMenuSeparator />
206
+ {showExport && (
207
+ <DropdownMenuItem
208
+ onClick={onExport}
209
+ className="gap-2 cursor-pointer"
210
+ >
211
+ <Download className="h-4 w-4" />
212
+ Xuất danh sách (Excel)
213
+ </DropdownMenuItem>
214
+ )}
215
+ {actions.map((action) => (
216
+ <DropdownMenuItem
217
+ key={action.key}
218
+ onClick={action.onClick}
219
+ disabled={action.disabled}
220
+ className={`gap-2 cursor-pointer${action.destructive ? " text-destructive focus:text-destructive" : ""}`}
221
+ >
222
+ {action.icon}
223
+ {action.label}
224
+ </DropdownMenuItem>
225
+ ))}
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ )}
229
+ {canCreate && onCreate && (
230
+ <Button
231
+ size="sm"
232
+ onClick={onCreate}
233
+ className="h-9 font-medium shadow-sm"
234
+ >
235
+ <Plus className="h-4 w-4 sm:mr-2" />
236
+ <span className="hidden sm:inline">{createLabel}</span>
237
+ </Button>
238
+ )}
239
+ </div>
240
+ </div>
241
+ </div>
242
+ )
243
+ }
244
+
245
+ /**
246
+ * Ô lọc trong Sheet nâng cao: nhãn + control, xếp dọc.
247
+ */
248
+ export function AdvancedField({
249
+ label,
250
+ children,
251
+ }: {
252
+ label: string
253
+ children: ReactNode
254
+ }) {
255
+ return (
256
+ <div className="space-y-1.5">
257
+ <label className="text-xs font-medium text-muted-foreground">
258
+ {label}
259
+ </label>
260
+ {children}
261
+ </div>
262
+ )
263
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PageHeader — standardized page header (title + description + icon + actions).
5
+ * Promoted from vinhhoa/wu; every app was copying this.
6
+ */
7
+
8
+ import React, { memo } from "react";
9
+ import { cn } from "../../utils";
10
+
11
+ export interface PageHeaderProps {
12
+ /** Page title (required) */
13
+ title: string;
14
+ /** Optional subtitle/description */
15
+ description?: string;
16
+ /** Optional icon displayed before the title */
17
+ icon?: React.ReactNode;
18
+ /** Right-side actions (buttons, filters, etc.) */
19
+ actions?: React.ReactNode;
20
+ /** Breadcrumb node above the title */
21
+ breadcrumbs?: React.ReactNode;
22
+ /** Extra content below the title/actions row */
23
+ children?: React.ReactNode;
24
+ className?: string;
25
+ }
26
+
27
+ export const PageHeader = memo(function PageHeader({
28
+ title,
29
+ description,
30
+ icon,
31
+ actions,
32
+ breadcrumbs,
33
+ children,
34
+ className,
35
+ }: PageHeaderProps) {
36
+ return (
37
+ <div className={cn("space-y-1", className)}>
38
+ {breadcrumbs && <div className="text-xs text-text-tertiary">{breadcrumbs}</div>}
39
+
40
+ <div className="flex items-center justify-between gap-4">
41
+ <div className="flex min-w-0 items-center gap-3">
42
+ {icon && <div className="flex-shrink-0 text-text-secondary">{icon}</div>}
43
+ <div className="min-w-0">
44
+ <h1 className="truncate text-lg font-semibold text-text-primary">{title}</h1>
45
+ {description && (
46
+ <p className="mt-0.5 truncate text-sm text-text-secondary">{description}</p>
47
+ )}
48
+ </div>
49
+ </div>
50
+
51
+ {actions && <div className="flex flex-shrink-0 items-center gap-2">{actions}</div>}
52
+ </div>
53
+
54
+ {children && <div className="mt-3">{children}</div>}
55
+ </div>
56
+ );
57
+ });
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ // StatBar — horizontal KPI strip (vinhhoa style). A thin wrapper over
4
+ // CompactStatBar that lets SERVER pages pass plain data (iconName as a string)
5
+ // without importing lucide components in the server component.
6
+ import { CompactStatBar, type CompactStatItem } from "../data-display";
7
+ import {
8
+ AlertTriangle, Ban, Boxes, Building2, CheckCircle2, Clock, FileCheck2, GitCompareArrows,
9
+ KeyRound, Landmark, ListChecks, MousePointerClick, PauseCircle, Percent, ShieldAlert, ShieldCheck,
10
+ Users, Wallet, XCircle, Zap, type LucideIcon,
11
+ } from "lucide-react";
12
+
13
+ const ICONS: Record<string, LucideIcon> = {
14
+ ListChecks, CheckCircle2, PauseCircle, Ban, Clock, XCircle, GitCompareArrows,
15
+ AlertTriangle, Percent, Landmark, Building2, Wallet, FileCheck2, ShieldAlert, Users,
16
+ ShieldCheck, KeyRound, Boxes, MousePointerClick, Zap,
17
+ };
18
+
19
+ export type StatItem = {
20
+ id: string;
21
+ label: string;
22
+ value: string | number;
23
+ iconName: keyof typeof ICONS | string;
24
+ colorTheme?: CompactStatItem["colorTheme"];
25
+ isHighlighted?: boolean;
26
+ };
27
+
28
+ export function StatBar({ items }: { items: StatItem[] }) {
29
+ const mapped: CompactStatItem[] = items.map((i) => ({
30
+ id: i.id,
31
+ label: i.label,
32
+ value: i.value,
33
+ icon: ICONS[i.iconName as string] ?? ListChecks,
34
+ colorTheme: i.colorTheme,
35
+ isHighlighted: i.isHighlighted,
36
+ }));
37
+ return <CompactStatBar items={mapped} />;
38
+ }
@@ -0,0 +1,173 @@
1
+ "use client"
2
+
3
+ /**
4
+ * StatusIndicator — Semantic status dot + text component
5
+ * Inspired by Plane's priority-icon pattern
6
+ *
7
+ * Maps ERP status strings to semantic colors automatically.
8
+ * Supports custom color overrides for non-standard statuses.
9
+ *
10
+ * @example
11
+ * <StatusIndicator status="active" />
12
+ * <StatusIndicator status="pending" />
13
+ * <StatusIndicator status="cancelled" label="Đã hủy" />
14
+ */
15
+
16
+ import React, { memo, useMemo } from "react"
17
+ import { cn } from "../../utils"
18
+
19
+ // ── Status → Semantic Color Map ──────────────────────────────
20
+ const STATUS_MAP: Record<string, { color: string; dotClass: string; label: string }> = {
21
+ // Active / Positive
22
+ active: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Hoạt động" },
23
+ completed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Hoàn thành" },
24
+ delivered: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã giao" },
25
+ paid: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã thanh toán" },
26
+ approved: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã duyệt" },
27
+ director_approved: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Giám đốc đã duyệt" },
28
+ confirmed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã xác nhận" },
29
+ received: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Đã nhận" },
30
+ delivery_completed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "Hoàn thành" },
31
+ supplier_confirmed: { color: "text-success-text", dotClass: "bg-success-semantic", label: "NCC xác nhận" },
32
+
33
+ // Pending / Warning
34
+ pending: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ xử lý" },
35
+ processing: { color: "text-warning-text", dotClass: "bg-warning", label: "Đang xử lý" },
36
+ partial: { color: "text-warning-text", dotClass: "bg-warning", label: "Một phần" },
37
+ partially_paid:{ color: "text-warning-text", dotClass: "bg-warning", label: "TT một phần" },
38
+ waiting: { color: "text-warning-text", dotClass: "bg-warning", label: "Đang chờ" },
39
+ sent: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt" },
40
+ pending_l1: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt L1" },
41
+ pending_l2: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt L2" },
42
+ pending_l3: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ duyệt L3" },
43
+ pending_control: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ kiểm soát" },
44
+ accounting_pending: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ KT duyệt" },
45
+ director_pending: { color: "text-warning-text", dotClass: "bg-warning", label: "Chờ BGĐ duyệt" },
46
+ accounting_revision: { color: "text-warning-text", dotClass: "bg-warning", label: "Chỉnh sửa" },
47
+ waiting_supplier:{ color: "text-warning-text", dotClass: "bg-warning", label: "Chờ NCC" },
48
+ partially_received: { color: "text-warning-text", dotClass: "bg-warning", label: "Nhận một phần" },
49
+ delivery_adjustment: { color: "text-warning-text", dotClass: "bg-warning", label: "Điều chỉnh giao hàng" },
50
+
51
+ // Info / In-progress
52
+ ordered: { color: "text-info-text", dotClass: "bg-info", label: "Đã đặt" },
53
+ shipping: { color: "text-info-text", dotClass: "bg-info", label: "Đang giao" },
54
+ in_transit: { color: "text-info-text", dotClass: "bg-info", label: "Đang vận chuyển" },
55
+ delivering: { color: "text-info-text", dotClass: "bg-info", label: "Đang giao hàng" },
56
+ accounting_approved: { color: "text-info-text", dotClass: "bg-info", label: "Kế toán đã duyệt" },
57
+
58
+ // Danger / Negative
59
+ cancelled: { color: "text-danger-text", dotClass: "bg-danger", label: "Đã hủy" },
60
+ overdue: { color: "text-danger-text", dotClass: "bg-danger", label: "Quá hạn" },
61
+ rejected: { color: "text-danger-text", dotClass: "bg-danger", label: "Từ chối" },
62
+ failed: { color: "text-danger-text", dotClass: "bg-danger", label: "Thất bại" },
63
+ inactive: { color: "text-danger-text", dotClass: "bg-danger", label: "Ngừng hoạt động" },
64
+ unpaid: { color: "text-danger-text", dotClass: "bg-danger", label: "Chưa thanh toán" },
65
+ refunded: { color: "text-danger-text", dotClass: "bg-danger", label: "Đã hoàn tiền" },
66
+ returned: { color: "text-danger-text", dotClass: "bg-danger", label: "Hoàn trả" },
67
+
68
+ // Neutral
69
+ draft: { color: "text-text-tertiary", dotClass: "bg-text-tertiary", label: "Nháp" },
70
+ archived: { color: "text-text-disabled", dotClass: "bg-text-disabled", label: "Lưu trữ" },
71
+ unknown: { color: "text-text-tertiary", dotClass: "bg-text-tertiary", label: "Không xác định" },
72
+ }
73
+
74
+ // ── Tone (text color) → Badge variant ───────────────────────
75
+ // Lets callers render the SAME status taxonomy as a filled colored Badge
76
+ // (bg + text) instead of a bare dot. Single source of truth = STATUS_MAP.
77
+ const TONE_TO_BADGE_VARIANT: Record<
78
+ string,
79
+ "success" | "warning" | "danger" | "info" | "secondary"
80
+ > = {
81
+ "text-success-text": "success",
82
+ "text-warning-text": "warning",
83
+ "text-danger-text": "danger",
84
+ "text-info-text": "info",
85
+ "text-text-tertiary": "secondary",
86
+ "text-text-disabled": "secondary",
87
+ }
88
+
89
+ // ── "Solid / Trầm sang" (direction D) chip styles ────────────
90
+ // Deep step-700 fills + white text. The subtle Badge variants (green-3 etc.)
91
+ // wash out on dark surfaces like the navy order header; these read crisply on
92
+ // both white and navy. Keyed by Badge variant so it tracks the same taxonomy.
93
+ const SOLID_STATUS_CLASS: Record<string, string> = {
94
+ success: "bg-emerald-700 text-white",
95
+ warning: "bg-amber-700 text-white",
96
+ danger: "bg-red-700 text-white",
97
+ info: "bg-blue-700 text-white",
98
+ secondary: "bg-slate-600 text-white",
99
+ }
100
+
101
+ /**
102
+ * Resolve a status string to its canonical label + colors + matching Badge
103
+ * variant. Use when you need a filled colored chip (e.g. the order header)
104
+ * rather than the dot-only `StatusIndicator`.
105
+ *
106
+ * - `badgeVariant`: the subtle semantic Badge variant (good on white surfaces).
107
+ * - `solidClass`: deep "Trầm sang" fill + white text (good on navy/dark bars).
108
+ */
109
+ export function getStatusMeta(status: string, labelOverride?: string) {
110
+ const key = status?.toLowerCase().replace(/\s+/g, "_") ?? "unknown"
111
+ const resolved = STATUS_MAP[key] ?? STATUS_MAP.unknown
112
+ const badgeVariant = TONE_TO_BADGE_VARIANT[resolved.color] ?? "secondary"
113
+ return {
114
+ label: labelOverride ?? resolved.label,
115
+ color: resolved.color,
116
+ dotClass: resolved.dotClass,
117
+ badgeVariant,
118
+ solidClass: SOLID_STATUS_CLASS[badgeVariant] ?? SOLID_STATUS_CLASS.secondary,
119
+ }
120
+ }
121
+
122
+ export interface StatusIndicatorProps {
123
+ /** Status key (e.g., "active", "pending", "cancelled") */
124
+ status: string
125
+ /** Override the default Vietnamese label */
126
+ label?: string
127
+ /** Size variant */
128
+ size?: "sm" | "md" | "lg"
129
+ /** Show dot only (no text) */
130
+ dotOnly?: boolean
131
+ /** Custom dot color class override */
132
+ dotColor?: string
133
+ /** Additional CSS classes */
134
+ className?: string
135
+ }
136
+
137
+ export const StatusIndicator = memo(function StatusIndicator({
138
+ status,
139
+ label: labelOverride,
140
+ size = "md",
141
+ dotOnly = false,
142
+ dotColor,
143
+ className,
144
+ }: StatusIndicatorProps) {
145
+ const resolved = useMemo(() => {
146
+ const key = status?.toLowerCase().replace(/\s+/g, "_") ?? "unknown"
147
+ return STATUS_MAP[key] ?? STATUS_MAP.unknown
148
+ }, [status])
149
+
150
+ const dotSizeClass = size === "sm" ? "size-1.5" : size === "lg" ? "size-3" : "size-2"
151
+ const textSizeClass = size === "sm" ? "text-[11px]" : size === "lg" ? "text-sm" : "text-xs"
152
+ const displayLabel = labelOverride ?? resolved.label
153
+
154
+ if (dotOnly) {
155
+ return (
156
+ <span
157
+ className={cn("inline-block rounded-full", dotColor ?? resolved.dotClass, dotSizeClass, className)}
158
+ title={displayLabel}
159
+ />
160
+ )
161
+ }
162
+
163
+ return (
164
+ <span className={cn("inline-flex items-center gap-1.5", className)}>
165
+ <span
166
+ className={cn("inline-block rounded-full flex-shrink-0", dotColor ?? resolved.dotClass, dotSizeClass)}
167
+ />
168
+ <span className={cn("font-medium", resolved.color, textSizeClass)}>
169
+ {displayLabel}
170
+ </span>
171
+ </span>
172
+ )
173
+ })
@@ -0,0 +1,47 @@
1
+ // Hợp đồng style bảng CHUẨN của app — trích từ bảng "Đơn bán hàng" của vinhhoa
2
+ // để MỌI trang có bảng (trừ trang CRUD generic) dùng lại đồng nhất: header dính,
3
+ // hàng zebra + viền trái primary khi hover, số tiền vàng, footer dính.
4
+ //
5
+ // Cách dùng: import các hằng này thay vì tự viết class rời rạc.
6
+ // <div className={tableShell}><div className={tableScroll}>
7
+ // <Table className={cn(tableBase, "min-w-[1200px]")}>
8
+ // <TableHeader className={theadSticky}><TableRow className={headerRow}>
9
+ // <TableHead className={thClass}>…</TableHead>
10
+ // …
11
+ // <TableRow className={bodyRow} onClick={…}>
12
+ // <TableCell className={sttCell}>{i+1}</TableCell>
13
+ // <TableCell className={moneyCell} style={{ color: MONEY_GOLD }}>…</TableCell>
14
+
15
+ export const tableShell =
16
+ "flex h-full flex-col overflow-hidden rounded-md border border-border bg-card shadow-sm";
17
+
18
+ export const tableScroll = "relative min-h-0 flex-1 overflow-auto show-scrollbar";
19
+
20
+ // w-full ép bảng GIÃN HẾT chiều rộng khung (core mặc định w-max → co cụm theo nội
21
+ // dung trên màn rộng; w-full đè lại). Kèm min-w-[Xpx] ở từng bảng để cuộn ngang
22
+ // khi màn hẹp. border-separate để nền header/footer dính không bị viền cell xuyên qua.
23
+ export const tableBase = "w-full border-separate border-spacing-0";
24
+
25
+ export const theadSticky = "sticky top-0 z-20 bg-card";
26
+
27
+ export const headerRow = "border-b-2 border-border hover:bg-transparent";
28
+
29
+ export const thClass = "whitespace-nowrap bg-card py-2.5 text-xs font-semibold text-foreground";
30
+
31
+ // Hàng dữ liệu: con trỏ tay, zebra, hover đổi nền + viền trái primary (không xê dịch).
32
+ export const bodyRow =
33
+ "group relative cursor-pointer border-b border-l-[3px] border-border/40 border-l-transparent transition-colors even:bg-muted/30 hover:border-l-primary hover:bg-accent/50 dark:hover:bg-slate-800/40";
34
+
35
+ // Hàng không bấm được (chỉ hiển thị) — bỏ con trỏ tay.
36
+ export const bodyRowStatic =
37
+ "group relative border-b border-l-[3px] border-border/40 border-l-transparent transition-colors even:bg-muted/30 hover:bg-accent/40";
38
+
39
+ export const sttCell = "text-center text-xs tabular-nums text-muted-foreground";
40
+
41
+ export const moneyCell = "whitespace-nowrap text-right font-bold tabular-nums";
42
+
43
+ // Vàng tiền tệ dùng thống nhất toàn app (giống ô "Tổng tiền" đơn bán hàng).
44
+ export const MONEY_GOLD = "#c8860b";
45
+
46
+ export const tfootSticky =
47
+ "sticky bottom-0 z-20 border-t-2 border-border bg-card [&>tr]:hover:bg-transparent";
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { TableCell } from "../primitives";
4
+
5
+ import { cn } from "../../utils";
6
+
7
+ /**
8
+ * Ô tổng dùng chung cho dòng <TableFooter> của các bảng danh sách — bám mẫu
9
+ * đơn bán hàng vinhhoa. `value` là tổng TOÀN BỘ kết quả sau lọc (mọi trang),
10
+ * không phải tổng của riêng trang hiển thị.
11
+ */
12
+ export function SumFooterCell({
13
+ value,
14
+ note,
15
+ align = "right",
16
+ colSpan,
17
+ className,
18
+ }: {
19
+ value: string;
20
+ note?: string;
21
+ align?: "left" | "center" | "right";
22
+ colSpan?: number;
23
+ className?: string;
24
+ }) {
25
+ const alignText = align === "left" ? "text-left" : align === "center" ? "text-center" : "text-right";
26
+ const alignItems = align === "left" ? "items-start" : align === "center" ? "items-center" : "items-end";
27
+
28
+ return (
29
+ <TableCell colSpan={colSpan} className={cn("whitespace-nowrap bg-card py-2 tabular-nums", alignText, className)}>
30
+ <div className={cn("flex flex-col leading-tight", alignItems)}>
31
+ <span className="font-bold text-foreground">{value}</span>
32
+ {note != null && <span className="text-[10px] font-medium text-muted-foreground">{note}</span>}
33
+ </div>
34
+ </TableCell>
35
+ );
36
+ }
37
+
38
+ /** Cộng nhanh một trường số trên mảng dòng (tổng-trang phía client). */
39
+ export function sumBy<T>(rows: T[], pick: (row: T) => number | null | undefined) {
40
+ return rows.reduce((acc, row) => acc + (Number(pick(row)) || 0), 0);
41
+ }