@goplusvn/core 0.1.9 → 0.1.11
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 +9 -0
- package/package.json +1 -1
- package/src/configs/themes.ts +40 -35
- package/src/crud/components/crud-card-view.tsx +1 -1
- package/src/crud/components/crud-page.tsx +6 -6
- package/src/crud/components/crud-table.tsx +6 -3
- package/src/print/print-styles.tsx +5 -0
- package/src/ui/data-display/data-table/data-table-toolbar.tsx +53 -28
- package/src/ui/data-display/data-table/data-table.tsx +29 -6
- package/src/ui/data-display/data-table-column-header.tsx +4 -4
- package/src/ui/data-display/data-table-pagination.tsx +84 -58
- package/src/ui/layout/customizer.tsx +92 -26
- package/src/ui/layout/footer.tsx +1 -1
- package/src/ui/layout/horizontal-layout-header.tsx +1 -1
- package/src/ui/layout/sidebar-group-icon-menu.tsx +15 -1
- package/src/ui/layout/sidebar.tsx +19 -5
- package/src/ui/layout/vertical-layout-header.tsx +1 -1
- package/src/ui/primitives/sidebar.tsx +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.11 — print: force light colours on the print surface
|
|
4
|
+
|
|
5
|
+
- **Fix faded print output in dark mode.** Print pages render under the app's
|
|
6
|
+
shared `<html class="dark">`, so the print surface inherited dark mode's light
|
|
7
|
+
`--foreground` and rendered as faded grey text on the white page (preview and
|
|
8
|
+
PDF alike). `PrintStyles` now pins `color: #000; color-scheme: light;` on
|
|
9
|
+
`.print-container, #print-content`, keeping every shared print template
|
|
10
|
+
black-on-white regardless of the active theme.
|
|
11
|
+
|
|
3
12
|
## 0.1.8 — branding wiring + auth/rbac unification + clean typecheck
|
|
4
13
|
|
|
5
14
|
- **Single source for permission checks (security fix).** `@goerp/core/rbac`
|
package/package.json
CHANGED
package/src/configs/themes.ts
CHANGED
|
@@ -1,100 +1,105 @@
|
|
|
1
1
|
export const radii = [0, 0.3, 0.5, 0.75, 1];
|
|
2
2
|
|
|
3
|
+
// Swatch colours are kept 1-1 with the app's applied --primary
|
|
4
|
+
// (vinhhoa: src/providers/theme-provider.tsx THEMES) so the Customizer preview
|
|
5
|
+
// shows exactly the colour that gets applied. Keep the two lists in sync.
|
|
6
|
+
// Neutrals are differentiated by lightness + warm/cool character (a tiny hue
|
|
7
|
+
// shift at low saturation is invisible).
|
|
3
8
|
export const themes = {
|
|
4
9
|
zinc: {
|
|
5
10
|
label: "Zinc",
|
|
6
11
|
activeColor: {
|
|
7
|
-
light: "
|
|
8
|
-
dark: "
|
|
12
|
+
light: "235 12% 27%",
|
|
13
|
+
dark: "235 10% 62%",
|
|
9
14
|
foreground: "0 0% 98%",
|
|
10
15
|
},
|
|
11
16
|
},
|
|
12
17
|
slate: {
|
|
13
18
|
label: "Slate",
|
|
14
19
|
activeColor: {
|
|
15
|
-
light: "
|
|
16
|
-
dark: "
|
|
17
|
-
foreground: "
|
|
20
|
+
light: "213 30% 42%",
|
|
21
|
+
dark: "213 26% 64%",
|
|
22
|
+
foreground: "0 0% 100%",
|
|
18
23
|
},
|
|
19
24
|
},
|
|
20
25
|
stone: {
|
|
21
26
|
label: "Stone",
|
|
22
27
|
activeColor: {
|
|
23
|
-
light: "
|
|
24
|
-
dark: "
|
|
25
|
-
foreground: "
|
|
28
|
+
light: "26 20% 40%",
|
|
29
|
+
dark: "28 16% 62%",
|
|
30
|
+
foreground: "0 0% 98%",
|
|
26
31
|
},
|
|
27
32
|
},
|
|
28
33
|
gray: {
|
|
29
34
|
label: "Gray",
|
|
30
35
|
activeColor: {
|
|
31
|
-
light: "
|
|
32
|
-
dark: "
|
|
33
|
-
foreground: "
|
|
36
|
+
light: "218 9% 48%",
|
|
37
|
+
dark: "218 9% 68%",
|
|
38
|
+
foreground: "0 0% 100%",
|
|
34
39
|
},
|
|
35
40
|
},
|
|
36
41
|
neutral: {
|
|
37
42
|
label: "Neutral",
|
|
38
43
|
activeColor: {
|
|
39
|
-
light: "0 0%
|
|
40
|
-
dark: "0 0%
|
|
41
|
-
foreground: "0 0%
|
|
44
|
+
light: "0 0% 42%",
|
|
45
|
+
dark: "0 0% 66%",
|
|
46
|
+
foreground: "0 0% 100%",
|
|
42
47
|
},
|
|
43
48
|
},
|
|
44
49
|
red: {
|
|
45
50
|
label: "Red",
|
|
46
51
|
activeColor: {
|
|
47
|
-
light: "
|
|
48
|
-
dark: "
|
|
49
|
-
foreground: "0
|
|
52
|
+
light: "4 78% 50%",
|
|
53
|
+
dark: "6 84% 62%",
|
|
54
|
+
foreground: "0 0% 100%",
|
|
50
55
|
},
|
|
51
56
|
},
|
|
52
57
|
rose: {
|
|
53
58
|
label: "Rose",
|
|
54
59
|
activeColor: {
|
|
55
|
-
light: "
|
|
56
|
-
dark: "
|
|
57
|
-
foreground: "
|
|
60
|
+
light: "338 80% 53%",
|
|
61
|
+
dark: "340 80% 63%",
|
|
62
|
+
foreground: "0 0% 100%",
|
|
58
63
|
},
|
|
59
64
|
},
|
|
60
65
|
orange: {
|
|
61
66
|
label: "Orange",
|
|
62
67
|
activeColor: {
|
|
63
|
-
light: "
|
|
64
|
-
dark: "
|
|
65
|
-
foreground: "
|
|
68
|
+
light: "26 88% 47%",
|
|
69
|
+
dark: "30 92% 58%",
|
|
70
|
+
foreground: "0 0% 100%",
|
|
66
71
|
},
|
|
67
72
|
},
|
|
68
73
|
green: {
|
|
69
74
|
label: "Green",
|
|
70
75
|
activeColor: {
|
|
71
|
-
light: "
|
|
72
|
-
dark: "
|
|
73
|
-
foreground: "
|
|
76
|
+
light: "151 66% 38%",
|
|
77
|
+
dark: "150 58% 48%",
|
|
78
|
+
foreground: "0 0% 100%",
|
|
74
79
|
},
|
|
75
80
|
},
|
|
76
81
|
blue: {
|
|
77
82
|
label: "Blue",
|
|
78
83
|
activeColor: {
|
|
79
|
-
light: "
|
|
80
|
-
dark: "
|
|
81
|
-
foreground: "
|
|
84
|
+
light: "233 74% 44%",
|
|
85
|
+
dark: "230 62% 60%",
|
|
86
|
+
foreground: "0 0% 100%",
|
|
82
87
|
},
|
|
83
88
|
},
|
|
84
89
|
yellow: {
|
|
85
90
|
label: "Yellow",
|
|
86
91
|
activeColor: {
|
|
87
|
-
light: "
|
|
88
|
-
dark: "
|
|
89
|
-
foreground: "
|
|
92
|
+
light: "42 92% 46%",
|
|
93
|
+
dark: "46 96% 56%",
|
|
94
|
+
foreground: "40 60% 12%",
|
|
90
95
|
},
|
|
91
96
|
},
|
|
92
97
|
violet: {
|
|
93
98
|
label: "Violet",
|
|
94
99
|
activeColor: {
|
|
95
|
-
light: "262
|
|
96
|
-
dark: "263
|
|
97
|
-
foreground: "
|
|
100
|
+
light: "262 72% 52%",
|
|
101
|
+
dark: "263 72% 64%",
|
|
102
|
+
foreground: "0 0% 100%",
|
|
98
103
|
},
|
|
99
104
|
},
|
|
100
105
|
};
|
|
@@ -140,7 +140,7 @@ export function CrudCardView<TData extends Record<string, unknown>>({
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
return (
|
|
143
|
-
<div className="space-y-4">
|
|
143
|
+
<div className="h-full overflow-auto space-y-4 pr-1">
|
|
144
144
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
145
145
|
{data.data.map((row) => {
|
|
146
146
|
const rowId = String(row[config.idField]);
|
|
@@ -734,8 +734,8 @@ function CrudPageContent({
|
|
|
734
734
|
|
|
735
735
|
return (
|
|
736
736
|
<>
|
|
737
|
-
<div className="
|
|
738
|
-
<div className="pb-2">
|
|
737
|
+
<div className="flex flex-col h-full gap-2">
|
|
738
|
+
<div className="pb-2 shrink-0">
|
|
739
739
|
{/* Header: Title + Description + Actions */}
|
|
740
740
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
741
741
|
<div className="space-y-1">
|
|
@@ -840,9 +840,9 @@ function CrudPageContent({
|
|
|
840
840
|
</div>
|
|
841
841
|
</div>
|
|
842
842
|
|
|
843
|
-
<div className="
|
|
844
|
-
{/* Toolbar Section
|
|
845
|
-
<div className="
|
|
843
|
+
<div className="flex-1 min-h-0 flex flex-col gap-2">
|
|
844
|
+
{/* Toolbar Section */}
|
|
845
|
+
<div className="shrink-0">
|
|
846
846
|
<CrudTableToolbar
|
|
847
847
|
table={tableInstance}
|
|
848
848
|
config={config}
|
|
@@ -853,7 +853,7 @@ function CrudPageContent({
|
|
|
853
853
|
</div>
|
|
854
854
|
|
|
855
855
|
{/* Table/Card Section */}
|
|
856
|
-
<div className="
|
|
856
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
857
857
|
{viewMode === "table" ? (
|
|
858
858
|
<CrudTable
|
|
859
859
|
data={data as CrudResponse<Record<string, unknown>>}
|
|
@@ -291,7 +291,7 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
291
291
|
cols.push({
|
|
292
292
|
id: "actions",
|
|
293
293
|
header: () => (
|
|
294
|
-
<div className="text-right font-semibold
|
|
294
|
+
<div className="text-right font-semibold whitespace-nowrap">
|
|
295
295
|
{translations.actions || "Actions"}
|
|
296
296
|
</div>
|
|
297
297
|
),
|
|
@@ -380,8 +380,11 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
380
380
|
}}
|
|
381
381
|
// Callbacks
|
|
382
382
|
onTableReady={onTableReady}
|
|
383
|
-
className="
|
|
384
|
-
height="
|
|
383
|
+
className="h-full"
|
|
384
|
+
height="auto"
|
|
385
|
+
tableClassName="text-sm"
|
|
386
|
+
cellClassName="text-sm"
|
|
387
|
+
headerCellClassName="text-xs py-1.5"
|
|
385
388
|
/>
|
|
386
389
|
);
|
|
387
390
|
}
|
|
@@ -71,6 +71,11 @@ export function PrintStyles({ pageSize = "A4" }: PrintStylesProps) {
|
|
|
71
71
|
padding: 20px;
|
|
72
72
|
font-family: "Times New Roman", serif;
|
|
73
73
|
background: white;
|
|
74
|
+
/* Force light document colours so the page never inherits the app's
|
|
75
|
+
dark-mode foreground (which would render as faded grey text on the
|
|
76
|
+
white print surface). Both preview and @media print stay black-on-white. */
|
|
77
|
+
color: #000;
|
|
78
|
+
color-scheme: light;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/* Base layout */
|
|
@@ -9,10 +9,14 @@ import { Button } from "../../primitives";
|
|
|
9
9
|
import { Input } from "../../primitives";
|
|
10
10
|
import { Separator } from "../../primitives";
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
Sheet,
|
|
13
|
+
SheetClose,
|
|
14
|
+
SheetContent,
|
|
15
|
+
SheetDescription,
|
|
16
|
+
SheetHeader,
|
|
17
|
+
SheetTitle,
|
|
18
|
+
SheetTrigger,
|
|
19
|
+
} from "../../feedback/sheet";
|
|
16
20
|
import { DataTableViewOptions } from "../data-table-view-options";
|
|
17
21
|
|
|
18
22
|
// ============================================================================
|
|
@@ -184,7 +188,7 @@ export function DataTableToolbar<TData>({
|
|
|
184
188
|
placeholder={searchPlaceholder}
|
|
185
189
|
value={searchValue}
|
|
186
190
|
onChange={handleSearchChange}
|
|
187
|
-
className="pl-9 pr-9 h-9 text-
|
|
191
|
+
className="pl-9 pr-9 h-9 text-sm bg-secondary/30 focus:bg-background shadow-sm focus-visible:ring-inset focus-visible:ring-offset-0"
|
|
188
192
|
/>
|
|
189
193
|
{hasActiveSearch && (
|
|
190
194
|
<button
|
|
@@ -208,8 +212,8 @@ export function DataTableToolbar<TData>({
|
|
|
208
212
|
{/* Filters */}
|
|
209
213
|
{hasFilters && (
|
|
210
214
|
<>
|
|
211
|
-
<
|
|
212
|
-
<
|
|
215
|
+
<Sheet open={filterOpen} onOpenChange={setFilterOpen}>
|
|
216
|
+
<SheetTrigger asChild>
|
|
213
217
|
<Button
|
|
214
218
|
variant={hasActiveFilters ? "default" : "outline"}
|
|
215
219
|
size="sm"
|
|
@@ -223,29 +227,50 @@ export function DataTableToolbar<TData>({
|
|
|
223
227
|
</span>
|
|
224
228
|
)}
|
|
225
229
|
</Button>
|
|
226
|
-
</
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
Xóa tất cả
|
|
240
|
-
</Button>
|
|
241
|
-
)}
|
|
242
|
-
</div>
|
|
243
|
-
<div className="space-y-4 max-h-[400px] overflow-y-auto -mx-2 px-2">
|
|
244
|
-
{filterBuilder}
|
|
230
|
+
</SheetTrigger>
|
|
231
|
+
<SheetContent
|
|
232
|
+
side="right"
|
|
233
|
+
className="flex flex-col h-full w-full sm:max-w-md p-0 gap-0"
|
|
234
|
+
>
|
|
235
|
+
<SheetHeader className="text-left px-6 py-4 border-b flex flex-row items-center justify-between space-y-0">
|
|
236
|
+
<div className="space-y-1">
|
|
237
|
+
<SheetTitle className="text-lg font-bold">
|
|
238
|
+
Bộ lọc nâng cao
|
|
239
|
+
</SheetTitle>
|
|
240
|
+
<SheetDescription>
|
|
241
|
+
Tinh chỉnh danh sách theo các tiêu chí bên dưới.
|
|
242
|
+
</SheetDescription>
|
|
245
243
|
</div>
|
|
244
|
+
<SheetClose asChild>
|
|
245
|
+
<Button
|
|
246
|
+
variant="ghost"
|
|
247
|
+
size="icon"
|
|
248
|
+
className="h-8 w-8 rounded-full shrink-0"
|
|
249
|
+
>
|
|
250
|
+
<X className="h-5 w-5" />
|
|
251
|
+
<span className="sr-only">Đóng</span>
|
|
252
|
+
</Button>
|
|
253
|
+
</SheetClose>
|
|
254
|
+
</SheetHeader>
|
|
255
|
+
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
|
|
256
|
+
{filterBuilder}
|
|
257
|
+
</div>
|
|
258
|
+
<div className="border-t px-6 py-4 flex items-center gap-2">
|
|
259
|
+
{hasActiveFilters && (
|
|
260
|
+
<Button
|
|
261
|
+
variant="outline"
|
|
262
|
+
onClick={handleClearFilters}
|
|
263
|
+
className="flex-1 text-muted-foreground hover:text-destructive"
|
|
264
|
+
>
|
|
265
|
+
<X className="mr-1 h-4 w-4" /> Xóa tất cả
|
|
266
|
+
</Button>
|
|
267
|
+
)}
|
|
268
|
+
<SheetClose asChild>
|
|
269
|
+
<Button className="flex-1">Xem kết quả</Button>
|
|
270
|
+
</SheetClose>
|
|
246
271
|
</div>
|
|
247
|
-
</
|
|
248
|
-
</
|
|
272
|
+
</SheetContent>
|
|
273
|
+
</Sheet>
|
|
249
274
|
|
|
250
275
|
{/* Reset button */}
|
|
251
276
|
{hasAnyActive && (
|
|
@@ -142,6 +142,19 @@ export interface DataTableProps<TData> {
|
|
|
142
142
|
* @default "auto"
|
|
143
143
|
*/
|
|
144
144
|
height?: "auto" | "full" | string;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Lớp CSS opt-in cho HEADER bảng (mặc định giữ "bg-card"). Dùng cho CRUD muốn
|
|
148
|
+
* header tối, vd "bg-sidebar [&_th]:text-white/90". KHÔNG ảnh hưởng nơi khác.
|
|
149
|
+
*/
|
|
150
|
+
headerClassName?: string;
|
|
151
|
+
|
|
152
|
+
/** Cỡ chữ cho <table> (mặc định "text-xs"). CRUD truyền "text-sm" cho khớp app. */
|
|
153
|
+
tableClassName?: string;
|
|
154
|
+
/** Cỡ chữ cho <th> (mặc định "text-xs"). CRUD truyền "text-sm". */
|
|
155
|
+
headerCellClassName?: string;
|
|
156
|
+
/** Cỡ chữ cho ô <td> (TableCell primitive vốn cứng "text-xs"). CRUD truyền "text-sm". */
|
|
157
|
+
cellClassName?: string;
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
// ============================================================================
|
|
@@ -167,6 +180,10 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
167
180
|
onRowClick,
|
|
168
181
|
className,
|
|
169
182
|
height = "auto",
|
|
183
|
+
headerClassName,
|
|
184
|
+
tableClassName,
|
|
185
|
+
headerCellClassName,
|
|
186
|
+
cellClassName,
|
|
170
187
|
}: DataTableProps<TData>) {
|
|
171
188
|
// Build final columns with selection and row number if enabled
|
|
172
189
|
const columns = useMemo<ColumnDef<TData>[]>(() => {
|
|
@@ -231,7 +248,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
231
248
|
cols.push({
|
|
232
249
|
id: "stt",
|
|
233
250
|
header: () => (
|
|
234
|
-
<div className="font-semibold text-center text-sm"
|
|
251
|
+
<div className="font-semibold text-center text-sm">#</div>
|
|
235
252
|
),
|
|
236
253
|
cell: ({ row }) => {
|
|
237
254
|
const rowIndex = row.index;
|
|
@@ -377,8 +394,12 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
377
394
|
>
|
|
378
395
|
{/* Table Container - Scrollable */}
|
|
379
396
|
<div className="flex-1 overflow-auto relative">
|
|
380
|
-
<table
|
|
381
|
-
|
|
397
|
+
<table
|
|
398
|
+
className={`w-max min-w-full caption-bottom ${tableClassName || "text-xs"} relative`}
|
|
399
|
+
>
|
|
400
|
+
<TableHeader
|
|
401
|
+
className={`sticky top-0 z-20 ${headerClassName || "bg-card"}`}
|
|
402
|
+
>
|
|
382
403
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
383
404
|
<TableRow
|
|
384
405
|
key={headerGroup.id}
|
|
@@ -387,7 +408,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
387
408
|
{headerGroup.headers.map((header) => (
|
|
388
409
|
<TableHead
|
|
389
410
|
key={header.id}
|
|
390
|
-
className="text-xs font-semibold text-foreground
|
|
411
|
+
className={`${headerCellClassName || "text-xs py-2.5"} font-semibold text-foreground`}
|
|
391
412
|
style={{
|
|
392
413
|
width: header.getSize(),
|
|
393
414
|
minWidth: header.column.columnDef.minSize,
|
|
@@ -428,6 +449,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
428
449
|
isSelected={row.getIsSelected()}
|
|
429
450
|
visibleCellsCount={row.getVisibleCells().length}
|
|
430
451
|
onRowClick={onRowClick}
|
|
452
|
+
cellClassName={cellClassName}
|
|
431
453
|
/>
|
|
432
454
|
))
|
|
433
455
|
) : null}
|
|
@@ -481,12 +503,13 @@ interface MemoizedTableRowProps<TData> {
|
|
|
481
503
|
isSelected: boolean;
|
|
482
504
|
visibleCellsCount: number;
|
|
483
505
|
onRowClick?: (row: TData) => void;
|
|
506
|
+
cellClassName?: string;
|
|
484
507
|
}
|
|
485
508
|
|
|
486
509
|
// Ensure TData is maintained by making it a generic memo component, or casting it
|
|
487
510
|
const MemoizedTableRow = memo(
|
|
488
511
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
489
|
-
({ row, isSelected, onRowClick }: MemoizedTableRowProps<any>) => {
|
|
512
|
+
({ row, isSelected, onRowClick, cellClassName }: MemoizedTableRowProps<any>) => {
|
|
490
513
|
return (
|
|
491
514
|
<TableRow
|
|
492
515
|
data-state={isSelected && "selected"}
|
|
@@ -498,7 +521,7 @@ const MemoizedTableRow = memo(
|
|
|
498
521
|
{row.getVisibleCells().map((cell) => (
|
|
499
522
|
<TableCell
|
|
500
523
|
key={cell.id}
|
|
501
|
-
className=""
|
|
524
|
+
className={cellClassName || ""}
|
|
502
525
|
style={{
|
|
503
526
|
width: cell.column.getSize(),
|
|
504
527
|
minWidth: cell.column.columnDef.minSize,
|
|
@@ -42,15 +42,15 @@ export function DataTableColumnHeader<TData, TValue>({
|
|
|
42
42
|
<Button
|
|
43
43
|
variant="ghost"
|
|
44
44
|
size="sm"
|
|
45
|
-
className="-ml-3 h-
|
|
45
|
+
className="-ml-3 h-6 px-2 data-[state=open]:bg-accent"
|
|
46
46
|
>
|
|
47
47
|
<span>{title}</span>
|
|
48
48
|
{column.getIsSorted() === "desc" ? (
|
|
49
|
-
<ArrowDown className="ml-
|
|
49
|
+
<ArrowDown className="ml-1.5 h-3.5 w-3.5" />
|
|
50
50
|
) : column.getIsSorted() === "asc" ? (
|
|
51
|
-
<ArrowUp className="ml-
|
|
51
|
+
<ArrowUp className="ml-1.5 h-3.5 w-3.5" />
|
|
52
52
|
) : (
|
|
53
|
-
<ArrowDownUp className="ml-
|
|
53
|
+
<ArrowDownUp className="ml-1.5 h-3.5 w-3.5" />
|
|
54
54
|
)}
|
|
55
55
|
</Button>
|
|
56
56
|
</DropdownMenuTrigger>
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// @goerp/core/ui/data-display
|
|
2
2
|
// DataTablePagination component for table pagination
|
|
3
|
+
// Style đồng bộ với shared Pagination của app (trang đơn bán hàng):
|
|
4
|
+
// "Hiển thị X - Y trong tổng số Z mục" · Số dòng · Trang [nhập] / N, nút h-6 w-6.
|
|
3
5
|
|
|
4
6
|
"use client";
|
|
5
7
|
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
6
9
|
import {
|
|
7
10
|
ChevronLeft,
|
|
8
11
|
ChevronRight,
|
|
@@ -12,7 +15,7 @@ import {
|
|
|
12
15
|
|
|
13
16
|
import type { Table } from "@tanstack/react-table";
|
|
14
17
|
|
|
15
|
-
import { Button
|
|
18
|
+
import { Button } from "../primitives";
|
|
16
19
|
import {
|
|
17
20
|
Select,
|
|
18
21
|
SelectContent,
|
|
@@ -34,94 +37,117 @@ export function DataTablePagination<TData>({
|
|
|
34
37
|
currentPage,
|
|
35
38
|
pageSize,
|
|
36
39
|
}: DataTablePaginationProps<TData>) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
: table.getState().pagination.pageIndex *
|
|
41
|
-
table.getState().pagination.pageSize +
|
|
42
|
-
1;
|
|
43
|
-
const endItem =
|
|
44
|
-
totalItems && currentPage && pageSize
|
|
45
|
-
? Math.min(currentPage * pageSize, totalItems)
|
|
46
|
-
: Math.min(
|
|
47
|
-
(table.getState().pagination.pageIndex + 1) *
|
|
48
|
-
table.getState().pagination.pageSize,
|
|
49
|
-
table.getFilteredRowModel().rows.length,
|
|
50
|
-
);
|
|
40
|
+
const state = table.getState().pagination;
|
|
41
|
+
const effPageSize = pageSize ?? state.pageSize;
|
|
42
|
+
const effCurrentPage = currentPage ?? state.pageIndex + 1;
|
|
51
43
|
const total = totalItems ?? table.getFilteredRowModel().rows.length;
|
|
44
|
+
const pageCount = Math.max(1, Math.ceil(total / effPageSize));
|
|
45
|
+
const startItem = total > 0 ? (effCurrentPage - 1) * effPageSize + 1 : 0;
|
|
46
|
+
const endItem = total > 0 ? Math.min(effCurrentPage * effPageSize, total) : 0;
|
|
47
|
+
|
|
48
|
+
const [inputPage, setInputPage] = useState(String(effCurrentPage));
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
setInputPage(String(effCurrentPage));
|
|
51
|
+
}, [effCurrentPage]);
|
|
52
|
+
|
|
53
|
+
const goToPage = (page: number) => {
|
|
54
|
+
if (page < 1 || page > pageCount) return;
|
|
55
|
+
table.setPageIndex(page - 1);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handlePageInputBlur = () => {
|
|
59
|
+
const n = Number(inputPage);
|
|
60
|
+
if (isNaN(n) || n < 1 || n > pageCount) {
|
|
61
|
+
setInputPage(String(effCurrentPage));
|
|
62
|
+
} else if (n !== effCurrentPage) {
|
|
63
|
+
goToPage(n);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
52
66
|
|
|
53
67
|
return (
|
|
54
|
-
<div className="flex flex-col items-center justify-between gap-2 py-
|
|
55
|
-
<div className="flex-1 text-
|
|
56
|
-
{
|
|
57
|
-
<span>Không có dữ liệu</span>
|
|
58
|
-
) : (
|
|
59
|
-
<span>
|
|
60
|
-
Hiển thị {startItem} - {endItem} trong tổng số {total} mục
|
|
61
|
-
</span>
|
|
62
|
-
)}
|
|
68
|
+
<div className="flex flex-col sm:flex-row items-center justify-between gap-1 sm:gap-2 py-0.5 px-1 sm:px-2">
|
|
69
|
+
<div className="hidden sm:block flex-1 text-xs text-left text-muted-foreground w-full">
|
|
70
|
+
Hiển thị {startItem} - {endItem} trong tổng số {total} mục
|
|
63
71
|
</div>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
|
|
73
|
+
<div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto pb-1 sm:pb-0">
|
|
74
|
+
<div className="flex items-center gap-1 sm:gap-2 px-1 sm:px-2 border-r pr-1 sm:pr-2">
|
|
75
|
+
<p className="hidden sm:block text-xs font-medium whitespace-nowrap">
|
|
76
|
+
Số dòng
|
|
77
|
+
</p>
|
|
67
78
|
<Select
|
|
68
|
-
value={`${
|
|
69
|
-
onValueChange={(value) =>
|
|
70
|
-
table.setPageSize(Number(value));
|
|
71
|
-
}}
|
|
79
|
+
value={`${effPageSize}`}
|
|
80
|
+
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
72
81
|
>
|
|
73
|
-
<SelectTrigger className="h-
|
|
74
|
-
<SelectValue placeholder={
|
|
82
|
+
<SelectTrigger className="h-6 w-[65px] sm:w-[75px] text-xs focus:ring-inset focus:ring-offset-0">
|
|
83
|
+
<SelectValue placeholder={effPageSize} />
|
|
75
84
|
</SelectTrigger>
|
|
76
85
|
<SelectContent side="top">
|
|
77
|
-
{[10, 20,
|
|
78
|
-
<SelectItem key={
|
|
79
|
-
{
|
|
86
|
+
{[10, 20, 50, 100, 500, 1000].map((size) => (
|
|
87
|
+
<SelectItem key={size} value={`${size}`}>
|
|
88
|
+
{size}
|
|
80
89
|
</SelectItem>
|
|
81
90
|
))}
|
|
82
91
|
</SelectContent>
|
|
83
92
|
</Select>
|
|
84
93
|
</div>
|
|
85
|
-
|
|
94
|
+
|
|
95
|
+
<div className="flex items-center gap-1 pl-1">
|
|
86
96
|
<Button
|
|
87
97
|
variant="outline"
|
|
88
|
-
className="hidden h-
|
|
89
|
-
onClick={() =>
|
|
90
|
-
disabled={
|
|
98
|
+
className="hidden h-6 w-6 p-0 lg:flex"
|
|
99
|
+
onClick={() => goToPage(1)}
|
|
100
|
+
disabled={effCurrentPage <= 1 || total === 0}
|
|
91
101
|
>
|
|
92
|
-
<span className="sr-only"
|
|
93
|
-
<ChevronsLeft className="h-
|
|
102
|
+
<span className="sr-only">Trang đầu</span>
|
|
103
|
+
<ChevronsLeft className="h-3 w-3" />
|
|
94
104
|
</Button>
|
|
95
105
|
<Button
|
|
96
106
|
variant="outline"
|
|
97
|
-
className="h-
|
|
98
|
-
onClick={() =>
|
|
99
|
-
disabled={
|
|
107
|
+
className="h-6 w-6 p-0"
|
|
108
|
+
onClick={() => goToPage(effCurrentPage - 1)}
|
|
109
|
+
disabled={effCurrentPage <= 1 || total === 0}
|
|
100
110
|
>
|
|
101
111
|
<span className="sr-only">Trang trước</span>
|
|
102
|
-
<ChevronLeft className="h-
|
|
112
|
+
<ChevronLeft className="h-3 w-3" />
|
|
103
113
|
</Button>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
|
|
115
|
+
<div className="flex items-center gap-1 sm:gap-2 px-1">
|
|
116
|
+
<span className="hidden sm:inline text-xs font-medium whitespace-nowrap">
|
|
117
|
+
Trang
|
|
118
|
+
</span>
|
|
119
|
+
<input
|
|
120
|
+
className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-xs text-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
121
|
+
value={inputPage}
|
|
122
|
+
onChange={(e) => setInputPage(e.target.value)}
|
|
123
|
+
onBlur={handlePageInputBlur}
|
|
124
|
+
onKeyDown={(e) => {
|
|
125
|
+
if (e.key === "Enter") handlePageInputBlur();
|
|
126
|
+
}}
|
|
127
|
+
disabled={total === 0}
|
|
128
|
+
/>
|
|
129
|
+
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
|
130
|
+
/ {pageCount}
|
|
131
|
+
</span>
|
|
107
132
|
</div>
|
|
133
|
+
|
|
108
134
|
<Button
|
|
109
135
|
variant="outline"
|
|
110
|
-
className="h-
|
|
111
|
-
onClick={() =>
|
|
112
|
-
disabled={
|
|
136
|
+
className="h-6 w-6 p-0"
|
|
137
|
+
onClick={() => goToPage(effCurrentPage + 1)}
|
|
138
|
+
disabled={effCurrentPage >= pageCount || total === 0}
|
|
113
139
|
>
|
|
114
140
|
<span className="sr-only">Trang sau</span>
|
|
115
|
-
<ChevronRight className="h-
|
|
141
|
+
<ChevronRight className="h-3 w-3" />
|
|
116
142
|
</Button>
|
|
117
143
|
<Button
|
|
118
144
|
variant="outline"
|
|
119
|
-
className="hidden h-
|
|
120
|
-
onClick={() =>
|
|
121
|
-
disabled={
|
|
145
|
+
className="hidden h-6 w-6 p-0 lg:flex"
|
|
146
|
+
onClick={() => goToPage(pageCount)}
|
|
147
|
+
disabled={effCurrentPage >= pageCount || total === 0}
|
|
122
148
|
>
|
|
123
|
-
<span className="sr-only"
|
|
124
|
-
<ChevronsRight className="h-
|
|
149
|
+
<span className="sr-only">Trang cuối</span>
|
|
150
|
+
<ChevronsRight className="h-3 w-3" />
|
|
125
151
|
</Button>
|
|
126
152
|
</div>
|
|
127
153
|
</div>
|
|
@@ -55,6 +55,64 @@ const sidebarCollapsibleOptions: SidebarCollapsibleType[] = [
|
|
|
55
55
|
];
|
|
56
56
|
const densityOptions: DensityType[] = ["comfortable", "compact"];
|
|
57
57
|
|
|
58
|
+
// Localized labels — the customizer follows the active URL locale (params.lang)
|
|
59
|
+
// so the panel matches the system language instead of always rendering English.
|
|
60
|
+
// Falls back to English for any locale/key not defined here.
|
|
61
|
+
const CUSTOMIZER_LABELS: Record<string, Record<string, string>> = {
|
|
62
|
+
en: {
|
|
63
|
+
title: "Customizer",
|
|
64
|
+
description: "Pick a style and color for the dashboard.",
|
|
65
|
+
color: "Color",
|
|
66
|
+
radius: "Radius",
|
|
67
|
+
mode: "Mode",
|
|
68
|
+
light: "Light",
|
|
69
|
+
dark: "Dark",
|
|
70
|
+
system: "System",
|
|
71
|
+
layout: "Layout",
|
|
72
|
+
horizontal: "Horizontal",
|
|
73
|
+
vertical: "Vertical",
|
|
74
|
+
sidebarVariant: "Sidebar Variant",
|
|
75
|
+
sidebarCollapsible: "Sidebar Collapsible",
|
|
76
|
+
density: "Density",
|
|
77
|
+
language: "Language",
|
|
78
|
+
reset: "Reset",
|
|
79
|
+
"variant.sidebar": "Sidebar",
|
|
80
|
+
"variant.floating": "Floating",
|
|
81
|
+
"variant.inset": "Inset",
|
|
82
|
+
"collapsible.offcanvas": "Offcanvas",
|
|
83
|
+
"collapsible.icon": "Icon",
|
|
84
|
+
"collapsible.none": "None",
|
|
85
|
+
"density.comfortable": "Comfortable",
|
|
86
|
+
"density.compact": "Compact",
|
|
87
|
+
},
|
|
88
|
+
vi: {
|
|
89
|
+
title: "Tùy chỉnh giao diện",
|
|
90
|
+
description: "Chọn kiểu hiển thị và màu sắc cho bảng điều khiển.",
|
|
91
|
+
color: "Màu sắc",
|
|
92
|
+
radius: "Bo góc",
|
|
93
|
+
mode: "Chế độ hiển thị",
|
|
94
|
+
light: "Sáng",
|
|
95
|
+
dark: "Tối",
|
|
96
|
+
system: "Theo hệ thống",
|
|
97
|
+
layout: "Bố cục",
|
|
98
|
+
horizontal: "Ngang",
|
|
99
|
+
vertical: "Dọc",
|
|
100
|
+
sidebarVariant: "Kiểu thanh bên",
|
|
101
|
+
sidebarCollapsible: "Thu gọn thanh bên",
|
|
102
|
+
density: "Mật độ",
|
|
103
|
+
language: "Ngôn ngữ",
|
|
104
|
+
reset: "Đặt lại mặc định",
|
|
105
|
+
"variant.sidebar": "Cố định",
|
|
106
|
+
"variant.floating": "Nổi",
|
|
107
|
+
"variant.inset": "Thụt vào",
|
|
108
|
+
"collapsible.offcanvas": "Ẩn ngoài",
|
|
109
|
+
"collapsible.icon": "Biểu tượng",
|
|
110
|
+
"collapsible.none": "Tắt",
|
|
111
|
+
"density.comfortable": "Thoáng",
|
|
112
|
+
"density.compact": "Gọn",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
58
116
|
export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
59
117
|
const { settings, updateSettings, resetSettings } = useSettings();
|
|
60
118
|
const pathname = usePathname();
|
|
@@ -63,6 +121,13 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
63
121
|
|
|
64
122
|
const locale = params.lang as LocaleType;
|
|
65
123
|
|
|
124
|
+
// Translate a label key by the active locale, falling back to English.
|
|
125
|
+
const t = useCallback(
|
|
126
|
+
(key: string) =>
|
|
127
|
+
CUSTOMIZER_LABELS[locale]?.[key] ?? CUSTOMIZER_LABELS.en[key] ?? key,
|
|
128
|
+
[locale],
|
|
129
|
+
);
|
|
130
|
+
|
|
66
131
|
const handleSetLocale = useCallback(
|
|
67
132
|
(localeName: LocaleType) => {
|
|
68
133
|
// Logic for locale set
|
|
@@ -81,8 +146,8 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
81
146
|
|
|
82
147
|
const handleReset = useCallback(() => {
|
|
83
148
|
resetSettings();
|
|
84
|
-
router.push(relocalizePathname(pathname,
|
|
85
|
-
}, [resetSettings, router, pathname]);
|
|
149
|
+
router.push(relocalizePathname(pathname, locale), { scroll: false });
|
|
150
|
+
}, [resetSettings, router, pathname, locale]);
|
|
86
151
|
|
|
87
152
|
return (
|
|
88
153
|
<Sheet>
|
|
@@ -92,13 +157,11 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
92
157
|
<ScrollArea className="h-full p-4">
|
|
93
158
|
<div className="flex flex-1 flex-col space-y-4">
|
|
94
159
|
<SheetHeader>
|
|
95
|
-
<SheetTitle>
|
|
96
|
-
<SheetDescription>
|
|
97
|
-
Pick a style and color for the dashboard.
|
|
98
|
-
</SheetDescription>
|
|
160
|
+
<SheetTitle>{t("title")}</SheetTitle>
|
|
161
|
+
<SheetDescription>{t("description")}</SheetDescription>
|
|
99
162
|
</SheetHeader>
|
|
100
163
|
<div className="space-y-1.5">
|
|
101
|
-
<p className="text-sm">
|
|
164
|
+
<p className="text-sm">{t("color")}</p>
|
|
102
165
|
<div className="grid grid-cols-3 gap-2">
|
|
103
166
|
{Object.entries(themes).map(([name, value]) => {
|
|
104
167
|
const isActive = settings.theme === name;
|
|
@@ -131,7 +194,7 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
131
194
|
</div>
|
|
132
195
|
</div>
|
|
133
196
|
<div className="space-y-1.5">
|
|
134
|
-
<p className="text-sm">
|
|
197
|
+
<p className="text-sm">{t("radius")}</p>
|
|
135
198
|
<div className="grid grid-cols-5 gap-2">
|
|
136
199
|
{radii.map((value) => (
|
|
137
200
|
<Button
|
|
@@ -152,36 +215,39 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
152
215
|
</div>
|
|
153
216
|
</div>
|
|
154
217
|
<div className="space-y-1.5">
|
|
155
|
-
<p className="text-sm">
|
|
218
|
+
<p className="text-sm">{t("mode")}</p>
|
|
156
219
|
<div className="grid grid-cols-3 gap-2">
|
|
157
220
|
<Button
|
|
158
221
|
variant={
|
|
159
222
|
settings.mode === "light" ? "secondary" : "outline"
|
|
160
223
|
}
|
|
161
224
|
onClick={() => handleSetMode("light")}
|
|
225
|
+
title={t("light")}
|
|
226
|
+
aria-label={t("light")}
|
|
162
227
|
>
|
|
163
|
-
<Sun className="shrink-0 h-4 w-4
|
|
164
|
-
Light
|
|
228
|
+
<Sun className="shrink-0 h-4 w-4" />
|
|
165
229
|
</Button>
|
|
166
230
|
<Button
|
|
167
231
|
variant={settings.mode === "dark" ? "secondary" : "outline"}
|
|
168
232
|
onClick={() => handleSetMode("dark")}
|
|
233
|
+
title={t("dark")}
|
|
234
|
+
aria-label={t("dark")}
|
|
169
235
|
>
|
|
170
|
-
<MoonStar className="shrink-0 h-4 w-4
|
|
171
|
-
Dark
|
|
236
|
+
<MoonStar className="shrink-0 h-4 w-4" />
|
|
172
237
|
</Button>
|
|
173
238
|
<Button
|
|
174
239
|
variant={
|
|
175
240
|
settings.mode === "system" ? "secondary" : "outline"
|
|
176
241
|
}
|
|
177
242
|
onClick={() => handleSetMode("system")}
|
|
243
|
+
title={t("system")}
|
|
244
|
+
aria-label={t("system")}
|
|
178
245
|
>
|
|
179
|
-
<SunMoon className="shrink-0 h-4 w-4
|
|
180
|
-
System
|
|
246
|
+
<SunMoon className="shrink-0 h-4 w-4" />
|
|
181
247
|
</Button>
|
|
182
248
|
</div>
|
|
183
249
|
<div className="space-y-1.5">
|
|
184
|
-
<span className="text-sm">
|
|
250
|
+
<span className="text-sm">{t("layout")}</span>
|
|
185
251
|
<div className="grid grid-cols-2 gap-2">
|
|
186
252
|
<Button
|
|
187
253
|
variant={
|
|
@@ -197,7 +263,7 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
197
263
|
}
|
|
198
264
|
>
|
|
199
265
|
<AlignStartHorizontal className="shrink-0 h-4 w-4 me-2" />
|
|
200
|
-
|
|
266
|
+
{t("horizontal")}
|
|
201
267
|
</Button>
|
|
202
268
|
<Button
|
|
203
269
|
variant={
|
|
@@ -211,13 +277,13 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
211
277
|
}
|
|
212
278
|
>
|
|
213
279
|
<AlignStartVertical className="shrink-0 h-4 w-4 me-2" />
|
|
214
|
-
|
|
280
|
+
{t("vertical")}
|
|
215
281
|
</Button>
|
|
216
282
|
</div>
|
|
217
283
|
</div>
|
|
218
284
|
|
|
219
285
|
<div className="space-y-1.5">
|
|
220
|
-
<span className="text-sm">
|
|
286
|
+
<span className="text-sm">{t("sidebarVariant")}</span>
|
|
221
287
|
<div className="grid grid-cols-3 gap-2">
|
|
222
288
|
{sidebarVariants.map((variant) => (
|
|
223
289
|
<Button
|
|
@@ -234,14 +300,14 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
234
300
|
})
|
|
235
301
|
}
|
|
236
302
|
>
|
|
237
|
-
{
|
|
303
|
+
{t(`variant.${variant}`)}
|
|
238
304
|
</Button>
|
|
239
305
|
))}
|
|
240
306
|
</div>
|
|
241
307
|
</div>
|
|
242
308
|
|
|
243
309
|
<div className="space-y-1.5">
|
|
244
|
-
<span className="text-sm">
|
|
310
|
+
<span className="text-sm">{t("sidebarCollapsible")}</span>
|
|
245
311
|
<div className="grid grid-cols-3 gap-2">
|
|
246
312
|
{sidebarCollapsibleOptions.map((option) => (
|
|
247
313
|
<Button
|
|
@@ -258,14 +324,14 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
258
324
|
})
|
|
259
325
|
}
|
|
260
326
|
>
|
|
261
|
-
{
|
|
327
|
+
{t(`collapsible.${option}`)}
|
|
262
328
|
</Button>
|
|
263
329
|
))}
|
|
264
330
|
</div>
|
|
265
331
|
</div>
|
|
266
332
|
|
|
267
333
|
<div className="space-y-1.5">
|
|
268
|
-
<span className="text-sm">
|
|
334
|
+
<span className="text-sm">{t("density")}</span>
|
|
269
335
|
<div className="grid grid-cols-2 gap-2">
|
|
270
336
|
{densityOptions.map((density) => (
|
|
271
337
|
<Button
|
|
@@ -280,14 +346,14 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
280
346
|
})
|
|
281
347
|
}
|
|
282
348
|
>
|
|
283
|
-
{
|
|
349
|
+
{t(`density.${density}`)}
|
|
284
350
|
</Button>
|
|
285
351
|
))}
|
|
286
352
|
</div>
|
|
287
353
|
</div>
|
|
288
354
|
|
|
289
355
|
<div className="space-y-1.5">
|
|
290
|
-
<span className="text-sm">
|
|
356
|
+
<span className="text-sm">{t("language")}</span>
|
|
291
357
|
<div className="grid grid-cols-2 gap-2">
|
|
292
358
|
<Button
|
|
293
359
|
variant={locale === "vi" ? "secondary" : "outline"}
|
|
@@ -313,7 +379,7 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
313
379
|
onClick={handleReset}
|
|
314
380
|
>
|
|
315
381
|
<RotateCcw className="shrink-0 h-4 w-4 me-2" />
|
|
316
|
-
|
|
382
|
+
{t("reset")}
|
|
317
383
|
</Button>
|
|
318
384
|
</div>
|
|
319
385
|
</ScrollArea>
|
package/src/ui/layout/footer.tsx
CHANGED
|
@@ -5,7 +5,7 @@ export function Footer() {
|
|
|
5
5
|
const currentYear = new Date().getFullYear();
|
|
6
6
|
|
|
7
7
|
return (
|
|
8
|
-
<footer className="bg-background border-t border-
|
|
8
|
+
<footer className="bg-background border-t border-border/50 overflow-x-hidden">
|
|
9
9
|
<div className="container flex justify-between items-center py-1 px-4 md:px-6">
|
|
10
10
|
<p className="text-xs text-muted-foreground">
|
|
11
11
|
© {currentYear}{" "}
|
|
@@ -32,7 +32,7 @@ export function HorizontalLayoutHeader({
|
|
|
32
32
|
|
|
33
33
|
return (
|
|
34
34
|
<>
|
|
35
|
-
<header className="sticky top-0 z-50 w-full bg-background/95 border-b border-
|
|
35
|
+
<header className="sticky top-0 z-50 w-full bg-background/95 border-b border-border/50 backdrop-blur supports-[backdrop-filter]:bg-background/80 flex flex-col">
|
|
36
36
|
<div className="container flex flex-wrap items-center gap-3 py-0 lg:h-12">
|
|
37
37
|
<div className="flex items-center gap-2">
|
|
38
38
|
<ToggleMobileSidebar />
|
|
@@ -54,6 +54,20 @@ export function SidebarGroupIconMenu({
|
|
|
54
54
|
[items],
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
+
// "Longest match wins" trong nhóm: chỉ mục khớp SÂU NHẤT với URL mới active,
|
|
58
|
+
// tránh cha + con cùng sáng (vd /purchase-orders và /purchase-orders/config).
|
|
59
|
+
const collectHrefs = (list: any[]): string[] =>
|
|
60
|
+
(list || []).flatMap((it: any) =>
|
|
61
|
+
it?.items
|
|
62
|
+
? collectHrefs(it.items)
|
|
63
|
+
: it?.href
|
|
64
|
+
? [ensureLocalizedPathname(it.href, locale)]
|
|
65
|
+
: [],
|
|
66
|
+
);
|
|
67
|
+
const activeHref = collectHrefs(items as any[])
|
|
68
|
+
.filter((href) => isActivePathname(href, pathname))
|
|
69
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
70
|
+
|
|
57
71
|
const renderMenuItem = (
|
|
58
72
|
item: NavigationRootItem | NavigationNestedItem,
|
|
59
73
|
level: number = 0,
|
|
@@ -98,7 +112,7 @@ export function SidebarGroupIconMenu({
|
|
|
98
112
|
// Handle regular link items
|
|
99
113
|
if ("href" in item && item.href) {
|
|
100
114
|
const localizedPathname = ensureLocalizedPathname(item.href, locale);
|
|
101
|
-
const isActive =
|
|
115
|
+
const isActive = localizedPathname === activeHref;
|
|
102
116
|
const isCrudLink = localizedPathname.startsWith("/crud/");
|
|
103
117
|
const crudEntity = isCrudLink
|
|
104
118
|
? localizedPathname.replace("/crud/", "").split("/")[0]
|
|
@@ -80,6 +80,22 @@ export function AppSidebar({
|
|
|
80
80
|
// If the layout is horizontal and not on mobile, don't render the sidebar. (We use a menubar for horizontal layout navigation.)
|
|
81
81
|
if (isHoizontalAndDesktop) return null;
|
|
82
82
|
|
|
83
|
+
// "Longest match wins": với một URL, chỉ mục có href khớp SÂU NHẤT mới active.
|
|
84
|
+
// Tránh việc mục cha (vd /purchase-orders) vẫn sáng khi đang ở trang con có
|
|
85
|
+
// menu riêng (vd /purchase-orders/config) — cả 2 cùng active. Mục cha vẫn sáng
|
|
86
|
+
// ở trang chi tiết KHÔNG có menu riêng (vd /purchase-orders/[id]).
|
|
87
|
+
const collectHrefs = (items: any[]): string[] =>
|
|
88
|
+
(items || []).flatMap((it: any) =>
|
|
89
|
+
it?.items
|
|
90
|
+
? collectHrefs(it.items)
|
|
91
|
+
: it?.href
|
|
92
|
+
? [ensureLocalizedPathname(it.href, locale)]
|
|
93
|
+
: [],
|
|
94
|
+
);
|
|
95
|
+
const activeHref = collectHrefs(navData as any[])
|
|
96
|
+
.filter((href) => isActivePathname(href, pathname))
|
|
97
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
98
|
+
|
|
83
99
|
const renderMenuItem = (
|
|
84
100
|
item: NavigationRootItem | NavigationNestedItem,
|
|
85
101
|
isSub = false,
|
|
@@ -133,11 +149,9 @@ export function AppSidebar({
|
|
|
133
149
|
item.title === "Tổng quan" ||
|
|
134
150
|
item.title === "Overview" ||
|
|
135
151
|
item.title === "Dashboard";
|
|
136
|
-
const isActive =
|
|
137
|
-
localizedPathname,
|
|
138
|
-
|
|
139
|
-
isExactMatchRequired,
|
|
140
|
-
);
|
|
152
|
+
const isActive = isExactMatchRequired
|
|
153
|
+
? isActivePathname(localizedPathname, pathname, true)
|
|
154
|
+
: localizedPathname === activeHref;
|
|
141
155
|
|
|
142
156
|
// ✅ Check if this is a CRUD link for prefetching
|
|
143
157
|
const isCrudLink = localizedPathname.startsWith("/crud/");
|
|
@@ -24,7 +24,7 @@ export function VerticalLayoutHeader({
|
|
|
24
24
|
const locale = params.lang as LocaleType;
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
|
-
<header className="sticky top-0 z-50 w-full bg-background/95 border-b border-
|
|
27
|
+
<header className="sticky top-0 z-50 w-full bg-background/95 border-b border-border/50 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
|
28
28
|
<div className="container flex h-11 justify-between items-center gap-0">
|
|
29
29
|
{/* Left: sidebar toggles */}
|
|
30
30
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
@@ -307,9 +307,9 @@ const Sidebar = React.forwardRef<
|
|
|
307
307
|
className={cn(
|
|
308
308
|
"flex h-full w-full flex-col border-r transition-colors duration-200",
|
|
309
309
|
// Blue background when collapsed, white when expanded/hover
|
|
310
|
-
"group-data-[state=collapsed]:bg-sidebar group-data-[state=collapsed]:border-
|
|
311
|
-
"group-data-[state=expanded]:bg-background group-data-[state=expanded]:border-border",
|
|
312
|
-
"group-data-[hover-expanded=true]:!bg-background group-data-[hover-expanded=true]:!border-border",
|
|
310
|
+
"group-data-[state=collapsed]:bg-sidebar group-data-[state=collapsed]:border-border/50",
|
|
311
|
+
"group-data-[state=expanded]:bg-background group-data-[state=expanded]:border-border/50",
|
|
312
|
+
"group-data-[hover-expanded=true]:!bg-background group-data-[hover-expanded=true]:!border-border/50",
|
|
313
313
|
"group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow",
|
|
314
314
|
)}
|
|
315
315
|
>
|