@goplusvn/core 0.1.14 → 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 +55 -51
- package/package.json +1 -1
- package/src/ui/shared/confirm-dialog.tsx +66 -0
- package/src/ui/shared/index.ts +3 -0
- package/src/ui/shared/list-toolbar.tsx +263 -0
- package/src/ui/shared/stat-bar.tsx +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,55 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.15 — Shared UI: ConfirmDialog, StatBar, ListToolbar
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Tiếp 0.1.14: promote 3 component dùng chung nữa vào `@goerp/core/ui` (ConfirmDialog, StatBar, ListToolbar + AdvancedField) — import từ sub-barrel tránh circular. Additive; wu shim + verify render 200.
|
|
6
|
+
|
|
7
|
+
## 0.1.14 â Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
|
|
8
|
+
|
|
9
|
+
Äúc kết từ viá»c dá»±ng app má»i (wu-vpbank): những thứ Má»I app phải tá»± viết lại nay
|
|
10
|
+
ÄÆ°a và o core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toà n bá» **additive, backward-
|
|
11
|
+
compatible** â 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toà n.
|
|
8
12
|
|
|
9
13
|
- **Server-CRUD engine** (`@goerp/core/crud/server`): `createServerCrudService`
|
|
10
14
|
(Prisma-agnostic qua DI), `getModelName`, `createAuditUserNameResolver` (schema-
|
|
11
|
-
tolerant
|
|
12
|
-
Next.js
|
|
15
|
+
tolerant tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
|
|
16
|
+
Next.js tá»± gác quyá»n). App bá» ~350 LOC engine tá»± chế.
|
|
13
17
|
- **Auth request-gate** (`@goerp/core/auth/proxy-gate`): `createAuthProxy` (default-
|
|
14
|
-
deny) + `unauthorizedResponse`. proxy.ts app
|
|
15
|
-
- **RBAC schema-tolerant**: `getRolesData(db, params, schema?)`
|
|
16
|
-
(`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`)
|
|
17
|
-
|
|
18
|
+
deny) + `unauthorizedResponse`. proxy.ts app còn ~10 LOC.
|
|
19
|
+
- **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nháºn field-map
|
|
20
|
+
(`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) â app schema
|
|
21
|
+
khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
|
|
18
22
|
- **Shared UI** (`@goerp/core/ui`): PageHeader, StatusIndicator + getStatusMeta,
|
|
19
|
-
SumFooterCell + sumBy, table-styles (
|
|
23
|
+
SumFooterCell + sumBy, table-styles (mẫu bảng ÄÆ¡n bán hà ng, subpath server-safe
|
|
20
24
|
`@goerp/core/ui/shared/table-styles`); export DynamicIcon.
|
|
21
25
|
|
|
22
|
-
Verify: core tsc 0
|
|
26
|
+
Verify: core tsc 0 lá»i; wu consume toà n bá» (tsc 0/0 + render/CRUD/auth chạy Äúng);
|
|
23
27
|
golden vinhhoa tsc 20=20 (0 regression).
|
|
24
28
|
|
|
25
|
-
## 0.1.13
|
|
29
|
+
## 0.1.13 â Customizer: make radius, density & inset/floating actually apply
|
|
26
30
|
|
|
27
31
|
The Customizer exposed controls that stored a value but changed nothing on
|
|
28
32
|
screen. Wired them up at the source so every app using core gets them working:
|
|
29
33
|
|
|
30
|
-
- **Radius ("Bo
|
|
34
|
+
- **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
|
|
31
35
|
`--radius` token from `settings.radius`; Tailwind's `@theme inline` derives
|
|
32
|
-
`rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0
|
|
33
|
-
1rem
|
|
34
|
-
- **Density ("
|
|
36
|
+
`rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 â square,
|
|
37
|
+
1rem â fully rounded). Previously nothing read `settings.radius`.
|
|
38
|
+
- **Density ("Máºt Äá»") now applies.** `SettingsProvider` sets
|
|
35
39
|
`data-density="compact"` on `<html>`; `base.css` tightens the global
|
|
36
|
-
`--spacing` scale (0.25rem
|
|
40
|
+
`--spacing` scale (0.25rem â 0.215rem) so every spacing utility packs in
|
|
37
41
|
compact mode. Previously `useDensity()` was read nowhere.
|
|
38
42
|
- **Inset / floating sidebar no longer renders a harsh frame.** Core's default
|
|
39
43
|
sidebar is deep navy, and shadcn's inset/floating paints the page frame with
|
|
40
|
-
that sidebar colour
|
|
44
|
+
that sidebar colour â reading as a broken-looking block. The frame is now the
|
|
41
45
|
soft `--muted` neutral, so the content floats as a card on a calm page
|
|
42
46
|
(adapts to dark mode; the rounded-xl + shadow core already applies stay).
|
|
43
47
|
- **Inset / floating collapsed rail no longer looks squeezed.** In icon mode the
|
|
44
48
|
floating/inset content box is `--sidebar-width-icon + 8px` (56px) but had
|
|
45
|
-
`px-2.5` padding, leaving the pill only 36px
|
|
49
|
+
`px-2.5` padding, leaving the pill only 36px â narrower than the default 48px
|
|
46
50
|
rail. Reduced to `px-1` so the pill is a comfortable 48px with a 4px floating
|
|
47
51
|
gutter.
|
|
48
52
|
- **Inset / floating sidebar corners are softened.** The floating panel was
|
|
49
53
|
`rounded-lg` and the inset panel had NO radius (a sharp navy block whose
|
|
50
|
-
top-right corner met the header as a hard 90
|
|
54
|
+
top-right corner met the header as a hard 90°). Both now use `rounded-xl`
|
|
51
55
|
(matching the inset content card) so the sidebar reads as a soft floating
|
|
52
|
-
panel
|
|
56
|
+
panel â the header/sidebar junction is a gentle curve, not a rough corner.
|
|
53
57
|
The floating content area also rounds both left corners
|
|
54
58
|
(`rounded-l-xl`, clipping the sticky header + footer via overflow-hidden) so
|
|
55
59
|
its whole left edge hugs the floating sidebar with soft curves; the right
|
|
@@ -60,20 +64,20 @@ screen. Wired them up at the source so every app using core gets them working:
|
|
|
60
64
|
And the content no longer touches the sidebar: inset drops its `ml-0` (so the
|
|
61
65
|
uniform `m-2` leaves a left gap) and floating gains `ml-2`, giving a small
|
|
62
66
|
gap between the sidebar and the content/header.
|
|
63
|
-
- **"
|
|
64
|
-
`AppSidebar` force-remapped `offcanvas
|
|
65
|
-
icon rail instead of hiding
|
|
67
|
+
- **"Ẩn ngoà i" (`collapsible="offcanvas"`) can finally hide the sidebar.**
|
|
68
|
+
`AppSidebar` force-remapped `offcanvas â icon`, so the option collapsed to an
|
|
69
|
+
icon rail instead of hiding â identical to "Biá»u tượng" and impossible to fully
|
|
66
70
|
hide the sidebar. The choice is now honoured as-is: offcanvas slides the
|
|
67
|
-
sidebar off-screen (reopen via the header toggle), icon
|
|
68
|
-
- **Dropped the "none" (
|
|
69
|
-
the sidebar expanded made the header collapse-toggle a no-op
|
|
71
|
+
sidebar off-screen (reopen via the header toggle), icon â rail.
|
|
72
|
+
- **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
|
|
73
|
+
the sidebar expanded made the header collapse-toggle a no-op â a
|
|
70
74
|
conflicting/dead control. The picker now offers only offcanvas + icon, both of
|
|
71
75
|
which the toggle actually drives. (`none` remains a valid type for any code
|
|
72
76
|
that sets it directly; it's just no longer offered in the UI.)
|
|
73
|
-
- **Dropped the "
|
|
77
|
+
- **Dropped the "BỠcục" (layout: horizontal/vertical) control from the
|
|
74
78
|
Customizer.** The horizontal layout swaps the whole nav for a top menubar,
|
|
75
|
-
which turns every sidebar option ("
|
|
76
|
-
a no-op
|
|
79
|
+
which turns every sidebar option ("Kiá»u thanh bên", "Thu gá»n thanh bên") into
|
|
80
|
+
a no-op â the same dead-control problem. The customizer now locks to the
|
|
77
81
|
vertical sidebar the apps are designed around (`defaultSettings.layout` stays
|
|
78
82
|
`vertical`; `HorizontalLayout` is untouched for code that sets it directly).
|
|
79
83
|
- **Non-collapsing sidebar (`collapsible="none"`) is now readable.** It rendered
|
|
@@ -81,9 +85,9 @@ screen. Wired them up at the source so every app using core gets them working:
|
|
|
81
85
|
active item are `text-primary`, giving ~1.9:1 contrast (unreadable) on that
|
|
82
86
|
navy. A non-collapsing sidebar is permanently *expanded*, so it now uses the
|
|
83
87
|
expanded surface (`bg-background` / `text-foreground`), matching the rest of
|
|
84
|
-
the design (labels-on-light)
|
|
88
|
+
the design (labels-on-light) â contrast jumps to ~9:1.
|
|
85
89
|
|
|
86
|
-
## 0.1.11
|
|
90
|
+
## 0.1.11 â print: force light colours on the print surface
|
|
87
91
|
|
|
88
92
|
- **Fix faded print output in dark mode.** Print pages render under the app's
|
|
89
93
|
shared `<html class="dark">`, so the print surface inherited dark mode's light
|
|
@@ -92,34 +96,34 @@ screen. Wired them up at the source so every app using core gets them working:
|
|
|
92
96
|
`.print-container, #print-content`, keeping every shared print template
|
|
93
97
|
black-on-white regardless of the active theme.
|
|
94
98
|
|
|
95
|
-
## 0.1.8
|
|
99
|
+
## 0.1.8 â branding wiring + auth/rbac unification + clean typecheck
|
|
96
100
|
|
|
97
101
|
- **Single source for permission checks (security fix).** `@goerp/core/rbac`
|
|
98
102
|
used to ship its own copy of `checkPermission` / `getUserPermissions` /
|
|
99
|
-
`getCrudPermissionsFromSession` /
|
|
100
|
-
|
|
103
|
+
`getCrudPermissionsFromSession` / ⦠which had drifted from `@goerp/core/auth`
|
|
104
|
+
â it still allowed `BYPASS_AUTH` in production and denied admins with no
|
|
101
105
|
explicit permission rows. `rbac` now re-exports these from the hardened `auth`
|
|
102
106
|
module (bypass dev-only, admin-before-empty-guard), so the two can't diverge.
|
|
103
107
|
|
|
104
108
|
|
|
105
109
|
- **Branding via `TenantProvider`.** `Logo`/`LogoCompact` now resolve their image,
|
|
106
|
-
company name and tagline from `props
|
|
110
|
+
company name and tagline from `props â TenantProvider branding â defaults`, so a
|
|
107
111
|
new app re-skins by wrapping with `<TenantProvider tenant={{ branding }}>` (or
|
|
108
112
|
passing `logoSrc`/`companyName`/`tagline` props) instead of hardcoding. Added a
|
|
109
113
|
non-throwing `useOptionalTenant()` hook and a `tagline` field to `TenantBranding`.
|
|
110
114
|
`TenantProvider` also applies `branding.primaryColor` (an HSL triple) to
|
|
111
115
|
`--primary`/`--accent-primary` at runtime, so the brand color is config-driven
|
|
112
|
-
too. Fully backward compatible (no provider/props
|
|
116
|
+
too. Fully backward compatible (no provider/props â previous GoERP defaults).
|
|
113
117
|
- **Core now type-checks clean (0 errors).** Fixed the `system/categories`
|
|
114
118
|
components that imported the consumer alias `@goerp/core/ui` from inside the
|
|
115
|
-
package (unresolvable in core's own tsconfig
|
|
119
|
+
package (unresolvable in core's own tsconfig â cascaded into implicit-any in
|
|
116
120
|
their event handlers); switched to relative imports.
|
|
117
121
|
- **Standard entity configs.** `@goerp/core/configs/entities` now ships canonical
|
|
118
122
|
`EntityConfig`s (starting with `departmentsConfig`, plus the previously
|
|
119
123
|
unexported `materialCategoriesConfig`) so apps import + spread-to-override
|
|
120
124
|
instead of copy-pasting near-identical configs.
|
|
121
125
|
|
|
122
|
-
## 0.1.7
|
|
126
|
+
## 0.1.7 â auth hardening
|
|
123
127
|
|
|
124
128
|
Hardened `@goerp/core/auth` so the Vinh Hoa app (and others) can consolidate
|
|
125
129
|
their hand-copied RBAC clones onto core without regressing two safety behaviors:
|
|
@@ -131,46 +135,46 @@ their hand-copied RBAC clones onto core without regressing two safety behaviors:
|
|
|
131
135
|
admin-role bypass BEFORE the empty-permissions guard, so an admin who relies on
|
|
132
136
|
their role (no explicit permission rows) is no longer denied everything.
|
|
133
137
|
|
|
134
|
-
## 0.1.6
|
|
138
|
+
## 0.1.6 â platform foundation extraction
|
|
135
139
|
|
|
136
140
|
Promoted the cross-cutting platform layer out of the Vinh Hoa reference app into
|
|
137
141
|
core so every app inherits the same design system + infrastructure and only
|
|
138
|
-
builds its own business logic. All additive
|
|
142
|
+
builds its own business logic. All additive â existing subpaths are unchanged.
|
|
139
143
|
|
|
140
144
|
### Added
|
|
141
145
|
|
|
142
|
-
- **`@goerp/core/styles/base.css`**
|
|
143
|
-
scale
|
|
146
|
+
- **`@goerp/core/styles/base.css`** â the canonical design-system tokens (Radix
|
|
147
|
+
scale â shadcn semantic â Plane semantic; status, charts, sidebar, scrollbar,
|
|
144
148
|
animations). Apps `@import` this from their Tailwind v4 entry stylesheet and
|
|
145
149
|
override only brand tokens (`--primary`, `--sidebar-*`). Brand default: indigo.
|
|
146
150
|
Also ships `@goerp/core/styles/radix-themes.css` (the HSL Radix scale).
|
|
147
151
|
|
|
148
|
-
- **`@goerp/core/errors`**
|
|
149
|
-
P2002/P2025/P2003/P2014
|
|
152
|
+
- **`@goerp/core/errors`** â `AppError` + `toAppError` (maps Prisma
|
|
153
|
+
P2002/P2025/P2003/P2014 â friendly message + stable `errorId`),
|
|
150
154
|
`withErrorHandler` / `createErrorResponse` / `errorResponse`, and the
|
|
151
155
|
db-injected factories `createErrorLogger(db)` (fingerprint-deduped persistence
|
|
152
156
|
to an `error_logs` table) + `buildServerError({ logServerError })` /
|
|
153
157
|
`createServerError({ db })`. Granular client-safe paths:
|
|
154
158
|
`@goerp/core/errors/{app-error,error-handler,server-error}`.
|
|
155
159
|
|
|
156
|
-
- **`@goerp/core/export`**
|
|
160
|
+
- **`@goerp/core/export`** â `useExport()` (the unified "Xuất Excel" hook),
|
|
157
161
|
`triggerBlobDownload` / `filenameFromContentDisposition`, and `toCsv` /
|
|
158
162
|
`escapeCsvField` (RFC 4180). The crud `exportToCSV` now delegates to `toCsv`,
|
|
159
163
|
fixing unescaped commas/quotes/newlines and falsy `0`/`false`. Granular paths:
|
|
160
164
|
`@goerp/core/export/{to-csv,download-file,use-export}`.
|
|
161
165
|
|
|
162
|
-
- **`@goerp/core/print`**
|
|
166
|
+
- **`@goerp/core/print`** â `DirectPrintProvider` (document-agnostic print device
|
|
163
167
|
layer: hidden-iframe URL printing with a mobile same-tab fallback, one-page
|
|
164
168
|
sizing, loading overlay) driven by a `renderContent` render-prop, the
|
|
165
169
|
`useDirectPrint` hook, and `PrintStyles` (A4/A5 `@media print`). Granular paths:
|
|
166
170
|
`@goerp/core/print/{direct-print-provider,print-styles}`.
|
|
167
171
|
|
|
168
|
-
- **`@goerp/core/repository`**
|
|
172
|
+
- **`@goerp/core/repository`** â `casUpdate` / `casUpdateById`: the compare-and-
|
|
169
173
|
swap invariant for money/stock mutations (`updateMany` on expected state,
|
|
170
174
|
throw on zero rows so an enclosing transaction rolls back).
|
|
171
175
|
|
|
172
|
-
- **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`**
|
|
173
|
-
`serializeDecimalFields` (Prisma Decimal
|
|
176
|
+
- **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`** â
|
|
177
|
+
`serializeDecimalFields` (Prisma Decimal â string for Client Components),
|
|
174
178
|
`swrFetcher` / `swrListFetcher` / `unwrapList`, Vietnam-timezone date helpers,
|
|
175
179
|
and the CCCD (VN ID-card) QR parser.
|
|
176
180
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// ConfirmDialog — promoted from vinhhoa/wu (every app copied it). Imports from
|
|
4
|
+
// sub-barrels (not ../index) to avoid a circular ui/shared ↔ ui/index import.
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { Button } from "../primitives";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from "../feedback";
|
|
15
|
+
import { AlertTriangle, Loader2 } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
export interface ConfirmDialogProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
onConfirm: () => void;
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
confirmText?: string;
|
|
25
|
+
cancelText?: string;
|
|
26
|
+
variant?: "default" | "destructive";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ConfirmDialog({
|
|
30
|
+
open,
|
|
31
|
+
onOpenChange,
|
|
32
|
+
title,
|
|
33
|
+
description,
|
|
34
|
+
onConfirm,
|
|
35
|
+
loading = false,
|
|
36
|
+
confirmText = "Xác nhận",
|
|
37
|
+
cancelText = "Hủy",
|
|
38
|
+
variant = "destructive",
|
|
39
|
+
}: ConfirmDialogProps) {
|
|
40
|
+
return (
|
|
41
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
42
|
+
<DialogContent>
|
|
43
|
+
<DialogHeader>
|
|
44
|
+
<DialogTitle className="flex items-center gap-2">
|
|
45
|
+
{variant === "destructive" && <AlertTriangle className="h-5 w-5 text-destructive" />}
|
|
46
|
+
{title}
|
|
47
|
+
</DialogTitle>
|
|
48
|
+
<DialogDescription>{description}</DialogDescription>
|
|
49
|
+
</DialogHeader>
|
|
50
|
+
<DialogFooter>
|
|
51
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
|
52
|
+
{cancelText}
|
|
53
|
+
</Button>
|
|
54
|
+
<Button
|
|
55
|
+
variant={variant === "destructive" ? "destructive" : "default"}
|
|
56
|
+
onClick={onConfirm}
|
|
57
|
+
disabled={loading}
|
|
58
|
+
>
|
|
59
|
+
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
60
|
+
{confirmText}
|
|
61
|
+
</Button>
|
|
62
|
+
</DialogFooter>
|
|
63
|
+
</DialogContent>
|
|
64
|
+
</Dialog>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/ui/shared/index.ts
CHANGED
|
@@ -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,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
|
+
}
|