@goplusvn/core 0.1.87 → 0.1.89

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +1 -1
  3. package/src/audit/__tests__/prisma-audit-extension.test.ts +113 -0
  4. package/src/audit/prisma-audit-extension.ts +41 -13
  5. package/src/configs/status.ts +67 -0
  6. package/src/cron/__tests__/cron-single-runner.test.ts +260 -0
  7. package/src/cron/cron-schedule.ts +113 -20
  8. package/src/cron/db-cron-manager.ts +90 -7
  9. package/src/cron/simple-cron-job.ts +17 -6
  10. package/src/crud/__tests__/status-boolean-display.test.tsx +186 -0
  11. package/src/crud/components/crud-detail-dialog.tsx +21 -8
  12. package/src/crud/components/crud-field-renderer.tsx +20 -13
  13. package/src/crud/components/crud-filter-chips.tsx +14 -39
  14. package/src/crud/components/crud-filters/datetime-filter.tsx +58 -17
  15. package/src/crud/components/crud-provider.tsx +10 -2
  16. package/src/crud/components/crud-table-toolbar.tsx +38 -40
  17. package/src/crud/components/crud-table.tsx +13 -8
  18. package/src/crud/lib/coerce.ts +36 -10
  19. package/src/crud/lib/filter-defaults.test.ts +115 -0
  20. package/src/crud/lib/filter-defaults.ts +56 -0
  21. package/src/crud/lib/filter-display.ts +59 -0
  22. package/src/crud/lib/query-builder.ts +4 -0
  23. package/src/types/index.ts +16 -9
  24. package/src/ui/data-display/data-table/data-table-toolbar.tsx +8 -2
  25. package/src/ui/data-display/data-table/data-table.tsx +32 -4
  26. package/src/ui/layout/user-dropdown.tsx +30 -23
  27. package/src/ui/primitives/status-badge.tsx +14 -10
  28. package/src/ui/shared/status-indicator.tsx +266 -74
  29. package/src/utils/index.ts +87 -38
  30. package/src/workspace/__tests__/workspace-delegation.test.ts +29 -11
