@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 +73 -0
- package/package.json +1 -1
- 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/lib/filter-between.test.ts +123 -0
- 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/filter-tree.ts +35 -0
- package/src/crud/lib/query-builder.ts +5 -16
- package/src/types/index.ts +7 -0
- package/src/ui/data-display/data-table/data-table-toolbar.tsx +8 -2
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
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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, và 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
|
|
package/src/types/index.ts
CHANGED
|
@@ -653,6 +653,13 @@ export interface FilterConfig {
|
|
|
653
653
|
type: FilterType;
|
|
654
654
|
field: string;
|
|
655
655
|
operator?: FilterOperator;
|
|
656
|
+
/**
|
|
657
|
+
* Bộ lọc BẬT SẴN khi trang vừa mở — `CrudProvider` seed thẳng vào state
|
|
658
|
+
* `filters`, nên nó hiện thành chip và người dùng thấy mình đang xem cái gì.
|
|
659
|
+
*
|
|
660
|
+
* Token `"$today"` = hôm nay theo lịch VN; với `operator: "between"` nó giải
|
|
661
|
+
* thành cặp [hôm nay, hôm nay]. Xem `crud/lib/filter-defaults`.
|
|
662
|
+
*/
|
|
656
663
|
defaultValue?: unknown;
|
|
657
664
|
options?: Array<{ label: string; value: string | number | boolean }>;
|
|
658
665
|
dataSource?: DataSource;
|
|
@@ -27,6 +27,11 @@ export interface ActiveFilter {
|
|
|
27
27
|
name: string;
|
|
28
28
|
value: unknown;
|
|
29
29
|
operator: string;
|
|
30
|
+
/**
|
|
31
|
+
* Chữ đã dựng sẵn cho chip. Lớp trên biết `FilterConfig` (nhãn tuỳ chọn,
|
|
32
|
+
* kiểu ngày) nên nó dựng; toolbar chỉ in ra. Không truyền thì rơi về cách cũ.
|
|
33
|
+
*/
|
|
34
|
+
displayValue?: string;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export interface FilterConfig {
|
|
@@ -345,9 +350,10 @@ export function DataTableToolbar<TData>({
|
|
|
345
350
|
const filterConfig = filters.find((f) => f.name === filter.name);
|
|
346
351
|
const label = filterConfig?.label || filter.name;
|
|
347
352
|
const displayValue =
|
|
348
|
-
|
|
353
|
+
filter.displayValue ??
|
|
354
|
+
(typeof filter.value === "object"
|
|
349
355
|
? JSON.stringify(filter.value)
|
|
350
|
-
: String(filter.value);
|
|
356
|
+
: String(filter.value));
|
|
351
357
|
|
|
352
358
|
return (
|
|
353
359
|
<div
|