@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 CHANGED
@@ -1,55 +1,59 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.14Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
3
+ ## 0.1.15Shared UI: ConfirmDialog, StatBar, ListToolbar
4
4
 
5
- Đú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
6
- đưa vào core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toàn bộ **additive, backward-
7
- compatible** 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toàn.
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 tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
12
- Next.js tự gác quyền). App bỏ ~350 LOC engine tự chế.
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 còn ~10 LOC.
15
- - **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nhận field-map
16
- (`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) app schema
17
- khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
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 (mẫu bảng đơn bán hàng, subpath server-safe
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 lỗi; wu consume toàn bộ (tsc 0/0 + render/CRUD/auth chạy đúng);
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 Customizer: make radius, density & inset/floating actually apply
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 góc") now applies.** `SettingsProvider` drives the real
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 square,
33
- 1rem fully rounded). Previously nothing read `settings.radius`.
34
- - **Density ("Mật độ") now applies.** `SettingsProvider` sets
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 0.215rem) so every spacing utility packs in
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 reading as a broken-looking block. The frame is now the
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 narrower than the default 48px
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°). Both now use `rounded-xl`
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 the header/sidebar junction is a gentle curve, not a rough corner.
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
- - **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
64
- `AppSidebar` force-remapped `offcanvas icon`, so the option collapsed to an
65
- icon rail instead of hiding identical to "Biểu tượng" and impossible to fully
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 rail.
68
- - **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
69
- the sidebar expanded made the header collapse-toggle a no-op a
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 "Bố cục" (layout: horizontal/vertical) control from 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 ("Kiểu thanh bên", "Thu gọn thanh bên") into
76
- a no-op the same dead-control problem. The customizer now locks to the
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) contrast jumps to ~9:1.
88
+ the design (labels-on-light) — contrast jumps to ~9:1.
85
89
 
86
- ## 0.1.11 print: force light colours on the print surface
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 branding wiring + auth/rbac unification + clean typecheck
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` / which had drifted from `@goerp/core/auth`
100
- it still allowed `BYPASS_AUTH` in production and denied admins with no
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 TenantProvider branding defaults`, so a
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 previous GoERP defaults).
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 cascaded into implicit-any in
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 auth hardening
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 platform foundation extraction
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 existing subpaths are unchanged.
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`** the canonical design-system tokens (Radix
143
- scale shadcn semantic Plane semantic; status, charts, sidebar, scrollbar,
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`** `AppError` + `toAppError` (maps Prisma
149
- P2002/P2025/P2003/P2014 friendly message + stable `errorId`),
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`** `useExport()` (the unified "Xuất Excel" hook),
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`** `DirectPrintProvider` (document-agnostic print device
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`** `casUpdate` / `casUpdateById`: the compare-and-
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 string for Client Components),
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.14",
4
+ "version": "0.1.15",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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
+ }
@@ -4,3 +4,6 @@ export * from "./page-header";
4
4
  export * from "./status-indicator";
5
5
  export * from "./table-sum-footer";
6
6
  export * from "./table-styles";
7
+ export * from "./confirm-dialog";
8
+ export * from "./stat-bar";
9
+ export * from "./list-toolbar";
@@ -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
+ }