@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.
- package/CHANGELOG.md +65 -39
- package/package.json +3 -1
- package/src/auth/proxy-gate.ts +80 -0
- package/src/crud/crud-route-handlers.ts +157 -0
- package/src/crud/server-service.ts +312 -0
- package/src/crud/server.ts +18 -0
- package/src/rbac/role-service.ts +40 -33
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/page-tabs.tsx +8 -42
- package/src/ui/primitives/index.tsx +1 -0
- package/src/ui/shared/confirm-dialog.tsx +66 -0
- package/src/ui/shared/index.ts +9 -0
- package/src/ui/shared/list-toolbar.tsx +263 -0
- package/src/ui/shared/page-header.tsx +57 -0
- package/src/ui/shared/stat-bar.tsx +38 -0
- package/src/ui/shared/status-indicator.tsx +173 -0
- package/src/ui/shared/table-styles.ts +47 -0
- package/src/ui/shared/table-sum-footer.tsx +41 -0
|
@@ -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
|
+
}
|