@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.
- package/package.json +1 -1
- package/src/audit/__tests__/prisma-audit-extension.test.ts +113 -0
- package/src/audit/prisma-audit-extension.ts +41 -13
- package/src/configs/status.ts +67 -0
- package/src/cron/__tests__/cron-single-runner.test.ts +260 -0
- package/src/cron/cron-schedule.ts +113 -20
- package/src/cron/db-cron-manager.ts +90 -7
- package/src/cron/simple-cron-job.ts +17 -6
- package/src/crud/__tests__/status-boolean-display.test.tsx +186 -0
- package/src/crud/components/crud-detail-dialog.tsx +21 -8
- package/src/crud/components/crud-field-renderer.tsx +20 -13
- package/src/crud/components/crud-table.tsx +13 -8
- package/src/crud/lib/coerce.ts +36 -10
- package/src/types/index.ts +9 -9
- package/src/ui/data-display/data-table/data-table.tsx +32 -4
- package/src/ui/layout/user-dropdown.tsx +30 -23
- package/src/ui/primitives/status-badge.tsx +14 -10
- package/src/ui/shared/status-indicator.tsx +266 -74
- package/src/utils/index.ts +87 -38
- package/src/workspace/__tests__/workspace-delegation.test.ts +29 -11
|
@@ -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 {
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
211
|
+
return matchesOptionValue(optValue, value);
|
|
208
212
|
});
|
|
209
|
-
const label =
|
|
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"
|
package/src/crud/lib/coerce.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// Không có 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
|