@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 +45 -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-defaults.test.ts +115 -0
- package/src/crud/lib/filter-defaults.ts +56 -0
- package/src/crud/lib/filter-display.ts +59 -0
- package/src/crud/lib/query-builder.ts +4 -0
- package/src/types/index.ts +7 -0
- package/src/ui/data-display/data-table/data-table-toolbar.tsx +8 -2
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
|
@@ -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,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;
|
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
|