@goplusvn/core 0.1.86 → 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
+ }
@@ -94,12 +94,22 @@ export interface ImportDialogProps {
94
94
  apiUrl: string;
95
95
  /**
96
96
  * Nguồn file mẫu — chọn MỘT:
97
- * - `templateApiUrl`: GET mẫu do server dựng (nhiều sheet, chứng từ), hoặc
98
- * - `config`: dựng mẫu ngay ở client từ EntityConfig.
99
- * `config` thì dialog còn kiểm tra file TRƯỚC khi gửi gửi kèm `format`.
97
+ * - `templateApiUrl`: GET mẫu do server dựng (nhiều sheet, chứng từ),
98
+ * - `config`: dựng mẫu ngay ở client từ EntityConfig, hoặc
99
+ * - `onDownloadTemplate`: app tự dựng (mẫu phụ thuộc cấu hình đang chạy
100
+ * danh sách ca, danh sách món… — nên không khai trước bằng config được,
101
+ * mà app lại không tự resolve được `xlsx`; core có).
100
102
  */
101
103
  templateApiUrl?: string;
102
104
  config?: EntityConfig;
105
+ onDownloadTemplate?: () => void | Promise<void>;
106
+ /**
107
+ * Nội dung riêng của app chèn NGAY DƯỚI khối kết quả — chỗ đặt nút "Tải file
108
+ * lỗi". Nhận nguyên văn thân JSON server trả, vì mỗi nghiệp vụ đính kèm một
109
+ * kiểu chi tiết khác nhau (dòng xem trước, lý do từng dòng…) mà dialog không
110
+ * nên biết.
111
+ */
112
+ renderResultExtra?: (raw: unknown) => ReactNode;
103
113
  /** Không có quyền nhập thì không render gì. */
104
114
  canImport?: boolean;
105
115
  onSuccess?: () => void;
@@ -132,6 +142,8 @@ export function ImportDialog({
132
142
  apiUrl,
133
143
  templateApiUrl,
134
144
  config,
145
+ onDownloadTemplate,
146
+ renderResultExtra,
135
147
  canImport = true,
136
148
  onSuccess,
137
149
  tasksPath,
@@ -140,6 +152,7 @@ export function ImportDialog({
140
152
  const [file, setFile] = useState<File | null>(null);
141
153
  const [loading, setLoading] = useState(false);
142
154
  const [result, setResult] = useState<NormalizedResult | null>(null);
155
+ const [rawResult, setRawResult] = useState<unknown>(null);
143
156
  const [format, setFormat] = useState<ImportOptions["format"]>("xlsx");
144
157
  const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
145
158
 
@@ -164,6 +177,7 @@ export function ImportDialog({
164
177
  (selectedFile: File) => {
165
178
  setFile(selectedFile);
166
179
  setResult(null);
180
+ setRawResult(null);
167
181
  if (configMode) {
168
182
  const extension = selectedFile.name.split(".").pop()?.toLowerCase();
169
183
  if (extension === "json") setFormat("json");
@@ -177,6 +191,7 @@ export function ImportDialog({
177
191
  const clearSelectedFile = () => {
178
192
  setFile(null);
179
193
  setResult(null);
194
+ setRawResult(null);
180
195
  };
181
196
 
182
197
  const onDrop = useCallback(
@@ -216,6 +231,10 @@ export function ImportDialog({
216
231
 
217
232
  const handleDownloadTemplate = async () => {
218
233
  try {
234
+ if (onDownloadTemplate) {
235
+ await onDownloadTemplate();
236
+ return;
237
+ }
219
238
  if (config) {
220
239
  const blob = await generateXLSXTemplate(config);
221
240
  triggerBlobDownload(blob, `${config.name}_template.xlsx`);
@@ -242,6 +261,7 @@ export function ImportDialog({
242
261
 
243
262
  setLoading(true);
244
263
  setResult(null);
264
+ setRawResult(null);
245
265
 
246
266
  try {
247
267
  // Chế độ config kiểm tra ở client trước, để người dùng thấy lỗi cột mà
@@ -297,6 +317,7 @@ export function ImportDialog({
297
317
  setOpen(false);
298
318
  setFile(null);
299
319
  setResult(null);
320
+ setRawResult(null);
300
321
  setLoading(false);
301
322
  return;
302
323
  }
@@ -307,6 +328,7 @@ export function ImportDialog({
307
328
 
308
329
  const normalized = normalizeImportResult(raw);
309
330
  setResult(normalized);
331
+ setRawResult(raw);
310
332
 
311
333
  if (normalized.success && normalized.importedCount > 0) {
312
334
  if (successTimeoutRef.current) clearTimeout(successTimeoutRef.current);
@@ -314,6 +336,7 @@ export function ImportDialog({
314
336
  setOpen(false);
315
337
  setFile(null);
316
338
  setResult(null);
339
+ setRawResult(null);
317
340
  onSuccess?.();
318
341
  successTimeoutRef.current = null;
319
342
  }, 2000);
@@ -353,6 +376,7 @@ export function ImportDialog({
353
376
  if (!open) {
354
377
  setFile(null);
355
378
  setResult(null);
379
+ setRawResult(null);
356
380
  }
357
381
  }, [open]);
358
382
 
@@ -554,13 +578,23 @@ export function ImportDialog({
554
578
  </AlertDescription>
555
579
  </Alert>
556
580
  )}
581
+
582
+ {result && renderResultExtra?.(rawResult)}
557
583
  </div>
558
584
 
559
585
  <DialogFooter className="flex flex-row sm:justify-end gap-2 space-x-0 pt-2">
560
- <Button variant="outline" className="flex-1 sm:flex-none" onClick={() => setOpen(false)}>
586
+ <Button
587
+ variant="outline"
588
+ className="flex-1 sm:flex-none"
589
+ onClick={() => setOpen(false)}
590
+ >
561
591
  Đóng
562
592
  </Button>
563
- <Button className="flex-1 sm:flex-none" onClick={handleImport} disabled={!file || loading}>
593
+ <Button
594
+ className="flex-1 sm:flex-none"
595
+ onClick={handleImport}
596
+ disabled={!file || loading}
597
+ >
564
598
  {loading ? "Đang import..." : "Import"}
565
599
  </Button>
566
600
  </DialogFooter>
@@ -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