@goplusvn/core 0.1.87 → 0.1.88

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.
@@ -9,7 +9,8 @@ import type { ChangeEvent } from "react";
9
9
 
10
10
  import { dataLoader } from "../lib/data-loader";
11
11
  import { formatFieldValue } from "../lib/field-formatter";
12
- import { cn } from "../../utils";
12
+ import { resolveSwitchPair } from "../lib/coerce";
13
+ import { cn, matchesOptionValue } from "../../utils";
13
14
 
14
15
  import { Button } from "../../ui";
15
16
  import { Combobox } from "../../ui/primitives/client";
@@ -477,28 +478,33 @@ export function CrudFieldRenderer({
477
478
  );
478
479
 
479
480
  case "boolean":
480
- case "switch":
481
+ case "switch": {
482
+ // CẶP GIÁ TRỊ BẬT/TẮT lấy từ chính FieldConfig, không đoán:
483
+ //
484
+ // - có `options` (thứ tự BẬT trước, TẮT sau — đúng quy ước của
485
+ // `coerceFieldValue`) ⇒ ghi đúng giá trị cột: "active"/"inactive",
486
+ // "1"/"0", hay bất kỳ cặp nào app khai;
487
+ // - không có `options` ⇒ cột Boolean, ghi thẳng true/false.
488
+ //
489
+ // Bản cũ ghi cứng chuỗi "active"/"inactive" và so `value === "active"`,
490
+ // nên với cột Boolean thì bản ghi đang BẬT vẫn hiện công tắc TẮT, bấm
491
+ // lưu lại nhét chuỗi vào cột boolean.
492
+ const { on: onValue, off: offValue } = resolveSwitchPair(field);
481
493
  return (
482
494
  <FormField
483
495
  control={control}
484
496
  name={field.name}
485
497
  render={({ field: formField }) => (
486
498
  <FormItem>
487
- {field.label && <FormLabel className="font-semibold">{field.label}</FormLabel>}
499
+ {field.label && (
500
+ <FormLabel className="font-semibold">{field.label}</FormLabel>
501
+ )}
488
502
  <FormControl>
489
503
  <div className="flex items-center h-10">
490
504
  <Switch
491
- checked={
492
- field.type === "switch"
493
- ? formField.value === "active"
494
- : (formField.value ?? false)
495
- }
505
+ checked={matchesOptionValue(onValue, formField.value)}
496
506
  onCheckedChange={(checked) => {
497
- if (field.type === "switch") {
498
- formField.onChange(checked ? "active" : "inactive");
499
- } else {
500
- formField.onChange(checked);
501
- }
507
+ formField.onChange(checked ? onValue : offValue);
502
508
  }}
503
509
  disabled={isFieldDisabled}
504
510
  />
@@ -512,6 +518,7 @@ export function CrudFieldRenderer({
512
518
  )}
513
519
  />
514
520
  );
521
+ }
515
522
 
516
523
  case "date":
517
524
  case "datetime":
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
5
5
  import type { ColumnDef, Table } from "@tanstack/react-table";
6
6
 
7
7
  import { StatusBadge } from "../../ui";
8
+ import { matchesOptionValue } from "../../utils";
8
9
 
9
10
  import type { CrudResponse } from "../../types";
10
11
 
@@ -194,19 +195,23 @@ export function CrudTable<TData extends Record<string, unknown>>({
194
195
 
195
196
  if (field.renderCell) {
196
197
  content = field.renderCell(value, row.original) as any;
197
- } else if (field.type === "switch") {
198
-
198
+ } else if (field.type === "switch" || field.type === "boolean") {
199
199
  if (typeof value === "boolean" && !field.options) {
200
- const label = value ? "Có" : "Không";
201
- // Map boolean to status colors that StatusBadge likely supports
202
- const status = value ? "active" : "inactive";
203
- content = <StatusBadge status={status} label={label} />;
200
+ const label = value ? "Có" : "Không";
201
+ // Map boolean to status colors that StatusBadge likely supports
202
+ const status = value ? "active" : "inactive";
203
+ content = <StatusBadge status={status} label={label} />;
204
204
  } else {
205
+ // SO LỎNG: cột `is_active` kiểu Boolean trả `true` trong khi
206
+ // option khai `"active"` — so tuyệt đối thì không khớp, nhãn
207
+ // rỗng và chip hiện đúng chữ "true". `matchesOptionValue` gom
208
+ // boolean/số/chuỗi về cùng key trước khi so.
205
209
  const option = field.options?.find((opt) => {
206
210
  const optValue = typeof opt === "object" ? opt.value : opt;
207
- return optValue === value;
211
+ return matchesOptionValue(optValue, value);
208
212
  });
209
- const label = typeof option === "object" ? option.label : option;
213
+ const label =
214
+ typeof option === "object" ? option.label : option;
210
215
  // Translate label if translator provided and label is a valid string
211
216
  const translatedLabel =
212
217
  getTranslation && typeof label === "string"
@@ -1,4 +1,5 @@
1
1
  import type { FieldConfig } from "../../types";
2
+ import { isTruthyStatus, matchesOptionValue } from "../../configs/status";
2
3
 
3
4
  function optionValue(option: unknown): unknown {
4
5
  return typeof option === "object" && option !== null && "value" in option
@@ -41,6 +42,12 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
41
42
  // Cột Int đứng sau select/multiselect/relation (FK autoincrement): `type`
42
43
  // không nói lên kiểu cột nên phải khai `valueType: "int"` trong FieldConfig.
43
44
  // Chuỗi không phải số giữ nguyên cho tầng validate/Prisma báo.
45
+ // Cột Boolean đứng sau một field khai cặp option chữ: form/URL chở xuống
46
+ // "active" / "true" / "1", ép về boolean trước khi vào Prisma.
47
+ if (field.valueType === "boolean") {
48
+ return isTruthyStatus(value);
49
+ }
50
+
44
51
  if (field.valueType === "int" && typeof value === "string") {
45
52
  if (value.trim() === "") return null;
46
53
  const num = Number(value);
@@ -59,14 +66,6 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
59
66
 
60
67
  case "boolean":
61
68
  case "switch": {
62
- let isTrue: boolean;
63
- if (typeof value === "string") {
64
- const lv = value.toLowerCase();
65
- isTrue =
66
- lv === "true" || lv === "active" || value === "1" || value === "on";
67
- } else {
68
- isTrue = Boolean(value);
69
- }
70
69
  // switch có cặp option = cột string 2 trạng thái (active/inactive…),
71
70
  // map về value của option chứ không phải boolean.
72
71
  if (
@@ -74,11 +73,20 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
74
73
  field.options &&
75
74
  field.options.length >= 2
76
75
  ) {
77
- return isTrue
76
+ // Giá trị đã ĐÚNG là một option rồi thì giữ nguyên — chỉ những giá
77
+ // trị "lạ" mới quy về cặp bật/tắt. Trước đây mọi giá trị đều bị ép
78
+ // qua boolean, nên cặp option nhiều hơn hai trạng thái mất dữ liệu.
79
+ const exact = field.options.find((opt) =>
80
+ matchesOptionValue(optionValue(opt), value),
81
+ );
82
+ if (exact !== undefined) return optionValue(exact);
83
+ return isTruthyStatus(value)
78
84
  ? optionValue(field.options[0])
79
85
  : optionValue(field.options[1]);
80
86
  }
81
- return isTrue;
87
+ // `isTruthyStatus` nhận cả "active"/"true"/"1"/"on"/"yes" và loại
88
+ // "inactive"/"false"/"0"/"off" — cùng bảng chuẩn hoá với phía hiển thị.
89
+ return typeof value === "string" ? isTruthyStatus(value) : Boolean(value);
82
90
  }
83
91
 
84
92
  case "date":
@@ -93,3 +101,21 @@ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
93
101
  return value;
94
102
  }
95
103
  }
104
+
105
+ /**
106
+ * CẶP GIÁ TRỊ BẬT/TẮT của một field `switch` — dùng cho công tắc ở form, và
107
+ * là cặp mà `coerceFieldValue` sẽ ghi xuống DB, nên hai bên không lệch nhau.
108
+ *
109
+ * Quy ước: `options[0]` là BẬT, `options[1]` là TẮT (đúng thứ tự khai trong
110
+ * STATUS_OPTIONS). Không khai options ⇒ cột Boolean thật ⇒ true/false.
111
+ */
112
+ export function resolveSwitchPair(field: FieldConfig): {
113
+ on: unknown;
114
+ off: unknown;
115
+ } {
116
+ const options = field.options;
117
+ if (field.valueType === "boolean" || !options || options.length < 2) {
118
+ return { on: true, off: false };
119
+ }
120
+ return { on: optionValue(options[0]), off: optionValue(options[1]) };
121
+ }
@@ -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;
@@ -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