@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
|
@@ -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,12 +5,19 @@ import { X } from "lucide-react";
|
|
|
5
5
|
import type { EntityConfig } from "../../types";
|
|
6
6
|
|
|
7
7
|
import { Badge } from "../../ui";
|
|
8
|
+
import { formatFilterValue } from "../lib/filter-display";
|
|
8
9
|
import { useCrudState } from "./crud-context";
|
|
9
10
|
|
|
10
11
|
interface CrudFilterChipsProps {
|
|
11
12
|
config: EntityConfig;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* @deprecated `CrudTableToolbar` KHÔNG còn render component này — thanh công cụ
|
|
17
|
+
* (`DataTableToolbar`) đã tự vẽ hàng chip, render cả hai thì mỗi bộ lọc hiện
|
|
18
|
+
* hai lần. Giữ export để app ngoài đang gọi trực tiếp không gãy khi nâng cấp.
|
|
19
|
+
*/
|
|
20
|
+
|
|
14
21
|
export function CrudFilterChips({ config }: CrudFilterChipsProps) {
|
|
15
22
|
const { filters, removeFilter, search, setSearch } = useCrudState();
|
|
16
23
|
|
|
@@ -18,43 +25,6 @@ export function CrudFilterChips({ config }: CrudFilterChipsProps) {
|
|
|
18
25
|
return null;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
const getFilterLabel = (filterName: string, value: unknown): string => {
|
|
22
|
-
const filterConfig = config.filters?.find((f) => f.name === filterName);
|
|
23
|
-
if (!filterConfig) return filterName;
|
|
24
|
-
|
|
25
|
-
// Handle different filter types
|
|
26
|
-
if (filterConfig.type === "select" || filterConfig.type === "radio") {
|
|
27
|
-
const option = filterConfig.options?.find(
|
|
28
|
-
(opt) => opt.value === value || String(opt.value) === String(value),
|
|
29
|
-
);
|
|
30
|
-
return option
|
|
31
|
-
? `${filterConfig.label}: ${option.label}`
|
|
32
|
-
: `${filterConfig.label}: ${String(value)}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (filterConfig.type === "checkbox" && Array.isArray(value)) {
|
|
36
|
-
const labels = value
|
|
37
|
-
.map((v) => {
|
|
38
|
-
const option = filterConfig.options?.find(
|
|
39
|
-
(opt) => opt.value === v || String(opt.value) === String(v),
|
|
40
|
-
);
|
|
41
|
-
return option ? option.label : String(v);
|
|
42
|
-
})
|
|
43
|
-
.filter(Boolean);
|
|
44
|
-
return `${filterConfig.label}: ${labels.join(", ")}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
filterConfig.type === "datetime" &&
|
|
49
|
-
Array.isArray(value) &&
|
|
50
|
-
value.length === 2
|
|
51
|
-
) {
|
|
52
|
-
return `${filterConfig.label}: ${new Date(value[0] as string).toLocaleDateString()} - ${new Date(value[1] as string).toLocaleDateString()}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return `${filterConfig.label}: ${String(value)}`;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
28
|
return (
|
|
59
29
|
<div className="flex items-center gap-2 flex-wrap">
|
|
60
30
|
{/* Search chip */}
|
|
@@ -83,10 +53,15 @@ export function CrudFilterChips({ config }: CrudFilterChipsProps) {
|
|
|
83
53
|
className="gap-1.5 px-2.5 py-1 text-xs font-medium hover:bg-secondary/80 transition-colors"
|
|
84
54
|
>
|
|
85
55
|
<span className="text-muted-foreground">
|
|
86
|
-
{
|
|
56
|
+
{config.filters?.find((f) => f.name === filter.name)?.label ??
|
|
57
|
+
filter.name}
|
|
58
|
+
:
|
|
87
59
|
</span>
|
|
88
60
|
<span className="font-semibold">
|
|
89
|
-
{
|
|
61
|
+
{formatFilterValue(
|
|
62
|
+
config.filters?.find((f) => f.name === filter.name),
|
|
63
|
+
filter.value,
|
|
64
|
+
)}
|
|
90
65
|
</span>
|
|
91
66
|
<button
|
|
92
67
|
onClick={() => removeFilter(filter.name)}
|
|
@@ -14,44 +14,85 @@ interface DateTimeFilterProps {
|
|
|
14
14
|
mode?: "single" | "range";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Giá trị bộ lọc ➝ `Date` cho bộ chọn.
|
|
19
|
+
*
|
|
20
|
+
* Bộ lọc chở CHUỖI "YYYY-MM-DD" (xem `filter-defaults`), nhưng URL/`coerceFilters`
|
|
21
|
+
* có thể trả về `Date`. Nhận cả hai, và dựng ngày trần bằng bộ dựng theo GIỜ ĐỊA
|
|
22
|
+
* PHƯƠNG chứ không phải `new Date("2026-08-23")` — chuỗi đó là 00:00 UTC, ở
|
|
23
|
+
* GMT+7 hiện ra vẫn đúng ngày, nhưng ở múi giờ âm thì lùi một ngày ngay trên
|
|
24
|
+
* lịch người dùng đang nhìn.
|
|
25
|
+
*/
|
|
26
|
+
function parseDateValue(val: unknown): Date | null {
|
|
27
|
+
if (!val) return null;
|
|
28
|
+
if (val instanceof Date) return Number.isNaN(val.getTime()) ? null : val;
|
|
29
|
+
if (typeof val !== "string") return null;
|
|
30
|
+
const bare = val.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
31
|
+
if (bare) {
|
|
32
|
+
return new Date(Number(bare[1]), Number(bare[2]) - 1, Number(bare[3]));
|
|
33
|
+
}
|
|
34
|
+
const parsed = new Date(val);
|
|
35
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseRangeValue(val: unknown): [Date, Date] | null {
|
|
39
|
+
if (!Array.isArray(val) || val.length !== 2) return null;
|
|
40
|
+
const from = parseDateValue(val[0]);
|
|
41
|
+
const to = parseDateValue(val[1]);
|
|
42
|
+
return from && to ? [from, to] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `Date` ➝ "YYYY-MM-DD" theo lịch người dùng đang nhìn. */
|
|
46
|
+
function toDateKey(date: Date): string {
|
|
47
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
48
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
export function DateTimeFilter({
|
|
18
52
|
filter,
|
|
19
53
|
mode = "single",
|
|
20
54
|
}: DateTimeFilterProps) {
|
|
21
|
-
const { filters,
|
|
55
|
+
const { filters, addFilter, removeFilter } = useCrudContext();
|
|
22
56
|
const currentFilter = filters.find((f) => f.name === filter.name);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
: null,
|
|
57
|
+
|
|
58
|
+
const [value, setValue] = useState<Date | [Date, Date] | null>(() =>
|
|
59
|
+
mode === "range"
|
|
60
|
+
? parseRangeValue(currentFilter?.value)
|
|
61
|
+
: parseDateValue(currentFilter?.value),
|
|
29
62
|
);
|
|
30
63
|
|
|
64
|
+
// Bộ lọc đổi từ bên ngoài (seed `defaultValue`, bấm X trên chip, Đặt lại) —
|
|
65
|
+
// ô nhập phải đi theo, kể cả khi bộ lọc bị GỠ (về rỗng, không giữ giá trị cũ).
|
|
31
66
|
useEffect(() => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
}, [filters, filter.name, mode, value]);
|
|
67
|
+
const external = filters.find((f) => f.name === filter.name);
|
|
68
|
+
setValue(
|
|
69
|
+
mode === "range"
|
|
70
|
+
? parseRangeValue(external?.value)
|
|
71
|
+
: parseDateValue(external?.value),
|
|
72
|
+
);
|
|
73
|
+
}, [filters, filter.name, mode]);
|
|
41
74
|
|
|
42
75
|
const handleChange = (newValue: Date | [Date, Date] | null) => {
|
|
43
76
|
setValue(newValue);
|
|
44
77
|
|
|
78
|
+
// Xoá ngày là GỠ bộ lọc. Trước đây nhánh này return sớm nên chip vẫn còn và
|
|
79
|
+
// danh sách vẫn bị lọc — không có đường nào bỏ lọc ngày.
|
|
45
80
|
if (!newValue) {
|
|
81
|
+
removeFilter(filter.name);
|
|
46
82
|
return;
|
|
47
83
|
}
|
|
48
84
|
|
|
49
85
|
const operator =
|
|
50
86
|
mode === "range" ? filter.operator || "between" : filter.operator || "eq";
|
|
51
87
|
|
|
88
|
+
// Gửi đi CHUỖI ngày trần: không mang giờ thì không có gì để lệch múi giờ
|
|
89
|
+
// trên đường qua URL. `Date.toISOString()` của 00:00 giờ VN là 17:00Z hôm
|
|
90
|
+
// TRƯỚC — đúng cái đã làm bộ lọc trả về dữ liệu của hôm qua.
|
|
52
91
|
addFilter({
|
|
53
92
|
name: filter.name,
|
|
54
|
-
value: newValue
|
|
93
|
+
value: Array.isArray(newValue)
|
|
94
|
+
? [toDateKey(newValue[0]), toDateKey(newValue[1])]
|
|
95
|
+
: toDateKey(newValue),
|
|
55
96
|
operator,
|
|
56
97
|
});
|
|
57
98
|
};
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
|
|
5
|
+
import { resolveFilterDefaults } from "../lib/filter-defaults";
|
|
6
|
+
|
|
5
7
|
import type {
|
|
6
8
|
ActiveFilter,
|
|
7
9
|
CrudPermissions,
|
|
@@ -28,7 +30,8 @@ import type {
|
|
|
28
30
|
|
|
29
31
|
// Combined interface for backward compatibility
|
|
30
32
|
interface CrudContextValue
|
|
31
|
-
extends
|
|
33
|
+
extends
|
|
34
|
+
CrudConfigContextValue,
|
|
32
35
|
CrudStateContextValue,
|
|
33
36
|
CrudSelectionContextValue {}
|
|
34
37
|
|
|
@@ -66,7 +69,12 @@ export function CrudProvider({
|
|
|
66
69
|
|
|
67
70
|
// --- State (Search, Filters, Pagination, Sorting) ---
|
|
68
71
|
const [search, setSearch] = useState<string>("");
|
|
69
|
-
|
|
72
|
+
// Bộ lọc khai `defaultValue` phải ĐANG CHẠY ngay khi trang mở, không chỉ nằm
|
|
73
|
+
// trong ngăn lọc. Trang danh sách theo ngày mà mở ra không lọc thì nạp cả
|
|
74
|
+
// lịch sử — xem `resolveFilterDefaults`.
|
|
75
|
+
const [filters, setFilters] = useState<ActiveFilter[]>(() =>
|
|
76
|
+
resolveFilterDefaults(initialConfig?.filters),
|
|
77
|
+
);
|
|
70
78
|
const [pagination, setPagination] = useState({ page: 1, pageSize: 10 });
|
|
71
79
|
|
|
72
80
|
// Validate defaultSort field exists in visible table fields
|
|
@@ -5,9 +5,9 @@ import type { Table } from "@tanstack/react-table";
|
|
|
5
5
|
|
|
6
6
|
import type { CrudPermissions, EntityConfig } from "../../types";
|
|
7
7
|
|
|
8
|
-
import { CrudFilterChips } from "./crud-filter-chips";
|
|
9
8
|
import { FilterBuilder } from "./crud-filters/filter-builder";
|
|
10
9
|
import { useCrudState } from "./crud-context";
|
|
10
|
+
import { formatFilterValue } from "../lib/filter-display";
|
|
11
11
|
import { DataTableToolbar } from "../../ui/data-display/data-table/data-table-toolbar";
|
|
12
12
|
import type { FilterConfig } from "../../ui/data-display/data-table/data-table-toolbar";
|
|
13
13
|
|
|
@@ -26,7 +26,7 @@ export function CrudTableToolbar<TData>({
|
|
|
26
26
|
viewMode = "table",
|
|
27
27
|
onViewModeChange,
|
|
28
28
|
}: CrudTableToolbarProps<TData>) {
|
|
29
|
-
const { filters,
|
|
29
|
+
const { filters, setFilters, clearFilters, search, setSearch } =
|
|
30
30
|
useCrudState();
|
|
31
31
|
|
|
32
32
|
// Convert Crud filters to DataTable filters
|
|
@@ -41,51 +41,49 @@ export function CrudTableToolbar<TData>({
|
|
|
41
41
|
}));
|
|
42
42
|
}, [config.filters]);
|
|
43
43
|
|
|
44
|
+
// Chip do MỘT chỗ vẽ: `DataTableToolbar`. Trước đây toolbar này render thêm
|
|
45
|
+
// `<CrudFilterChips>` ngay dưới, hai component đọc cùng mảng `filters` nên
|
|
46
|
+
// mỗi bộ lọc hiện hai chip — và hai chip in giá trị theo hai kiểu hỏng khác
|
|
47
|
+
// nhau (JSON thô / bị `split(":")` cắt cụt).
|
|
44
48
|
const activeFilters = useMemo(() => {
|
|
45
49
|
return filters.map((f) => ({
|
|
46
50
|
name: f.name,
|
|
47
51
|
value: f.value,
|
|
48
52
|
operator: f.operator,
|
|
53
|
+
displayValue: formatFilterValue(
|
|
54
|
+
config.filters?.find((cf) => cf.name === f.name),
|
|
55
|
+
f.value,
|
|
56
|
+
),
|
|
49
57
|
}));
|
|
50
|
-
}, [filters]);
|
|
58
|
+
}, [filters, config.filters]);
|
|
51
59
|
|
|
52
60
|
return (
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// Reset
|
|
81
|
-
onReset={() => {
|
|
82
|
-
setSearch("");
|
|
83
|
-
clearFilters();
|
|
84
|
-
}}
|
|
85
|
-
/>
|
|
86
|
-
|
|
87
|
-
{/* Second row: Filter chips (specific to CRUD complex filters) */}
|
|
88
|
-
<CrudFilterChips config={config} />
|
|
89
|
-
</div>
|
|
61
|
+
<DataTableToolbar
|
|
62
|
+
table={table}
|
|
63
|
+
// Search
|
|
64
|
+
searchEnabled={config.features?.search}
|
|
65
|
+
searchValue={search}
|
|
66
|
+
onSearchChange={setSearch}
|
|
67
|
+
searchPlaceholder={`Search ${config.pluralLabel.toLowerCase()}...`}
|
|
68
|
+
// Filters
|
|
69
|
+
filters={dataTableFilters}
|
|
70
|
+
activeFilters={activeFilters}
|
|
71
|
+
// Custom filter builder overrides the default filter rendering
|
|
72
|
+
filterBuilder={<FilterBuilder filters={config.filters!} />}
|
|
73
|
+
// Bấm X trên MỘT chip chỉ gỡ chip đó. Trước đây nhánh nào cũng gọi
|
|
74
|
+
// `clearFilters()` nên gỡ một cái là bay sạch bộ lọc còn lại.
|
|
75
|
+
onFiltersChange={(next) =>
|
|
76
|
+
setFilters(filters.filter((f) => next.some((n) => n.name === f.name)))
|
|
77
|
+
}
|
|
78
|
+
// View Options
|
|
79
|
+
viewMode={viewMode}
|
|
80
|
+
onViewModeChange={onViewModeChange}
|
|
81
|
+
enableViewModeToggle={!!onViewModeChange}
|
|
82
|
+
// Reset
|
|
83
|
+
onReset={() => {
|
|
84
|
+
setSearch("");
|
|
85
|
+
clearFilters();
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
90
88
|
);
|
|
91
89
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { FilterConfig } from "../../types";
|
|
4
|
+
|
|
5
|
+
import { resolveFilterDefaults, todayDateKey } from "./filter-defaults";
|
|
6
|
+
import { formatFilterValue } from "./filter-display";
|
|
7
|
+
|
|
8
|
+
const dateFilter: FilterConfig = {
|
|
9
|
+
name: "date",
|
|
10
|
+
label: "Ngày nghỉ",
|
|
11
|
+
type: "datetime",
|
|
12
|
+
field: "date",
|
|
13
|
+
operator: "eq",
|
|
14
|
+
defaultValue: "$today",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("resolveFilterDefaults", () => {
|
|
18
|
+
it("giải $today thành ngày trần theo lịch VN", () => {
|
|
19
|
+
const [filter] = resolveFilterDefaults([dateFilter]);
|
|
20
|
+
expect(filter).toEqual({
|
|
21
|
+
name: "date",
|
|
22
|
+
value: todayDateKey(),
|
|
23
|
+
operator: "eq",
|
|
24
|
+
});
|
|
25
|
+
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("$today với operator between ➝ hôm nay tới hết hôm nay", () => {
|
|
29
|
+
const [filter] = resolveFilterDefaults([
|
|
30
|
+
{ ...dateFilter, operator: "between" },
|
|
31
|
+
]);
|
|
32
|
+
expect(filter.value).toEqual([todayDateKey(), todayDateKey()]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("ngày VN tính theo múi giờ cố định, không theo giờ máy", () => {
|
|
36
|
+
// Ngưỡng đổi ngày ở VN là 17:00Z hôm trước; máy chạy UTC vẫn phải ra ngày VN.
|
|
37
|
+
const vn = new Intl.DateTimeFormat("en-CA", {
|
|
38
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
39
|
+
year: "numeric",
|
|
40
|
+
month: "2-digit",
|
|
41
|
+
day: "2-digit",
|
|
42
|
+
}).format(new Date());
|
|
43
|
+
expect(todayDateKey()).toBe(vn);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("bộ lọc không khai defaultValue ➝ không bật sẵn", () => {
|
|
47
|
+
expect(
|
|
48
|
+
resolveFilterDefaults([{ ...dateFilter, defaultValue: undefined }]),
|
|
49
|
+
).toEqual([]);
|
|
50
|
+
expect(resolveFilterDefaults(undefined)).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("giữ nguyên defaultValue thường (không phải token)", () => {
|
|
54
|
+
const [filter] = resolveFilterDefaults([
|
|
55
|
+
{
|
|
56
|
+
name: "status",
|
|
57
|
+
label: "Trạng thái",
|
|
58
|
+
type: "select",
|
|
59
|
+
field: "status",
|
|
60
|
+
defaultValue: "approved",
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
expect(filter).toEqual({
|
|
64
|
+
name: "status",
|
|
65
|
+
value: "approved",
|
|
66
|
+
operator: "eq",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("formatFilterValue", () => {
|
|
72
|
+
it("ngày hiện dd/MM/yyyy, không phải chuỗi ISO hay JSON", () => {
|
|
73
|
+
expect(formatFilterValue(dateFilter, "2026-08-21")).toBe("21/08/2026");
|
|
74
|
+
// 00:00 giờ VN ngày 21/08 = 17:00Z ngày 20/08 — vẫn phải đọc là 21/08.
|
|
75
|
+
expect(
|
|
76
|
+
formatFilterValue(dateFilter, new Date("2026-08-20T17:00:00.000Z")),
|
|
77
|
+
).toBe("21/08/2026");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("khoảng một ngày đọc gọn thành một ngày", () => {
|
|
81
|
+
expect(
|
|
82
|
+
formatFilterValue({ ...dateFilter, operator: "between" }, [
|
|
83
|
+
"2026-08-21",
|
|
84
|
+
"2026-08-21",
|
|
85
|
+
]),
|
|
86
|
+
).toBe("21/08/2026");
|
|
87
|
+
expect(
|
|
88
|
+
formatFilterValue({ ...dateFilter, operator: "between" }, [
|
|
89
|
+
"2026-08-21",
|
|
90
|
+
"2026-08-23",
|
|
91
|
+
]),
|
|
92
|
+
).toBe("21/08/2026 - 23/08/2026");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("select hiện nhãn tuỳ chọn, không hiện giá trị thô", () => {
|
|
96
|
+
const filter: FilterConfig = {
|
|
97
|
+
name: "status",
|
|
98
|
+
label: "Trạng thái",
|
|
99
|
+
type: "select",
|
|
100
|
+
field: "status",
|
|
101
|
+
options: [{ label: "Đã duyệt", value: "approved" }],
|
|
102
|
+
};
|
|
103
|
+
expect(formatFilterValue(filter, "approved")).toBe("Đã duyệt");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("giá trị có dấu hai chấm KHÔNG bị cắt cụt", () => {
|
|
107
|
+
const filter: FilterConfig = {
|
|
108
|
+
name: "note",
|
|
109
|
+
label: "Ghi chú",
|
|
110
|
+
type: "text",
|
|
111
|
+
field: "note",
|
|
112
|
+
};
|
|
113
|
+
expect(formatFilterValue(filter, "ca 1: 06:30")).toBe("ca 1: 06:30");
|
|
114
|
+
});
|
|
115
|
+
});
|