@goplusvn/core 0.1.88 → 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 CHANGED
@@ -1,3 +1,48 @@
1
+ ## 0.1.89 — Bộ lọc CRUD: `defaultValue` sống lại, chip vẽ một lần và đọc được
2
+
3
+ Trang danh sách theo ngày (nghỉ phép, tăng ca, đăng ký suất ăn) mở ra là nạp
4
+ **cả lịch sử**: `FilterConfig.defaultValue` được khai trong config nhưng chỉ
5
+ dùng làm giá trị ban đầu cho ô nhập trong ngăn lọc, không nhánh nào đẩy nó vào
6
+ state `filters` — nên request đầu tiên đi ra không mang bộ lọc nào. Cùng màn
7
+ hình đó còn năm lỗi hiển thị khác của hàng chip.
8
+
9
+ **Đổi**
10
+
11
+ - `crud/lib/filter-defaults.ts` (mới) — `resolveFilterDefaults()` biến
12
+ `FilterConfig[]` thành `ActiveFilter[]` để seed thẳng vào state. Thêm token
13
+ `"$today"`: config khai `defaultValue: "$today"` thì nhận NGÀY HÔM NAY, và
14
+ với `operator: "between"` thì nhận `[hôm nay, hôm nay]`.
15
+ Ngày tính bằng `Intl.DateTimeFormat("en-CA", { timeZone })` chứ **không**
16
+ phải giờ máy: initializer của `useState` chạy cả trên server lẫn client, máy
17
+ chủ UTC và trình duyệt GMT+7 lệch ngày suốt 00:00–07:00 giờ VN ⇒ hydration
18
+ mismatch mỗi đêm. Giá trị seed là chuỗi `YYYY-MM-DD` trần, không phải `Date`,
19
+ để không kéo theo múi giờ khi đi qua JSON.
20
+ - `crud/components/crud-provider.tsx` — `useState<ActiveFilter[]>` seed bằng
21
+ `resolveFilterDefaults(initialConfig?.filters)`.
22
+ - `crud/lib/filter-display.ts` (mới) — `formatFilterValue()`: ngày in
23
+ `21/08/2026` theo `Asia/Ho_Chi_Minh`, khoảng cùng ngày gộp lại còn một ngày,
24
+ giá trị select in nhãn tuỳ chọn thay vì mã.
25
+ - `crud/components/crud-table-toolbar.tsx` — thôi render `<CrudFilterChips>`.
26
+ `DataTableToolbar` đã tự vẽ hàng chip, render cả hai thì **mỗi bộ lọc hiện
27
+ hai chip**, mỗi chip hỏng một kiểu: một cái `JSON.stringify(Date)` ra chuỗi
28
+ ISO có nháy (`"2026-08-20T17:00:00.000Z"`), một cái `split(":")` cắt cụt
29
+ (`Fri Aug 21 2026 00`, và giá trị nào chứa dấu hai chấm cũng mất đuôi).
30
+ Chip giờ nhận `displayValue` đã format sẵn.
31
+ - `crud/components/crud-table-toolbar.tsx` — bấm X trên **một** chip trước đây
32
+ gọi `clearFilters()` ⇒ bay sạch bộ lọc còn lại. Nay chỉ gỡ đúng chip đó.
33
+ - `crud/components/crud-filters/datetime-filter.tsx` — xoá trắng ô ngày giờ
34
+ thật sự `removeFilter()`; đọc được cả `YYYY-MM-DD`, `Date` và chuỗi ISO; gửi
35
+ lên dạng `YYYY-MM-DD` thay vì `toISOString()` (mốc ISO của nửa đêm giờ VN là
36
+ `17:00Z` **hôm trước**, cột `@db.Date` cắt theo UTC ⇒ lọc ngày 20 ra ngày 19).
37
+ - `crud/lib/query-builder.ts` — thêm nhánh `between`: thiếu nó thì mảng
38
+ `[từ, đến]` rơi xuống `else` và đi thẳng vào Prisma dạng array ⇒ nổ ở driver.
39
+ - `ui/data-display/data-table/data-table-toolbar.tsx` — `ActiveFilter` thêm
40
+ `displayValue?: string` (tuỳ chọn, không breaking).
41
+ - `crud/components/crud-filter-chips.tsx` — giữ export cho app gọi trực tiếp,
42
+ đánh `@deprecated`, và sửa luôn phần format value.
43
+
44
+ **Quy tắc rút ra**: mọi phép tính "hôm nay" chạy trong component `"use client"`
45
+ phải chốt `timeZone` tường minh — giờ máy khác nhau giữa hai lần render.
1
46
  ## 0.1.85 — Hộp thoại: gỡ bẫy class `sm:` khiến trang không ghi đè được
2
47
 
3
48
  Sửa lỗi giao diện do 0.1.84 gây ra. `DialogContent` đổi mặc định thành
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.88",
4
+ "version": "0.1.89",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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
  }
@@ -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
+ });
@@ -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;
@@ -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
- typeof filter.value === "object"
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