@goplusvn/core 0.1.88 → 0.1.90

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,76 @@
1
+ ## 0.1.90 — `between` phủ hết ngày cuối, và một bảng toán tử duy nhất
2
+
3
+ Tiếp bản 0.1.89. `buildListQuery` **chép lại** bảng toán tử thay vì gọi
4
+ `conditionForOperator` — và bản chép thiếu `between`. Hệ quả nối nhau:
5
+
6
+ - Trước 0.1.89: mảng `[từ, đến]` rơi xuống nhánh mặc định rồi đi thẳng vào
7
+ Prisma dạng array ⇒ nổ ở tầng driver. Bộ lọc khoảng ngày của app khai đúng
8
+ `type: "datetime"` + `operator: "between"` hỏng sẵn mà không ai báo.
9
+ - 0.1.89 thêm `between` nhưng dịch cận trên thành `lte`: bộ chọn ngày chỉ chở
10
+ ngày trần, `lte: 31/08` là chặn ở **00:00** ngày cuối ⇒ ngày cuối bị cắt gần
11
+ hết. Chọn 01/08–31/08 thì đơn tạo lúc 9h sáng 31/08 rơi ra ngoài. Không có
12
+ exception nào, chỉ thiếu dữ liệu — khó phát hiện hơn hẳn lỗi ở trên.
13
+
14
+ **Đổi**
15
+
16
+ - `crud/lib/filter-tree.ts` — `conditionForOperator` thêm `case "between"`,
17
+ dựng khoảng **nửa mở** `[từ, đến+1 ngày)` khi cận trên là ngày trần (chuỗi
18
+ `YYYY-MM-DD`, hoặc `Date` đúng nửa đêm UTC do `coerceFieldValue` sinh ra).
19
+ Mốc có giờ thật giữ nguyên `lte`; khoảng số vẫn là khoảng đóng.
20
+ KHÔNG cộng offset múi giờ ở đây — cận dưới cũng đang hiểu theo nửa đêm UTC,
21
+ thêm offset vào một đầu thì hai đầu lệch nhau. App cần biên theo lịch địa
22
+ phương thì quy đổi trước khi gửi.
23
+ - `crud/lib/query-builder.ts` — bỏ chuỗi `if/else` chép tay, gọi thẳng
24
+ `conditionForOperator`. Filter phẳng và `filterTree` từ nay không thể lệch
25
+ bảng toán tử nữa (có test đối chiếu từng toán tử giữa hai đường).
26
+
27
+ **Quy tắc rút ra**: comment ghi "dùng chung" mà code vẫn chép tay thì chỗ chép
28
+ sẽ tụt lại — hợp nhất thật, đừng ghi chú suông.
29
+ ## 0.1.89 — Bộ lọc CRUD: `defaultValue` sống lại, chip vẽ một lần và đọc được
30
+
31
+ Trang danh sách theo ngày (nghỉ phép, tăng ca, đăng ký suất ăn) mở ra là nạp
32
+ **cả lịch sử**: `FilterConfig.defaultValue` được khai trong config nhưng chỉ
33
+ 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
34
+ state `filters` — nên request đầu tiên đi ra không mang bộ lọc nào. Cùng màn
35
+ hình đó còn năm lỗi hiển thị khác của hàng chip.
36
+
37
+ **Đổi**
38
+
39
+ - `crud/lib/filter-defaults.ts` (mới) — `resolveFilterDefaults()` biến
40
+ `FilterConfig[]` thành `ActiveFilter[]` để seed thẳng vào state. Thêm token
41
+ `"$today"`: config khai `defaultValue: "$today"` thì nhận NGÀY HÔM NAY, và
42
+ với `operator: "between"` thì nhận `[hôm nay, hôm nay]`.
43
+ Ngày tính bằng `Intl.DateTimeFormat("en-CA", { timeZone })` chứ **không**
44
+ phải giờ máy: initializer của `useState` chạy cả trên server lẫn client, máy
45
+ chủ UTC và trình duyệt GMT+7 lệch ngày suốt 00:00–07:00 giờ VN ⇒ hydration
46
+ mismatch mỗi đêm. Giá trị seed là chuỗi `YYYY-MM-DD` trần, không phải `Date`,
47
+ để không kéo theo múi giờ khi đi qua JSON.
48
+ - `crud/components/crud-provider.tsx` — `useState<ActiveFilter[]>` seed bằng
49
+ `resolveFilterDefaults(initialConfig?.filters)`.
50
+ - `crud/lib/filter-display.ts` (mới) — `formatFilterValue()`: ngày in
51
+ `21/08/2026` theo `Asia/Ho_Chi_Minh`, khoảng cùng ngày gộp lại còn một ngày,
52
+ giá trị select in nhãn tuỳ chọn thay vì mã.
53
+ - `crud/components/crud-table-toolbar.tsx` — thôi render `<CrudFilterChips>`.
54
+ `DataTableToolbar` đã tự vẽ hàng chip, render cả hai thì **mỗi bộ lọc hiện
55
+ hai chip**, mỗi chip hỏng một kiểu: một cái `JSON.stringify(Date)` ra chuỗi
56
+ ISO có nháy (`"2026-08-20T17:00:00.000Z"`), một cái `split(":")` cắt cụt
57
+ (`Fri Aug 21 2026 00`, và giá trị nào chứa dấu hai chấm cũng mất đuôi).
58
+ Chip giờ nhận `displayValue` đã format sẵn.
59
+ - `crud/components/crud-table-toolbar.tsx` — bấm X trên **một** chip trước đây
60
+ gọi `clearFilters()` ⇒ bay sạch bộ lọc còn lại. Nay chỉ gỡ đúng chip đó.
61
+ - `crud/components/crud-filters/datetime-filter.tsx` — xoá trắng ô ngày giờ
62
+ thật sự `removeFilter()`; đọc được cả `YYYY-MM-DD`, `Date` và chuỗi ISO; gửi
63
+ lên dạng `YYYY-MM-DD` thay vì `toISOString()` (mốc ISO của nửa đêm giờ VN là
64
+ `17:00Z` **hôm trước**, cột `@db.Date` cắt theo UTC ⇒ lọc ngày 20 ra ngày 19).
65
+ - `crud/lib/query-builder.ts` — thêm nhánh `between`: thiếu nó thì mảng
66
+ `[từ, đến]` rơi xuống `else` và đi thẳng vào Prisma dạng array ⇒ nổ ở driver.
67
+ - `ui/data-display/data-table/data-table-toolbar.tsx` — `ActiveFilter` thêm
68
+ `displayValue?: string` (tuỳ chọn, không breaking).
69
+ - `crud/components/crud-filter-chips.tsx` — giữ export cho app gọi trực tiếp,
70
+ đánh `@deprecated`, và sửa luôn phần format value.
71
+
72
+ **Quy tắc rút ra**: mọi phép tính "hôm nay" chạy trong component `"use client"`
73
+ phải chốt `timeZone` tường minh — giờ máy khác nhau giữa hai lần render.
1
74
  ## 0.1.85 — Hộp thoại: gỡ bẫy class `sm:` khiến trang không ghi đè được