@@ -0,0 +1,56 @@
1
+ import type { ActiveFilter, FilterConfig } from "../../types";
2
+
3
+ /**
4
+ * BỘ LỌC MẶC ĐỊNH — biến `FilterConfig.defaultValue` thành bộ lọc đang chạy thật.
5
+ *
6
+ * Trước đây `defaultValue` chỉ được vài ô nhập trong ngăn lọc đọc để đặt giá
7
+ * trị ban đầu; không nhánh nào đẩy nó vào state `filters` của `CrudProvider`
8
+ * (khởi tạo cứng bằng `[]`). App khai xong tưởng đã bật, mà trang vẫn mở ra ở
9
+ * trạng thái KHÔNG lọc — danh sách theo ngày nạp toàn bộ lịch sử.
10
+ *
11
+ * Token `"$today"` giải ra NGÀY HÔM NAY theo lịch Việt Nam, dạng "YYYY-MM-DD".
12
+ * Hai điểm bắt buộc:
13
+ *
14
+ * · Tính bằng `Intl` với `timeZone` cố định, KHÔNG dùng giờ máy. Hàm này chạy
15
+ * trong initializer của `useState` nên nổ ở CẢ server lẫn trình duyệt; lấy
16
+ * giờ máy thì server (thường UTC) ra ngày khác trình duyệt (GMT+7) và React
17
+ * báo hydration mismatch — mỗi đêm một lần, từ 00:00 đến 07:00 giờ VN.
18
+ * · Giá trị là CHUỖI ngày trần, không phải `Date`. Chuỗi không mang giờ nên
19
+ * không có gì để lệch múi giờ khi đi qua URL; `coerceFieldValue` ở route
20
+ * handler ép về `Date` đúng kiểu cột ngay trước khi vào Prisma.
21
+ */
22
+ export const TODAY_TOKEN = "$today";
23
+
24
+ /** Múi giờ vận hành — trùng với formatter hiển thị trong `utils` (vi-VN). */
25
+ const TZ = "Asia/Ho_Chi_Minh";
26
+
27
+ /** Hôm nay theo lịch VN, "YYYY-MM-DD". `en-CA` cho ra đúng dạng ISO này. */
28
+ export function todayDateKey(): string {
29
+ return new Intl.DateTimeFormat("en-CA", {
30
+ timeZone: TZ,
31
+ year: "numeric",
32
+ month: "2-digit",
33
+ day: "2-digit",
34
+ }).format(new Date());
35
+ }
36
+
37
+ /** `defaultValue` đã khai ➝ giá trị thật (giải token nếu có). */
38
+ export function resolveDefaultValue(filter: FilterConfig): unknown {
39
+ if (filter.defaultValue !== TODAY_TOKEN) return filter.defaultValue;
40
+ const today = todayDateKey();
41
+ // Bộ chọn KHOẢNG ngày cần cặp [từ, đến] — hôm nay tới hết hôm nay.
42
+ return filter.operator === "between" ? [today, today] : today;
43
+ }
44
+
45
+ /** Danh sách bộ lọc bật sẵn khi trang vừa mở. */
46
+ export function resolveFilterDefaults(
47
+ filters: FilterConfig[] | undefined,
48
+ ): ActiveFilter[] {
49
+ return (filters ?? [])
50
+ .filter((f) => f.defaultValue !== undefined)
51
+ .map((f) => ({
52
+ name: f.name,
53
+ value: resolveDefaultValue(f),
54
+ operator: f.operator ?? "eq",
55
+ }));
56
+ }
@@ -0,0 +1,59 @@
1
+ import type { FilterConfig } from "../../types";
2
+
3
+ /**
4
+ * GIÁ TRỊ BỘ LỌC ➝ CHỮ CHO NGƯỜI ĐỌC (chip trên thanh công cụ).
5
+ *
6
+ * Trước đây chip tự lo phần này và hỏng theo hai kiểu khác nhau:
7
+ * · `typeof value === "object" ? JSON.stringify(v) : String(v)` — ngày là
8
+ * `Date` (typeof "object") nên chip in ra `"2026-08-20T17:00:00.000Z"`,
9
+ * kèm cả dấu nháy của JSON.
10
+ * · dựng chuỗi `"Nhãn: giá trị"` rồi `.split(":")` để tách lại hai vế — giá
11
+ * trị nào có dấu hai chấm là mất đuôi, `String(date)` bị cắt còn
12
+ * "Fri Aug 21 2026 00".
13
+ *
14
+ * Nhãn và giá trị ở đây là HAI thứ tách rời từ đầu, không ghép rồi cắt.
15
+ */
16
+ const DATE_FORMAT: Intl.DateTimeFormatOptions = {
17
+ day: "2-digit",
18
+ month: "2-digit",
19
+ year: "numeric",
20
+ timeZone: "Asia/Ho_Chi_Minh",
21
+ };
22
+
23
+ function formatDate(value: unknown): string {
24
+ const date = value instanceof Date ? value : new Date(String(value));
25
+ if (Number.isNaN(date.getTime())) return String(value);
26
+ return new Intl.DateTimeFormat("vi-VN", DATE_FORMAT).format(date);
27
+ }
28
+
29
+ function optionLabel(filter: FilterConfig, value: unknown): string {
30
+ const option = filter.options?.find(
31
+ (opt) => opt.value === value || String(opt.value) === String(value),
32
+ );
33
+ return option ? option.label : String(value);
34
+ }
35
+
36
+ /** Chỉ phần GIÁ TRỊ — nhãn do chip tự lấy từ `filter.label`. */
37
+ export function formatFilterValue(
38
+ filter: FilterConfig | undefined,
39
+ value: unknown,
40
+ ): string {
41
+ if (value === null || value === undefined) return "";
42
+ if (!filter) return String(value);
43
+
44
+ if (filter.type === "select" || filter.type === "radio") {
45
+ return optionLabel(filter, value);
46
+ }
47
+
48
+ if (Array.isArray(value)) {
49
+ if (filter.type === "datetime") {
50
+ const parts = value.map(formatDate);
51
+ // Khoảng một ngày ("hôm nay tới hết hôm nay") đọc là một ngày.
52
+ return parts[0] === parts[1] ? parts[0] : parts.join(" - ");
53
+ }
54
+ return value.map((v) => optionLabel(filter, v)).join(", ");
55
+ }
56
+
57
+ if (filter.type === "datetime") return formatDate(value);
58
+ return String(value);
59
+ }
@@ -60,6 +60,10 @@ export function buildListQuery({
60
60
  }
61
61
  const op = operator as string;
62
62
  if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
63
+ // Khoảng [từ, đến] của bộ chọn ngày. Thiếu nhánh này thì cả mảng rơi
64
+ // xuống `else` và đi thẳng vào Prisma dưới dạng array ⇒ nổ ở tầng driver.
65
+ else if (op === "between" && Array.isArray(value) && value.length === 2)
66
+ target[key] = { gte: value[0], lte: value[1] };
63
67
  else if (op === "in") target[key] = { in: value };
64
68
  else if (op === "notIn") target[key] = { notIn: value };
65
69
  else if (op === "eq") target[key] = value;
@@ -224,14 +224,12 @@ export interface NavigationRootItemBasicType {
224
224
  action?: string;
225
225
  }
226
226
 
227
- export interface NavigationRootItemWithHrefType
228
- extends NavigationRootItemBasicType {
227
+ export interface NavigationRootItemWithHrefType extends NavigationRootItemBasicType {
229
228
  href: string;
230
229
  items?: never;
231
230
  }
232
231
 
233
- export interface NavigationRootItemWithItemsType
234
- extends NavigationRootItemBasicType {
232
+ export interface NavigationRootItemWithItemsType extends NavigationRootItemBasicType {
235
233
  items: (
236
234
  | NavigationNestedItemWithHrefType
237
235
  | NavigationNestedItemWithItemsType
@@ -247,14 +245,12 @@ export interface NavigationNestedItemBasicType {
247
245
  iconName?: DynamicIconNameType;
248
246
  }
249
247
 
250
- export interface NavigationNestedItemWithHrefType
251
- extends NavigationNestedItemBasicType {
248
+ export interface NavigationNestedItemWithHrefType extends NavigationNestedItemBasicType {
252
249
  href: string;
253
250
  items?: never;
254
251
  }
255
252
 
256
- export interface NavigationNestedItemWithItemsType
257
- extends NavigationNestedItemBasicType {
253
+ export interface NavigationNestedItemWithItemsType extends NavigationNestedItemBasicType {
258
254
  items: (
259
255
  | NavigationNestedItemWithHrefType
260
256
  | NavigationNestedItemWithItemsType
@@ -460,8 +456,12 @@ export interface FieldConfig {
460
456
  * Int autoincrement hiện qua select/multiselect/relation: form và URL chở
461
457
  * chuỗi "3", khai `valueType: "int"` để coercion tập trung (coerceFieldValue)
462
458
  * ép Number trước khi vào Prisma. Default: "string" (giữ nguyên).
459
+ *
460
+ * `"boolean"` cho trường hợp ngược lại: field khai `type: "switch"` kèm cặp
461
+ * option chữ ("Hoạt động"/"Tạm ngưng") nhưng CỘT trong DB là Boolean — khai
462
+ * ra thì lưu xuống `true/false` chứ không nhét chuỗi "active" vào cột bool.
463
463
  */
464
- valueType?: "string" | "int";
464
+ valueType?: "string" | "int" | "boolean";
465
465
  required?: boolean;
466
466
  defaultValue?: unknown;
467
467
  placeholder?: string;
@@ -653,6 +653,13 @@ export interface FilterConfig {
653
653
  type: FilterType;
654
654
  field: string;
655
655
  operator?: FilterOperator;
656
+ /**
657
+ * Bộ lọc BẬT SẴN khi trang vừa mở — `CrudProvider` seed thẳng vào state
658
+ * `filters`, nên nó hiện thành chip và người dùng thấy mình đang xem cái gì.
659
+ *
660
+ * Token `"$today"` = hôm nay theo lịch VN; với `operator: "between"` nó giải
661
+ * thành cặp [hôm nay, hôm nay]. Xem `crud/lib/filter-defaults`.
662
+ */
656
663
  defaultValue?: unknown;
657
664
  options?: Array<{ label: string; value: string | number | boolean }>;
658
665
  dataSource?: DataSource;
@@ -27,6 +27,11 @@ export interface ActiveFilter {
27
27
  name: string;
28
28
  value: unknown;
29
29
  operator: string;
30
+ /**
31
+ * Chữ đã dựng sẵn cho chip. Lớp trên biết `FilterConfig` (nhãn tuỳ chọn,
32
+ * kiểu ngày) nên nó dựng; toolbar chỉ in ra. Không truyền thì rơi về cách cũ.
33
+ */
34
+ displayValue?: string;
30
35
  }
31
36
 
32
37
  export interface FilterConfig {
@@ -345,9 +350,10 @@ export function DataTableToolbar<TData>({
345
350
  const filterConfig = filters.find((f) => f.name === filter.name);
346
351
  const label = filterConfig?.label || filter.name;
347
352
  const displayValue =
348
- typeof filter.value === "object"
353
+ filter.displayValue ??
354
+ (typeof filter.value === "object"
349
355
  ? JSON.stringify(filter.value)
350
- : String(filter.value);
356
+ : String(filter.value));
351
357
 
352
358
  return (
353
359
  <div
@@ -8,6 +8,7 @@ import {
8
8
  getFilteredRowModel,
9
9
  getPaginationRowModel,
10
10
  getSortedRowModel,
11
+ getExpandedRowModel,
11
12
  useReactTable,
12
13
  } from "@tanstack/react-table";
13
14
  import type {
@@ -138,6 +139,16 @@ export interface DataTableProps<TData> {
138
139
  */
139
140
  className?: string;
140
141
 
142
+ /**
143
+ * Determine if a row can be expanded
144
+ */
145
+ getRowCanExpand?: (row: Row<TData>) => boolean;
146
+
147
+ /**
148
+ * Render custom sub-component (expanded row content)
149
+ */
150
+ renderSubComponent?: (props: { row: Row<TData> }) => ReactNode;
151
+
141
152
  /**
142
153
  * Height configuration
143
154
  * @default "auto"
@@ -190,6 +201,8 @@ export function DataTable<TData extends Record<string, unknown>>({
190
201
  onTableReady,
191
202
  onRowClick,
192
203
  className,
204
+ getRowCanExpand,
205
+ renderSubComponent,
193
206
  height = "auto",
194
207
  headerClassName,
195
208
  tableClassName,
@@ -360,6 +373,8 @@ export function DataTable<TData extends Record<string, unknown>>({
360
373
  onSortingChange: handleSortingChange,
361
374
  getSortedRowModel: getSortedRowModel(),
362
375
  getFilteredRowModel: getFilteredRowModel(),
376
+ getExpandedRowModel: getExpandedRowModel(),
377
+ getRowCanExpand,
363
378
  state: {
364
379
  sorting: tanstackSorting,
365
380
  // CHỈ đưa key `pagination` vào state khi controlled: `pagination: undefined`
@@ -471,6 +486,7 @@ export function DataTable<TData extends Record<string, unknown>>({
471
486
  visibleCellsCount={row.getVisibleCells().length}
472
487
  onRowClick={onRowClick}
473
488
  cellClassName={cellClassName}
489
+ renderSubComponent={renderSubComponent}
474
490
  />
475
491
  ) : (
476
492
  <MemoizedTableRow
@@ -481,6 +497,7 @@ export function DataTable<TData extends Record<string, unknown>>({
481
497
  onRowClick={onRowClick}
482
498
  cellClassName={cellClassName}
483
499
  memoKey={rowMemo ? rowMemo(row.original) : undefined}
500
+ renderSubComponent={renderSubComponent}
484
501
  />
485
502
  ),
486
503
  )
@@ -538,6 +555,7 @@ interface MemoizedTableRowProps<TData> {
538
555
  cellClassName?: string;
539
556
  /** Key từ `rowMemo(row.original)` — comparator so bằng Object.is. */
540
557
  memoKey?: unknown;
558
+ renderSubComponent?: (props: { row: Row<TData> }) => ReactNode;
541
559
  }
542
560
 
543
561
  // Thân hàng KHÔNG memo — dùng trực tiếp khi rowMemo={false}, và là inner của
@@ -545,11 +563,14 @@ interface MemoizedTableRowProps<TData> {
545
563
  function DataTableRowInner<TData>({
546
564
  row,
547
565
  isSelected,
566
+ visibleCellsCount,
548
567
  onRowClick,
549
568
  cellClassName,
569
+ renderSubComponent,
550
570
  }: MemoizedTableRowProps<TData>) {
551
571
  return (
552
- <TableRow
572
+ <>
573
+ <TableRow
553
574
  data-state={isSelected && "selected"}
554
575
  className={`transition-colors duration-100 even:bg-muted/30 hover:bg-accent/50 dark:hover:bg-slate-800/40 data-[state=selected]:bg-primary/5 border-b border-border/40 dark:border-slate-800 relative hover:border-l-[3px] hover:border-l-primary ${
555
576
  onRowClick ? "cursor-pointer" : ""
@@ -557,9 +578,6 @@ function DataTableRowInner<TData>({
557
578
  onClick={
558
579
  onRowClick
559
580
  ? (e) => {
560
- // Don't trigger row click for interactive controls inside the row
561
- // (selection checkbox, action menu trigger, links). The actions
562
- // dropdown content is portaled, so only its trigger lives here.
563
581
  const target = e.target as HTMLElement;
564
582
  if (
565
583
  target.closest(
@@ -587,6 +605,14 @@ function DataTableRowInner<TData>({
587
605
  </TableCell>
588
606
  ))}
589
607
  </TableRow>
608
+ {row.getIsExpanded() && renderSubComponent && (
609
+ <TableRow className="bg-muted/10 hover:bg-muted/10 border-b border-border/40">
610
+ <TableCell colSpan={visibleCellsCount} className="p-0">
611
+ {renderSubComponent({ row })}
612
+ </TableCell>
613
+ </TableRow>
614
+ )}
615
+ </>
590
616
  );
591
617
  }
592
618
 
@@ -602,6 +628,8 @@ const MemoizedTableRow = memo(
602
628
  prevProps.row.original !== nextProps.row.original ||
603
629
  // cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
604
630
  prevProps.cellClassName !== nextProps.cellClassName ||
631
+ // Kiểm tra trạng thái expand
632
+ prevProps.row.getIsExpanded() !== nextProps.row.getIsExpanded() ||
605
633
  // Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
606
634
  !Object.is(prevProps.memoKey, nextProps.memoKey)
607
635
  ) {
@@ -2,9 +2,9 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { useAuthBridge, useSafeAuthSession } from "../auth/auth-bridge";
5
- import { LogOut, User, UserCog,
6
- } from "lucide-react";
5
+ import { LogOut, User, UserCog } from "lucide-react";
7
6
 
7
+ import { useMounted } from "../../hooks";
8
8
  import type { DictionaryType } from "../../hooks";
9
9
  import type { LocaleType } from "../../types";
10
10
 
@@ -16,7 +16,6 @@ import { Button } from "../primitives/button";
16
16
  import { useTabContentCache } from "./tab-content-cache";
17
17
  import { useTabNavigation } from "./tab-navigation-provider";
18
18
 
19
-
20
19
  import {
21
20
  DropdownMenu,
22
21
  DropdownMenuContent,
@@ -78,28 +77,36 @@ export function UserDropdown({
78
77
  }
79
78
  };
80
79
 
80
+ const mounted = useMounted();
81
+
82
+ const triggerButton = (
83
+ <Button
84
+ variant="outline"
85
+ size="icon"
86
+ className="rounded-full bg-primary border-primary hover:bg-primary/90 focus-visible:ring-0 focus-visible:ring-offset-0"
87
+ aria-label="User"
88
+ title="Profile"
89
+ >
90
+ <Avatar className="size-6">
91
+ <AvatarImage
92
+ src={user?.avatar || undefined}
93
+ alt={user?.name || ""}
94
+ className="object-cover"
95
+ />
96
+ <AvatarFallback className="bg-primary text-white text-[10px] font-bold uppercase">
97
+ {user?.name && getInitials(user.name)}
98
+ </AvatarFallback>
99
+ </Avatar>
100
+ </Button>
101
+ );
102
+
103
+ if (!mounted) {
104
+ return triggerButton;
105
+ }
106
+
81
107
  return (
82
108
  <DropdownMenu>
83
- <DropdownMenuTrigger asChild>
84
- <Button
85
- variant="outline"
86
- size="icon"
87
- className="rounded-full bg-primary border-primary hover:bg-primary/90 focus-visible:ring-0 focus-visible:ring-offset-0"
88
- aria-label="User"
89
- title="Profile"
90
- >
91
- <Avatar className="size-6">
92
- <AvatarImage
93
- src={user?.avatar || undefined}
94
- alt={user?.name || ""}
95
- className="object-cover"
96
- />
97
- <AvatarFallback className="bg-primary text-white text-[10px] font-bold uppercase">
98
- {user?.name && getInitials(user.name)}
99
- </AvatarFallback>
100
- </Avatar>
101
- </Button>
102
- </DropdownMenuTrigger>
109
+ <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
103
110
 
104
111
  <DropdownMenuContent
105
112
  className="w-56 p-1 bg-background/80 backdrop-blur-xl border border-border/40 shadow-xl rounded-xl animate-in fade-in zoom-in-95 duration-200"
@@ -1,5 +1,6 @@
1
1
  import { Badge } from "./badge";
2
- import { STATUS_COLORS, STATUS_ACTIVE, STATUS_INACTIVE } from "../../utils";
2
+ import { STATUS_COLORS, normalizeStatusValue } from "../../utils";
3
+ import { getStatusMeta } from "../shared/status-indicator";
3
4
  import { cn } from "../../utils";
4
5
 
5
6
  interface StatusBadgeProps {
@@ -13,8 +14,11 @@ export function StatusBadge({
13
14
  className,
14
15
  label: customLabel,
15
16
  }: StatusBadgeProps) {
16
- const statusStr = String(status);
17
- const variant = STATUS_COLORS[statusStr] || "secondary";
17
+ // Giá trị vào đây không chỉ là chuỗi: cột Boolean gửi `true`/`false`, cột
18
+ // smallint gửi 1/0. Tra bảng màu bằng chuỗi thô thì `"true"` không có trong
19
+ // STATUS_COLORS ⇒ chip xám, chữ hiện "true". Chuẩn hoá trước rồi mới tra.
20
+ const statusKey = normalizeStatusValue(status);
21
+ const variant = (statusKey && STATUS_COLORS[statusKey]) || "secondary";
18
22
 
19
23
  // Custom styles for success (green) and warning (yellow) since they might not be in default badge variants
20
24
  let badgeClass = "";
@@ -26,13 +30,13 @@ export function StatusBadge({
26
30
  "bg-yellow-500 hover:bg-yellow-600 text-white border-transparent";
27
31
  }
28
32
 
29
- // Use custom label if provided, otherwise fallback to basic mapping or raw value
30
- let label = customLabel || statusStr;
31
-
32
- // Fallback mapping if no custom label provided
33
- if (!customLabel) {
34
- if (statusStr === STATUS_ACTIVE) label = "Active";
35
- }
33
+ // Không nhãn riêng thì lấy nhãn TIẾNG VIỆT của taxonomy chung
34
+ // (`getStatusMeta`) trước đây chỗ này trả về chữ "Active", và với giá trị
35
+ // boolean thì rơi thẳng ra "true"/"false" trên màn hình.
36
+ const meta = statusKey ? getStatusMeta(statusKey) : null;
37
+ const label =
38
+ customLabel ??
39
+ (meta?.matched ? meta.label : statusKey ? String(status) : "");
36
40
 
37
41
  return (
38
42
  <Badge