@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 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
@@ -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.9",
4
+ "version": "0.1.11",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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: "240 5.9% 10%",
8
- dark: "240 5.2% 33.9%",
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: "215.4 16.3% 46.9%",
16
- dark: "215.3 19.3% 34.5%",
17
- foreground: "210 40% 98%",
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: "25 5.3% 44.7%",
24
- dark: "33.3 5.5% 32.4%",
25
- foreground: "60 9.1% 97.8%",
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: "220 8.9% 46.1%",
32
- dark: "215 13.8% 34.1%",
33
- foreground: "210 20% 98%",
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% 45.1%",
40
- dark: "0 0% 32.2%",
41
- foreground: "0 0% 98%",
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: "0 72.2% 50.6%",
48
- dark: "0 72.2% 50.6%",
49
- foreground: "0 85.7% 97.3%",
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: "346.8 77.2% 49.8%",
56
- dark: "346.8 77.2% 49.8%",
57
- foreground: "355.7 100% 97.3%",
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: "24.6 95% 53.1%",
64
- dark: "20.5 90.2% 48.2%",
65
- foreground: "60 9.1% 97.8%",
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: "142.1 76.2% 36.3%",
72
- dark: "142.1 70.6% 45.3%",
73
- foreground: "355.7 100% 97.3%",
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: "221.2 83.2% 53.3%",
80
- dark: "217.2 91.2% 59.8%",
81
- foreground: "210 40% 98%",
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: "47.9 95.8% 53.1%",
88
- dark: "47.9 95.8% 53.1%",
89
- foreground: "26 83.3% 14.1%",
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.1 83.3% 57.8%",
96
- dark: "263.4 70% 50.4%",
97
- foreground: "210 20% 98%",
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="space-y-3">
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="space-y-3">
844
- {/* Toolbar Section - Sticky when scrolling */}
845
- <div className="sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 pt-0 border-b border-border/50 transition-all duration-200">
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="overflow-hidden">
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 text-xs">
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="min-h-[calc(100vh-16rem)]"
384
- height="full"
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
- Popover,
13
- PopoverContent,
14
- PopoverTrigger,
15
- } from "../../primitives/client";
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-xs"
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
- <Popover open={filterOpen} onOpenChange={setFilterOpen}>
212
- <PopoverTrigger asChild>
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
- </PopoverTrigger>
227
- <PopoverContent className="w-80 p-4" align="start">
228
- <div className="space-y-4">
229
- <div className="flex items-center justify-between">
230
- <h4 className="font-medium text-sm">Bộ lọc</h4>
231
- {hasActiveFilters && (
232
- <Button
233
- variant="ghost"
234
- size="sm"
235
- onClick={handleClearFilters}
236
- className="h-7 text-xs"
237
- >
238
- <X className="h-3 w-3 mr-1" />
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
- </PopoverContent>
248
- </Popover>
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">STT</div>
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 className="w-max min-w-full caption-bottom text-xs relative">
381
- <TableHeader className="sticky top-0 z-20 bg-card">
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 py-2.5"
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-8 data-[state=open]:bg-accent"
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-2 h-4 w-4" />
49
+ <ArrowDown className="ml-1.5 h-3.5 w-3.5" />
50
50
  ) : column.getIsSorted() === "asc" ? (
51
- <ArrowUp className="ml-2 h-4 w-4" />
51
+ <ArrowUp className="ml-1.5 h-3.5 w-3.5" />
52
52
  ) : (
53
- <ArrowDownUp className="ml-2 h-4 w-4" />
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, buttonVariants } from "../primitives";
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 startItem =
38
- totalItems && currentPage && pageSize
39
- ? (currentPage - 1) * pageSize + 1
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-1 md:flex-row md:gap-3">
55
- <div className="flex-1 text-sm text-muted-foreground">
56
- {total === 0 ? (
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
- <div className="flex items-center gap-2">
65
- <div className="flex items-center gap-2">
66
- <p className="text-sm font-medium">Số dòng mỗi trang</p>
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={`${table.getState().pagination.pageSize}`}
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-8 w-[70px]">
74
- <SelectValue placeholder={table.getState().pagination.pageSize} />
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, 30, 50, 100].map((pageSize) => (
78
- <SelectItem key={pageSize} value={`${pageSize}`}>
79
- {pageSize}
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
- <div className="flex items-center gap-1">
94
+
95
+ <div className="flex items-center gap-1 pl-1">
86
96
  <Button
87
97
  variant="outline"
88
- className="hidden h-8 w-8 p-0 lg:flex"
89
- onClick={() => table.setPageIndex(0)}
90
- disabled={!table.getCanPreviousPage()}
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">Đi tới trang đầu</span>
93
- <ChevronsLeft className="h-4 w-4" />
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-8 w-8 p-0"
98
- onClick={() => table.previousPage()}
99
- disabled={!table.getCanPreviousPage()}
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-4 w-4" />
112
+ <ChevronLeft className="h-3 w-3" />
103
113
  </Button>
104
- <div className="flex items-center justify-center text-sm font-medium">
105
- Trang {table.getState().pagination.pageIndex + 1} /{" "}
106
- {table.getPageCount()}
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-8 w-8 p-0"
111
- onClick={() => table.nextPage()}
112
- disabled={!table.getCanNextPage()}
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-4 w-4" />
141
+ <ChevronRight className="h-3 w-3" />
116
142
  </Button>
117
143
  <Button
118
144
  variant="outline"
119
- className="hidden h-8 w-8 p-0 lg:flex"
120
- onClick={() => table.setPageIndex(table.getPageCount() - 1)}
121
- disabled={!table.getCanNextPage()}
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">Đi tới trang cuối</span>
124
- <ChevronsRight className="h-4 w-4" />
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, "en"), { scroll: false });
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>Customizer</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">Color</p>
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">Radius</p>
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">Mode</p>
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 me-2" />
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 me-2" />
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 me-2" />
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">Layout</span>
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
- Horizontal
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
- Vertical
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">Sidebar Variant</span>
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
- {variant.charAt(0).toUpperCase() + variant.slice(1)}
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">Sidebar Collapsible</span>
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
- {option.charAt(0).toUpperCase() + option.slice(1)}
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">Density</span>
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
- {density.charAt(0).toUpperCase() + density.slice(1)}
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">Language</span>
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
- Reset
382
+ {t("reset")}
317
383
  </Button>
318
384
  </div>
319
385
  </ScrollArea>
@@ -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-sidebar-border overflow-x-hidden">
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-sidebar-border backdrop-blur supports-[backdrop-filter]:bg-background/80 flex flex-col">
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 = isActivePathname(localizedPathname, pathname);
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 = isActivePathname(
137
- localizedPathname,
138
- pathname,
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-sidebar-border backdrop-blur supports-[backdrop-filter]:bg-background/80">
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-sidebar-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
  >