2
75
 
3
76
  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.90",
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,123 @@
1
+ /**
2
+ * BỘ LỌC KHOẢNG NGÀY — `between`.
3
+ *
4
+ * Hai lỗi nối nhau, cùng một gốc: `buildListQuery` chép lại bảng toán tử thay
5
+ * vì gọi `conditionForOperator`, và bản chép thiếu `between`.
6
+ *
7
+ * 1. Trước 0.1.89: mảng `[từ, đến]` rơi xuống nhánh mặc định, đi thẳng vào
8
+ * Prisma dạng array ⇒ nổ ở tầng driver. Hai bộ lọc khoảng ngày của vinhhoa
9
+ * (orders.createdAt, purchaseOrders.orderDate) hỏng sẵn mà không ai báo.
10
+ * 2. 0.1.89 dịch thành `lte` ⇒ chặn ở 00:00 ngày cuối, NGÀY CUỐI BỊ CẮT gần
11
+ * hết. Đây mới là kiểu hỏng nguy hiểm: không exception, chỉ thiếu dữ liệu.
12
+ *
13
+ * Bản vá: khoảng nửa mở `[từ, đến+1 ngày)`, và hai đường (filter phẳng /
14
+ * `filterTree`) dùng CHUNG một bảng toán tử để không lệch nhau lần nữa.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import type { EntityConfig, FilterOperator } from "../../types";
19
+
20
+ import { buildListQuery } from "./query-builder";
21
+ import { conditionForOperator } from "./filter-tree";
22
+
23
+ const config = {
24
+ name: "orders",
25
+ label: "Đơn",
26
+ pluralLabel: "Đơn",
27
+ fields: [
28
+ { name: "createdAt", label: "Ngày tạo", type: "datetime" },
29
+ { name: "total", label: "Tổng", type: "number" },
30
+ ],
31
+ } as unknown as EntityConfig;
32
+
33
+ function whereFor(value: unknown) {
34
+ return buildListQuery({
35
+ entity: "orders",
36
+ config,
37
+ params: {
38
+ page: 1,
39
+ pageSize: 10,
40
+ filters: [{ name: "createdAt", value, operator: "between" }],
41
+ },
42
+ }).where.createdAt;
43
+ }
44
+
45
+ describe("between — cận trên phải phủ hết ngày cuối", () => {
46
+ it("ngày trần dạng chuỗi ➝ nửa mở tới nửa đêm hôm sau", () => {
47
+ expect(whereFor(["2026-08-01", "2026-08-31"])).toEqual({
48
+ gte: "2026-08-01",
49
+ lt: new Date("2026-09-01T00:00:00.000Z"),
50
+ });
51
+ });
52
+
53
+ it("Date nửa đêm UTC (đã qua coerceFieldValue) cũng được nới", () => {
54
+ const where = whereFor([
55
+ new Date("2026-08-01T00:00:00.000Z"),
56
+ new Date("2026-08-31T00:00:00.000Z"),
57
+ ]);
58
+
59
+ expect((where as { lt: Date }).lt.toISOString()).toBe(
60
+ "2026-09-01T00:00:00.000Z",
61
+ );
62
+ });
63
+
64
+ it("bản ghi lúc 9h sáng ngày cuối nằm TRONG khoảng", () => {
65
+ const { lt } = whereFor(["2026-08-01", "2026-08-31"]) as { lt: Date };
66
+ expect(new Date("2026-08-31T09:00:00.000Z").getTime()).toBeLessThan(
67
+ lt.getTime(),
68
+ );
69
+ });
70
+
71
+ it("mốc CÓ giờ thật thì giữ nguyên lte, không tự nới thêm một ngày", () => {
72
+ const end = new Date("2026-08-31T12:30:00.000Z");
73
+ expect(whereFor(["2026-08-01T00:00:00.000Z", end])).toEqual({
74
+ gte: "2026-08-01T00:00:00.000Z",
75
+ lte: end,
76
+ });
77
+ });
78
+
79
+ it("mảng không đủ hai mốc ➝ trả nguyên giá trị, không dựng khoảng méo", () => {
80
+ expect(conditionForOperator("between", ["2026-08-01"])).toEqual([
81
+ "2026-08-01",
82
+ ]);
83
+ });
84
+
85
+ it("khoảng số vẫn là khoảng đóng — chỉ NGÀY mới được nới", () => {
86
+ expect(conditionForOperator("between", [1000, 5000])).toEqual({
87
+ gte: 1000,
88
+ lte: 5000,
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("filter phẳng và filterTree dùng chung một bảng toán tử", () => {
94
+ const cases: [FilterOperator, unknown][] = [
95
+ ["contains", "an"],
96
+ ["in", [1, 2]],
97
+ ["notIn", [3]],
98
+ ["eq", 5],
99
+ ["ne", 5],
100
+ ["gt", 5],
101
+ ["gte", 5],
102
+ ["lt", 5],
103
+ ["lte", 5],
104
+ ["startsWith", "a"],
105
+ ["endsWith", "z"],
106
+ ["isNotNull", 1],
107
+ ["between", ["2026-08-01", "2026-08-31"]],
108
+ ];
109
+
110
+ it.each(cases)("%s cho cùng một điều kiện ở cả hai đường", (op, value) => {
111
+ const flat = buildListQuery({
112
+ entity: "orders",
113
+ config,
114
+ params: {
115
+ page: 1,
116
+ pageSize: 10,
117
+ filters: [{ name: "total", value, operator: op }],
118
+ },
119
+ }).where.total;
120
+
121
+ expect(flat).toEqual(conditionForOperator(op, value));
122
+ });
123
+ });
@@ -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
+ }
@@ -29,11 +29,46 @@ export type FilterTreeNode =
29
29
  const MAX_DEPTH = 4;
30
30
  const MAX_LEAVES = 30;
31
31
 
32
+ const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
33
+ const DAY_MS = 86_400_000;
34
+
35
+ /**
36
+ * Cận TRÊN của `between` khi mốc gửi xuống không mang giờ.
37
+ *
38
+ * Bộ chọn ngày chỉ chở ngày trần ("2026-08-31", hoặc `Date` đã ép về nửa đêm
39
+ * UTC — xem `coerceFieldValue`). Dịch thẳng thành `lte` là chặn ở 00:00 của
40
+ * ngày cuối, tức NGÀY CUỐI BỊ CẮT gần hết: chọn 01/08–31/08 thì bản ghi tạo
41
+ * lúc 9h sáng 31/08 rơi ra ngoài. Người dùng không thấy lỗi, chỉ thấy thiếu
42
+ * dữ liệu — kiểu hỏng khó phát hiện hơn hẳn một cái exception.
43
+ *
44
+ * Nên khoảng dựng NỬA MỞ: `[từ, đến+1 ngày)`. Không cộng múi giờ ở đây —
45
+ * cận dưới cũng đang hiểu theo nửa đêm UTC, thêm offset vào một đầu thì hai
46
+ * đầu lệch nhau. App nào cần biên theo lịch địa phương thì tự quy đổi trước
47
+ * khi gửi (xem cách tanloc-spartronics dùng `vnDateKeyToDbDate`).
48
+ *
49
+ * Mốc CÓ giờ thật thì giữ nguyên `lte` — đó là ý người gọi, không phải ngày trần.
50
+ */
51
+ function upperDateBound(value: unknown): Record<string, unknown> {
52
+ if (typeof value === "string" && DATE_KEY.test(value)) {
53
+ return { lt: new Date(Date.parse(`${value}T00:00:00.000Z`) + DAY_MS) };
54
+ }
55
+ // Nửa đêm UTC chằn chặn = ngày trần đã qua `new Date("YYYY-MM-DD")`.
56
+ if (value instanceof Date && value.getTime() % DAY_MS === 0) {
57
+ return { lt: new Date(value.getTime() + DAY_MS) };
58
+ }
59
+ return { lte: value };
60
+ }
61
+
32
62
  /** Điều kiện Prisma cho một toán tử — dùng chung với filter phẳng. */
