@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +1 -1
  3. package/src/audit/__tests__/prisma-audit-extension.test.ts +113 -0
  4. package/src/audit/prisma-audit-extension.ts +41 -13
  5. package/src/configs/status.ts +67 -0
  6. package/src/cron/__tests__/cron-single-runner.test.ts +260 -0
  7. package/src/cron/cron-schedule.ts +113 -20
  8. package/src/cron/db-cron-manager.ts +90 -7
  9. package/src/cron/simple-cron-job.ts +17 -6
  10. package/src/crud/__tests__/status-boolean-display.test.tsx +186 -0
  11. package/src/crud/components/crud-detail-dialog.tsx +21 -8
  12. package/src/crud/components/crud-field-renderer.tsx +20 -13
  13. package/src/crud/components/crud-filter-chips.tsx +14 -39
  14. package/src/crud/components/crud-filters/datetime-filter.tsx +58 -17
  15. package/src/crud/components/crud-provider.tsx +10 -2
  16. package/src/crud/components/crud-table-toolbar.tsx +38 -40
  17. package/src/crud/components/crud-table.tsx +13 -8
  18. package/src/crud/lib/coerce.ts +36 -10
  19. package/src/crud/lib/filter-defaults.test.ts +115 -0
  20. package/src/crud/lib/filter-defaults.ts +56 -0
  21. package/src/crud/lib/filter-display.ts +59 -0
  22. package/src/crud/lib/query-builder.ts +4 -0
  23. package/src/types/index.ts +16 -9
  24. package/src/ui/data-display/data-table/data-table-toolbar.tsx +8 -2
  25. package/src/ui/data-display/data-table/data-table.tsx +32 -4
  26. package/src/ui/layout/user-dropdown.tsx +30 -23
  27. package/src/ui/primitives/status-badge.tsx +14 -10
  28. package/src/ui/shared/status-indicator.tsx +266 -74
  29. package/src/utils/index.ts +87 -38
  30. 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 { 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,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
- {getFilterLabel(filter.name, filter.value).split(":")[0]}:
56
+ {config.filters?.find((f) => f.name === filter.name)?.label ??
57
+ filter.name}
58
+ :
87
59
  </span>
88
60
  <span className="font-semibold">
89
- {getFilterLabel(filter.name, filter.value).split(":")[1]?.trim()}
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, updateFilter, addFilter } = useCrudContext();
55
+ const { filters, addFilter, removeFilter } = useCrudContext();
22
56
  const currentFilter = filters.find((f) => f.name === filter.name);
23
- const [value, setValue] = useState<Date | [Date, Date] | null>(
24
- currentFilter?.value
25
- ? mode === "range"
26
- ? (currentFilter.value as [Date, Date])
27
- : (currentFilter.value as Date)
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 externalFilter = filters.find((f) => f.name === filter.name);
33
- if (externalFilter && externalFilter.value !== value) {
34
- setValue(
35
- mode === "range"
36
- ? (externalFilter.value as [Date, Date])
37
- : (externalFilter.value as Date),
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 CrudConfigContextValue,
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
- const [filters, setFilters] = useState<ActiveFilter[]>([]);
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, clearFilters, addFilter, removeFilter, search, setSearch } =
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
- <div className="space-y-2">
54
- <DataTableToolbar
55
- table={table}
56
- // Search
57
- searchEnabled={config.features?.search}
58
- searchValue={search}
59
- onSearchChange={setSearch}
60
- searchPlaceholder={`Search ${config.pluralLabel.toLowerCase()}...`}
61
- // Filters
62
- filters={dataTableFilters}
63
- activeFilters={activeFilters}
64
- // Custom filter builder overrides the default filter rendering
65
- filterBuilder={<FilterBuilder filters={config.filters!} />}
66
- // Filter callbacks
67
- onFiltersChange={(newFilters) => {
68
- if (newFilters.length === 0) {
69
- clearFilters();
70
- } else {
71
- // Complex syncing is handled by FilterBuilder/CrudFilterChips
72
- // This callback mainly handles the "Clear all" action
73
- clearFilters();
74
- }
75
- }}
76
- // View Options
77
- viewMode={viewMode}
78
- onViewModeChange={onViewModeChange}
79
- enableViewModeToggle={!!onViewModeChange}
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
- 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
+ }
@@ -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
+ });