@goplusvn/core 0.1.55 → 0.1.56
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/PLATFORM.md +44 -0
- package/package.json +3 -1
- package/src/crud/components/crud-import-dialog.tsx +23 -421
- package/src/crud/components/crud-page.tsx +7 -12
- package/src/import/__tests__/import-dialog.test.tsx +141 -0
- package/src/import/__tests__/import-engine.test.ts +235 -0
- package/src/import/import-dialog.tsx +570 -0
- package/src/import/import-engine.ts +357 -0
- package/src/import/index.ts +39 -0
- package/src/import/types.ts +73 -0
- package/src/import/use-import.ts +156 -0
- package/src/ui/filters/__tests__/advanced-filter-builder.test.tsx +194 -0
- package/src/ui/filters/advanced-filter-builder.tsx +380 -0
- package/src/ui/filters/index.ts +7 -0
- package/src/ui/index.tsx +1 -0
- package/src/crud/crud-filters/checkbox-filter.tsx +0 -87
- package/src/crud/crud-filters/datetime-filter.tsx +0 -82
- package/src/crud/crud-filters/filter-builder.tsx +0 -64
- package/src/crud/crud-filters/index.tsx +0 -78
- package/src/crud/crud-filters/radio-filter.tsx +0 -79
- package/src/crud/crud-filters/select-filter.tsx +0 -148
- package/src/crud/crud-filters/text-filter.tsx +0 -81
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nửa CLIENT của filter-tree DSL. Chốt hai thứ đắt nếu sai: biên ngày (lọc
|
|
3
|
+
* "đến hết 31/07" phải ôm trọn ngày 31) và việc chỉ phát cây khi bấm "Áp dụng"
|
|
4
|
+
* — mỗi ký tự gõ mà đẩy URL thì danh sách nhấp nháy và Back hỏng.
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
AdvancedFilterBuilder,
|
|
11
|
+
countFilterTreeLeaves,
|
|
12
|
+
} from "../advanced-filter-builder";
|
|
13
|
+
import type {
|
|
14
|
+
AdvancedFilterField,
|
|
15
|
+
FilterTree,
|
|
16
|
+
} from "../advanced-filter-builder";
|
|
17
|
+
|
|
18
|
+
const FIELDS: AdvancedFilterField[] = [
|
|
19
|
+
{ name: "code", label: "Mã phiếu", type: "text" },
|
|
20
|
+
{ name: "total", label: "Thành tiền", type: "number" },
|
|
21
|
+
{ name: "createdAt", label: "Ngày tạo", type: "date" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function setup(value: FilterTree | null = null) {
|
|
25
|
+
const onApply = vi.fn();
|
|
26
|
+
render(
|
|
27
|
+
<AdvancedFilterBuilder
|
|
28
|
+
fields={FIELDS}
|
|
29
|
+
value={value}
|
|
30
|
+
onApply={onApply}
|
|
31
|
+
tzOffset="+07:00"
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
return { onApply, apply: () => fireEvent.click(screen.getByText("Áp dụng")) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("countFilterTreeLeaves", () => {
|
|
38
|
+
it("đếm lá xuyên qua nhóm lồng", () => {
|
|
39
|
+
expect(countFilterTreeLeaves(null)).toBe(0);
|
|
40
|
+
expect(
|
|
41
|
+
countFilterTreeLeaves({ field: "a", operator: "eq", value: 1 }),
|
|
42
|
+
).toBe(1);
|
|
43
|
+
expect(
|
|
44
|
+
countFilterTreeLeaves({
|
|
45
|
+
$and: [
|
|
46
|
+
{ field: "a", operator: "eq", value: 1 },
|
|
47
|
+
{
|
|
48
|
+
$or: [
|
|
49
|
+
{ field: "b", operator: "eq" },
|
|
50
|
+
{ field: "c", operator: "eq" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
).toBe(3);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("AdvancedFilterBuilder", () => {
|
|
60
|
+
it("không phát gì trong lúc gõ, chỉ phát khi bấm Áp dụng", () => {
|
|
61
|
+
const { onApply, apply } = setup();
|
|
62
|
+
fireEvent.click(screen.getByText("Thêm điều kiện"));
|
|
63
|
+
fireEvent.change(screen.getByPlaceholderText("Giá trị"), {
|
|
64
|
+
target: { value: "PN2026" },
|
|
65
|
+
});
|
|
66
|
+
expect(onApply).not.toHaveBeenCalled();
|
|
67
|
+
|
|
68
|
+
apply();
|
|
69
|
+
expect(onApply).toHaveBeenCalledWith({
|
|
70
|
+
field: "code",
|
|
71
|
+
operator: "contains",
|
|
72
|
+
value: "PN2026",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('"đến hết ngày" lấy 23:59:59.999 giờ VN, không phải nửa đêm', () => {
|
|
77
|
+
const { onApply, apply } = setup({
|
|
78
|
+
field: "createdAt",
|
|
79
|
+
operator: "lte",
|
|
80
|
+
value: "2026-07-31T16:59:59.999Z",
|
|
81
|
+
});
|
|
82
|
+
apply();
|
|
83
|
+
// 23:59:59.999 +07:00 == 16:59:59.999Z cùng ngày
|
|
84
|
+
expect(onApply).toHaveBeenCalledWith({
|
|
85
|
+
field: "createdAt",
|
|
86
|
+
operator: "lte",
|
|
87
|
+
value: "2026-07-31T16:59:59.999Z",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('"từ ngày" lấy đầu ngày giờ VN, mở ra bấm lại KHÔNG lùi ngày', () => {
|
|
92
|
+
// 31/07 00:00 +07:00 nằm ở 17:00Z ngày 30 — cắt thẳng chuỗi ISO sẽ hiện 30.
|
|
93
|
+
const { onApply, apply } = setup({
|
|
94
|
+
field: "createdAt",
|
|
95
|
+
operator: "gte",
|
|
96
|
+
value: "2026-07-30T17:00:00.000Z",
|
|
97
|
+
});
|
|
98
|
+
expect(screen.getByDisplayValue("2026-07-31")).toBeTruthy();
|
|
99
|
+
|
|
100
|
+
apply();
|
|
101
|
+
expect(onApply).toHaveBeenCalledWith({
|
|
102
|
+
field: "createdAt",
|
|
103
|
+
operator: "gte",
|
|
104
|
+
value: "2026-07-30T17:00:00.000Z",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("dựng lại draft từ cây đang áp, giữ nguyên chế độ bất kỳ", () => {
|
|
109
|
+
const { onApply, apply } = setup({
|
|
110
|
+
$or: [
|
|
111
|
+
{ field: "code", operator: "contains", value: "PN" },
|
|
112
|
+
{ field: "total", operator: "gte", value: 1000000 },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
expect(screen.getAllByPlaceholderText("Giá trị")).toHaveLength(2);
|
|
116
|
+
apply();
|
|
117
|
+
expect(onApply).toHaveBeenCalledWith({
|
|
118
|
+
$or: [
|
|
119
|
+
{ field: "code", operator: "contains", value: "PN" },
|
|
120
|
+
{ field: "total", operator: "gte", value: 1000000 },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("số nhập vào là number chứ không phải chuỗi", () => {
|
|
126
|
+
const { onApply, apply } = setup({
|
|
127
|
+
field: "total",
|
|
128
|
+
operator: "gte",
|
|
129
|
+
value: 500,
|
|
130
|
+
});
|
|
131
|
+
fireEvent.change(screen.getByPlaceholderText("Giá trị"), {
|
|
132
|
+
target: { value: "1500000" },
|
|
133
|
+
});
|
|
134
|
+
apply();
|
|
135
|
+
expect(onApply).toHaveBeenCalledWith({
|
|
136
|
+
field: "total",
|
|
137
|
+
operator: "gte",
|
|
138
|
+
value: 1500000,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("bỏ điều kiện chưa có giá trị; hết sạch thì phát null (xoá lọc)", () => {
|
|
143
|
+
const { onApply, apply } = setup();
|
|
144
|
+
fireEvent.click(screen.getByText("Thêm điều kiện"));
|
|
145
|
+
apply();
|
|
146
|
+
expect(onApply).toHaveBeenCalledWith(null);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("đổi sang bất kỳ thì gói bằng $or", () => {
|
|
150
|
+
const { onApply, apply } = setup({
|
|
151
|
+
$and: [
|
|
152
|
+
{ field: "code", operator: "contains", value: "PN" },
|
|
153
|
+
{ field: "total", operator: "gte", value: 100 },
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
fireEvent.click(screen.getByText("bất kỳ"));
|
|
157
|
+
apply();
|
|
158
|
+
expect(onApply.mock.calls[0][0]).toHaveProperty("$or");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("nút xoá lọc chỉ hiện khi đang có lọc, và phát null", () => {
|
|
162
|
+
render(
|
|
163
|
+
<AdvancedFilterBuilder fields={FIELDS} value={null} onApply={vi.fn()} />,
|
|
164
|
+
);
|
|
165
|
+
expect(screen.queryByText("Xoá lọc nâng cao")).toBeNull();
|
|
166
|
+
|
|
167
|
+
const onApply = vi.fn();
|
|
168
|
+
render(
|
|
169
|
+
<AdvancedFilterBuilder
|
|
170
|
+
fields={FIELDS}
|
|
171
|
+
value={{ field: "code", operator: "contains", value: "PN" }}
|
|
172
|
+
onApply={onApply}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
fireEvent.click(screen.getByText("Xoá lọc nâng cao"));
|
|
176
|
+
expect(onApply).toHaveBeenCalledWith(null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("xoá một điều kiện thì cây còn lại thu về lá đơn", () => {
|
|
180
|
+
const { onApply, apply } = setup({
|
|
181
|
+
$and: [
|
|
182
|
+
{ field: "code", operator: "contains", value: "PN" },
|
|
183
|
+
{ field: "total", operator: "gte", value: 100 },
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
fireEvent.click(screen.getAllByTitle("Xoá điều kiện")[1]);
|
|
187
|
+
apply();
|
|
188
|
+
expect(onApply).toHaveBeenCalledWith({
|
|
189
|
+
field: "code",
|
|
190
|
+
operator: "contains",
|
|
191
|
+
value: "PN",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Plus, X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import type { FilterTreeNode } from "../../crud/lib/filter-tree";
|
|
7
|
+
import { Button } from "../primitives/button";
|
|
8
|
+
import { Input } from "../primitives/input";
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "../primitives/select";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bộ lọc nâng cao dạng điều kiện ghép (trường + toán tử + giá trị, thoả TẤT CẢ
|
|
19
|
+
* hoặc BẤT KỲ) — sinh cây khớp `FilterTreeNode` cho `compileFilterTree` phía
|
|
20
|
+
* server (`@goerp/core/crud/server`). Đây là NỬA CLIENT của DSL đó.
|
|
21
|
+
*
|
|
22
|
+
* MVP một nhóm phẳng; DSL server đã hỗ trợ lồng sâu cho caller gọi API trực
|
|
23
|
+
* tiếp. Draft giữ cục bộ, chỉ phát ra khi bấm "Áp dụng" — gõ tới đâu đẩy URL
|
|
24
|
+
* tới đó thì mỗi ký tự là một lần điều hướng.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface AdvancedFilterField {
|
|
28
|
+
name: string;
|
|
29
|
+
label: string;
|
|
30
|
+
type: "text" | "number" | "date" | "select";
|
|
31
|
+
options?: { value: string; label: string }[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* CHÍNH LÀ kiểu server nhận (`import type` bị xoá lúc build nên không có
|
|
36
|
+
* module server nào lọt vào bundle client) — hai nửa không thể trôi khỏi nhau.
|
|
37
|
+
*/
|
|
38
|
+
export type FilterTree = FilterTreeNode;
|
|
39
|
+
|
|
40
|
+
interface ConditionDraft {
|
|
41
|
+
field: string;
|
|
42
|
+
operator: string;
|
|
43
|
+
value: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const OPERATORS: Record<
|
|
47
|
+
AdvancedFilterField["type"],
|
|
48
|
+
{ value: string; label: string; needsValue?: boolean }[]
|
|
49
|
+
> = {
|
|
50
|
+
text: [
|
|
51
|
+
{ value: "contains", label: "chứa" },
|
|
52
|
+
{ value: "eq", label: "bằng" },
|
|
53
|
+
{ value: "ne", label: "khác" },
|
|
54
|
+
{ value: "startsWith", label: "bắt đầu bằng" },
|
|
55
|
+
{ value: "isNull", label: "đang trống", needsValue: false },
|
|
56
|
+
{ value: "isNotNull", label: "có giá trị", needsValue: false },
|
|
57
|
+
],
|
|
58
|
+
number: [
|
|
59
|
+
{ value: "eq", label: "=" },
|
|
60
|
+
{ value: "ne", label: "≠" },
|
|
61
|
+
{ value: "gte", label: "≥" },
|
|
62
|
+
{ value: "lte", label: "≤" },
|
|
63
|
+
{ value: "gt", label: ">" },
|
|
64
|
+
{ value: "lt", label: "<" },
|
|
65
|
+
],
|
|
66
|
+
date: [
|
|
67
|
+
{ value: "gte", label: "từ ngày" },
|
|
68
|
+
{ value: "lte", label: "đến hết ngày" },
|
|
69
|
+
],
|
|
70
|
+
select: [
|
|
71
|
+
{ value: "eq", label: "là" },
|
|
72
|
+
{ value: "ne", label: "không là" },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const VALUELESS = new Set(["isNull", "isNotNull"]);
|
|
77
|
+
|
|
78
|
+
/** Múi giờ mặc định của các app GoERP. */
|
|
79
|
+
const DEFAULT_TZ_OFFSET = "+07:00";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Người dùng gõ ngày theo giờ địa phương; server so bằng mốc tuyệt đối. Quy về
|
|
83
|
+
* ĐÚNG BIÊN ngày: "đến hết ngày" phải là 23:59:59.999, không phải 00:00 (nếu
|
|
84
|
+
* không thì lọc "đến 31/07" bỏ mất toàn bộ chứng từ ngày 31).
|
|
85
|
+
*/
|
|
86
|
+
function dateLeafValue(
|
|
87
|
+
operator: string,
|
|
88
|
+
ymd: string,
|
|
89
|
+
tzOffset: string,
|
|
90
|
+
): string {
|
|
91
|
+
return operator === "lte" || operator === "lt"
|
|
92
|
+
? new Date(`${ymd}T23:59:59.999${tzOffset}`).toISOString()
|
|
93
|
+
: new Date(`${ymd}T00:00:00${tzOffset}`).toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function offsetMinutes(tzOffset: string): number {
|
|
97
|
+
const m = tzOffset.match(/^([+-])(\d{2}):?(\d{2})$/);
|
|
98
|
+
if (!m) return 0;
|
|
99
|
+
return (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Chiều ngược của `dateLeafValue`. Phải quy về NGÀY THEO MÚI GIỜ đó rồi mới
|
|
104
|
+
* cắt: đầu ngày VN nằm ở 17:00Z HÔM TRƯỚC, nên cắt thẳng chuỗi ISO sẽ hiện lùi
|
|
105
|
+
* một ngày — và mỗi lần mở ra bấm lại là lùi thêm một ngày nữa.
|
|
106
|
+
*/
|
|
107
|
+
function ymdInZone(value: unknown, tzOffset: string): string {
|
|
108
|
+
const raw = String(value);
|
|
109
|
+
const d = new Date(raw);
|
|
110
|
+
if (isNaN(d.getTime())) return raw.slice(0, 10);
|
|
111
|
+
return new Date(d.getTime() + offsetMinutes(tzOffset) * 60_000)
|
|
112
|
+
.toISOString()
|
|
113
|
+
.slice(0, 10);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildTree(
|
|
117
|
+
mode: "$and" | "$or",
|
|
118
|
+
drafts: ConditionDraft[],
|
|
119
|
+
fields: AdvancedFilterField[],
|
|
120
|
+
tzOffset: string,
|
|
121
|
+
): FilterTree | null {
|
|
122
|
+
const leaves: FilterTree[] = [];
|
|
123
|
+
for (const d of drafts) {
|
|
124
|
+
const field = fields.find((f) => f.name === d.field);
|
|
125
|
+
if (!field || !d.operator) continue;
|
|
126
|
+
if (VALUELESS.has(d.operator)) {
|
|
127
|
+
leaves.push({ field: d.field, operator: d.operator });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (d.value === "") continue;
|
|
131
|
+
let value: unknown = d.value;
|
|
132
|
+
if (field.type === "number") {
|
|
133
|
+
const n = Number(d.value);
|
|
134
|
+
if (!Number.isFinite(n)) continue;
|
|
135
|
+
value = n;
|
|
136
|
+
} else if (field.type === "date") {
|
|
137
|
+
value = dateLeafValue(d.operator, d.value, tzOffset);
|
|
138
|
+
}
|
|
139
|
+
leaves.push({ field: d.field, operator: d.operator, value });
|
|
140
|
+
}
|
|
141
|
+
if (leaves.length === 0) return null;
|
|
142
|
+
if (leaves.length === 1) return leaves[0];
|
|
143
|
+
return mode === "$and" ? { $and: leaves } : { $or: leaves };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Đếm số điều kiện trong cây — dùng cho badge trên toolbar. */
|
|
147
|
+
export function countFilterTreeLeaves(tree: FilterTree | null): number {
|
|
148
|
+
if (!tree) return 0;
|
|
149
|
+
if ("$and" in tree)
|
|
150
|
+
return tree.$and.reduce((s, c) => s + countFilterTreeLeaves(c), 0);
|
|
151
|
+
if ("$or" in tree)
|
|
152
|
+
return tree.$or.reduce((s, c) => s + countFilterTreeLeaves(c), 0);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function draftsFromTree(
|
|
157
|
+
tree: FilterTree | null,
|
|
158
|
+
fields: AdvancedFilterField[],
|
|
159
|
+
tzOffset: string,
|
|
160
|
+
): { mode: "$and" | "$or"; drafts: ConditionDraft[] } {
|
|
161
|
+
if (!tree) return { mode: "$and", drafts: [] };
|
|
162
|
+
const mode: "$and" | "$or" = "$or" in tree ? "$or" : "$and";
|
|
163
|
+
const children =
|
|
164
|
+
"$and" in tree ? tree.$and : "$or" in tree ? tree.$or : [tree];
|
|
165
|
+
const drafts: ConditionDraft[] = [];
|
|
166
|
+
for (const child of children) {
|
|
167
|
+
if ("$and" in child || "$or" in child) continue; // MVP không dựng lại nhóm lồng
|
|
168
|
+
const field = fields.find((f) => f.name === child.field);
|
|
169
|
+
if (!field) continue;
|
|
170
|
+
let value = "";
|
|
171
|
+
if (child.value !== undefined && child.value !== null) {
|
|
172
|
+
value =
|
|
173
|
+
field.type === "date"
|
|
174
|
+
? ymdInZone(child.value, tzOffset)
|
|
175
|
+
: String(child.value);
|
|
176
|
+
}
|
|
177
|
+
drafts.push({ field: child.field, operator: child.operator, value });
|
|
178
|
+
}
|
|
179
|
+
return { mode, drafts };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface AdvancedFilterBuilderProps {
|
|
183
|
+
fields: AdvancedFilterField[];
|
|
184
|
+
/** Cây đang áp (từ URL) — dựng lại draft khi mở. */
|
|
185
|
+
value: FilterTree | null;
|
|
186
|
+
/** `null` = xoá bộ lọc nâng cao. */
|
|
187
|
+
onApply: (tree: FilterTree | null) => void;
|
|
188
|
+
/** Múi giờ quy biên ngày, mặc định `+07:00`. */
|
|
189
|
+
tzOffset?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function AdvancedFilterBuilder({
|
|
193
|
+
fields,
|
|
194
|
+
value,
|
|
195
|
+
onApply,
|
|
196
|
+
tzOffset = DEFAULT_TZ_OFFSET,
|
|
197
|
+
}: AdvancedFilterBuilderProps) {
|
|
198
|
+
const [{ mode, drafts }, setState] = useState(() =>
|
|
199
|
+
draftsFromTree(value, fields, tzOffset),
|
|
200
|
+
);
|
|
201
|
+
// URL đổi từ ngoài (Back/reset) → đồng bộ lại draft.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
setState(draftsFromTree(value, fields, tzOffset));
|
|
204
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
205
|
+
}, [value]);
|
|
206
|
+
|
|
207
|
+
const setMode = (m: "$and" | "$or") => setState({ mode: m, drafts });
|
|
208
|
+
const setDraft = (i: number, patch: Partial<ConditionDraft>) =>
|
|
209
|
+
setState({
|
|
210
|
+
mode,
|
|
211
|
+
drafts: drafts.map((d, idx) => (idx === i ? { ...d, ...patch } : d)),
|
|
212
|
+
});
|
|
213
|
+
const addDraft = () =>
|
|
214
|
+
setState({
|
|
215
|
+
mode,
|
|
216
|
+
drafts: [
|
|
217
|
+
...drafts,
|
|
218
|
+
{
|
|
219
|
+
field: fields[0]?.name ?? "",
|
|
220
|
+
operator: OPERATORS[fields[0]?.type ?? "text"][0].value,
|
|
221
|
+
value: "",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
const removeDraft = (i: number) =>
|
|
226
|
+
setState({ mode, drafts: drafts.filter((_, idx) => idx !== i) });
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div className="space-y-2">
|
|
230
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
231
|
+
<span>Thoả</span>
|
|
232
|
+
<div className="flex overflow-hidden rounded-md border border-border">
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => setMode("$and")}
|
|
236
|
+
className={`px-2 py-1 transition-colors ${mode === "$and" ? "bg-muted font-semibold text-foreground" : "bg-card hover:text-foreground"}`}
|
|
237
|
+
>
|
|
238
|
+
tất cả
|
|
239
|
+
</button>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={() => setMode("$or")}
|
|
243
|
+
className={`border-l border-border px-2 py-1 transition-colors ${mode === "$or" ? "bg-muted font-semibold text-foreground" : "bg-card hover:text-foreground"}`}
|
|
244
|
+
>
|
|
245
|
+
bất kỳ
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
<span>điều kiện dưới đây</span>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{drafts.map((draft, i) => {
|
|
252
|
+
const field = fields.find((f) => f.name === draft.field) ?? fields[0];
|
|
253
|
+
const ops = OPERATORS[field.type];
|
|
254
|
+
const op = ops.find((o) => o.value === draft.operator) ?? ops[0];
|
|
255
|
+
return (
|
|
256
|
+
<div key={i} className="flex items-center gap-1.5">
|
|
257
|
+
<Select
|
|
258
|
+
value={draft.field}
|
|
259
|
+
onValueChange={(name) => {
|
|
260
|
+
const nf = fields.find((f) => f.name === name)!;
|
|
261
|
+
setDraft(i, {
|
|
262
|
+
field: name,
|
|
263
|
+
operator: OPERATORS[nf.type][0].value,
|
|
264
|
+
value: "",
|
|
265
|
+
});
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<SelectTrigger className="h-8 w-[38%] text-xs">
|
|
269
|
+
<SelectValue />
|
|
270
|
+
</SelectTrigger>
|
|
271
|
+
<SelectContent>
|
|
272
|
+
{fields.map((f) => (
|
|
273
|
+
<SelectItem key={f.name} value={f.name} className="text-xs">
|
|
274
|
+
{f.label}
|
|
275
|
+
</SelectItem>
|
|
276
|
+
))}
|
|
277
|
+
</SelectContent>
|
|
278
|
+
</Select>
|
|
279
|
+
|
|
280
|
+
<Select
|
|
281
|
+
value={draft.operator}
|
|
282
|
+
onValueChange={(operator) => setDraft(i, { operator })}
|
|
283
|
+
>
|
|
284
|
+
<SelectTrigger className="h-8 w-[26%] text-xs">
|
|
285
|
+
<SelectValue />
|
|
286
|
+
</SelectTrigger>
|
|
287
|
+
<SelectContent>
|
|
288
|
+
{ops.map((o) => (
|
|
289
|
+
<SelectItem key={o.value} value={o.value} className="text-xs">
|
|
290
|
+
{o.label}
|
|
291
|
+
</SelectItem>
|
|
292
|
+
))}
|
|
293
|
+
</SelectContent>
|
|
294
|
+
</Select>
|
|
295
|
+
|
|
296
|
+
{op.needsValue === false ? (
|
|
297
|
+
<div className="flex-1" />
|
|
298
|
+
) : field.type === "select" ? (
|
|
299
|
+
<Select
|
|
300
|
+
value={draft.value}
|
|
301
|
+
onValueChange={(v) => setDraft(i, { value: v })}
|
|
302
|
+
>
|
|
303
|
+
<SelectTrigger className="h-8 flex-1 text-xs">
|
|
304
|
+
<SelectValue placeholder="Chọn..." />
|
|
305
|
+
</SelectTrigger>
|
|
306
|
+
<SelectContent>
|
|
307
|
+
{(field.options ?? []).map((o) => (
|
|
308
|
+
<SelectItem
|
|
309
|
+
key={o.value}
|
|
310
|
+
value={o.value}
|
|
311
|
+
className="text-xs"
|
|
312
|
+
>
|
|
313
|
+
{o.label}
|
|
314
|
+
</SelectItem>
|
|
315
|
+
))}
|
|
316
|
+
</SelectContent>
|
|
317
|
+
</Select>
|
|
318
|
+
) : (
|
|
319
|
+
<Input
|
|
320
|
+
type={
|
|
321
|
+
field.type === "number"
|
|
322
|
+
? "number"
|
|
323
|
+
: field.type === "date"
|
|
324
|
+
? "date"
|
|
325
|
+
: "text"
|
|
326
|
+
}
|
|
327
|
+
value={draft.value}
|
|
328
|
+
onChange={(e) => setDraft(i, { value: e.target.value })}
|
|
329
|
+
className="h-8 flex-1 text-xs"
|
|
330
|
+
placeholder="Giá trị"
|
|
331
|
+
/>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
onClick={() => removeDraft(i)}
|
|
337
|
+
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
338
|
+
title="Xoá điều kiện"
|
|
339
|
+
>
|
|
340
|
+
<X className="h-3.5 w-3.5" />
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
})}
|
|
345
|
+
|
|
346
|
+
<div className="flex items-center justify-between pt-1">
|
|
347
|
+
<Button
|
|
348
|
+
type="button"
|
|
349
|
+
variant="ghost"
|
|
350
|
+
size="sm"
|
|
351
|
+
className="h-7 px-2 text-xs"
|
|
352
|
+
onClick={addDraft}
|
|
353
|
+
>
|
|
354
|
+
<Plus className="mr-1 h-3.5 w-3.5" /> Thêm điều kiện
|
|
355
|
+
</Button>
|
|
356
|
+
<div className="flex gap-1.5">
|
|
357
|
+
{value && (
|
|
358
|
+
<Button
|
|
359
|
+
type="button"
|
|
360
|
+
variant="ghost"
|
|
361
|
+
size="sm"
|
|
362
|
+
className="h-7 px-2 text-xs text-muted-foreground"
|
|
363
|
+
onClick={() => onApply(null)}
|
|
364
|
+
>
|
|
365
|
+
Xoá lọc nâng cao
|
|
366
|
+
</Button>
|
|
367
|
+
)}
|
|
368
|
+
<Button
|
|
369
|
+
type="button"
|
|
370
|
+
size="sm"
|
|
371
|
+
className="h-7 px-3 text-xs"
|
|
372
|
+
onClick={() => onApply(buildTree(mode, drafts, fields, tzOffset))}
|
|
373
|
+
>
|
|
374
|
+
Áp dụng
|
|
375
|
+
</Button>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
package/src/ui/index.tsx
CHANGED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
4
|
-
|
|
5
|
-
import { dataLoader } from "../lib/data-loader";
|
|
6
|
-
|
|
7
|
-
import { useCrudContext } from "../components/crud-provider";
|
|
8
|
-
import { Checkbox } from "../../ui/primitives/checkbox";
|
|
9
|
-
import { Label } from "../../ui/primitives/label";
|
|
10
|
-
import { ScrollArea } from "../../ui/primitives/scroll-area";
|
|
11
|
-
import type { FilterConfig } from "../../types";
|
|
12
|
-
|
|
13
|
-
export function CheckboxFilter({ filter }: { filter: FilterConfig }) {
|
|
14
|
-
const { filters, addFilter, removeFilter } = useCrudContext();
|
|
15
|
-
const currentFilter = filters.find((f: any) => f.name === filter.name);
|
|
16
|
-
const [value, setValue] = useState<string[]>(
|
|
17
|
-
currentFilter?.value
|
|
18
|
-
? (currentFilter.value as string[])
|
|
19
|
-
: (filter.defaultValue as string[]) || [],
|
|
20
|
-
);
|
|
21
|
-
const [options, setOptions] = useState<
|
|
22
|
-
Array<{ label: string; value: string | number | boolean }>
|
|
23
|
-
>(filter.options || []);
|
|
24
|
-
|
|
25
|
-
// Load options from API if dataSource is provided
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
if (filter.dataSource) {
|
|
28
|
-
dataLoader
|
|
29
|
-
.loadOptions(filter.dataSource)
|
|
30
|
-
.then(setOptions)
|
|
31
|
-
.catch((error) => {
|
|
32
|
-
console.error("Error loading filter options:", error);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
}, [filter.dataSource]);
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
const externalFilter = filters.find((f) => f.name === filter.name);
|
|
39
|
-
if (externalFilter && externalFilter.value !== value) {
|
|
40
|
-
setValue((externalFilter.value as string[]) || []);
|
|
41
|
-
}
|
|
42
|
-
}, [filters, filter.name, value]);
|
|
43
|
-
|
|
44
|
-
const handleChange = (optionValue: string | number, checked: boolean) => {
|
|
45
|
-
const stringValue = String(optionValue);
|
|
46
|
-
const updatedValues = checked
|
|
47
|
-
? [...value, stringValue]
|
|
48
|
-
: value.filter((v) => v !== stringValue);
|
|
49
|
-
|
|
50
|
-
setValue(updatedValues);
|
|
51
|
-
|
|
52
|
-
if (updatedValues.length === 0) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
addFilter({
|
|
57
|
-
name: filter.name,
|
|
58
|
-
value: updatedValues,
|
|
59
|
-
operator: filter.operator || "in",
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<div className="space-y-2">
|
|
65
|
-
<Label>{filter.label}</Label>
|
|
66
|
-
<div className="space-y-2">
|
|
67
|
-
{options.map((option) => (
|
|
68
|
-
<div key={String(option.value)} className="flex items-center space-x-2">
|
|
69
|
-
<Checkbox
|
|
70
|
-
id={`${filter.name}-${option.value}`}
|
|
71
|
-
checked={value.includes(String(option.value))}
|
|
72
|
-
onCheckedChange={(checked) =>
|
|
73
|
-
handleChange(String(option.value), checked === true)
|
|
74
|
-
}
|
|
75
|
-
/>
|
|
76
|
-
<Label
|
|
77
|
-
htmlFor={`${filter.name}-${option.value}`}
|
|
78
|
-
className="font-normal cursor-pointer"
|
|
79
|
-
>
|
|
80
|
-
{option.label}
|
|
81
|
-
</Label>
|
|
82
|
-
</div>
|
|
83
|
-
))}
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
);
|
|
87
|
-
}
|