33
63
  export function conditionForOperator(op: string, value: unknown): unknown {
34
64
  switch (op) {
35
65
  case "contains":
36
66
  return { contains: value, mode: "insensitive" };
67
+ // Khoảng [từ, đến] của bộ chọn ngày. Trước 0.1.89 KHÔNG có nhánh này: cả
68
+ // mảng rơi xuống `default` và đi thẳng vào Prisma dạng array ⇒ nổ ở driver.
69
+ case "between":
70
+ if (!Array.isArray(value) || value.length !== 2) return value;
71
+ return { gte: value[0], ...upperDateBound(value[1]) };
37
72
  case "in":
38
73
  return { in: value };
39
74
  case "notIn":
@@ -1,4 +1,4 @@
1
- import { compileFilterTree } from "./filter-tree";
1
+ import { compileFilterTree, conditionForOperator } from "./filter-tree";
2
2
  import { CrudRequestError } from "./errors";
3
3
  import type { CrudQueryParams, EntityConfig } from "../../types";
4
4
 
@@ -58,21 +58,10 @@ export function buildListQuery({
58
58
  key = parts.pop()!;
59
59
  for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
60
60
  }
61
- const op = operator as string;
62
- if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
63
- else if (op === "in") target[key] = { in: value };
64
- else if (op === "notIn") target[key] = { notIn: value };
65
- else if (op === "eq") target[key] = value;
66
- else if (op === "ne") target[key] = { not: value };
67
- else if (op === "gt") target[key] = { gt: value };
68
- else if (op === "gte") target[key] = { gte: value };
69
- else if (op === "lt") target[key] = { lt: value };
70
- else if (op === "lte") target[key] = { lte: value };
71
- else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
72
- else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
73
- else if (op === "isNull") target[key] = null;
74
- else if (op === "isNotNull") target[key] = { not: null };
75
- else target[key] = value;
61
+ // MỘT bảng toán tử cho cả hai đường (filter phẳng ở đây, `filterTree` ở
62
+ // dưới). Trước đây chỗ này chép lại chuỗi if riêng, bản chép thiếu
63
+ // `between` bộ lọc khoảng ngày của app khai đúng type vẫn nổ ở driver.
64
+ target[key] = conditionForOperator(operator as string, value);
76
65
  }
77
66
  }
78
67
 
@@ -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