@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.
- package/CHANGELOG.md +45 -0
- 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-filter-chips.tsx +14 -39
- package/src/crud/components/crud-filters/datetime-filter.tsx +58 -17
- package/src/crud/components/crud-provider.tsx +10 -2
- package/src/crud/components/crud-table-toolbar.tsx +38 -40
- package/src/crud/components/crud-table.tsx +13 -8
- package/src/crud/lib/coerce.ts +36 -10
- package/src/crud/lib/filter-defaults.test.ts +115 -0
- package/src/crud/lib/filter-defaults.ts +56 -0
- package/src/crud/lib/filter-display.ts +59 -0
- package/src/crud/lib/query-builder.ts +4 -0
- package/src/types/index.ts +16 -9
- package/src/ui/data-display/data-table/data-table-toolbar.tsx +8 -2
- 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
|
@@ -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;
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|