@goplusvn/core 0.1.55 → 0.1.57

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.
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ AdvancedFilterBuilder,
3
+ countFilterTreeLeaves,
4
+ type AdvancedFilterBuilderProps,
5
+ type AdvancedFilterField,
6
+ type FilterTree,
7
+ } from "./advanced-filter-builder";
package/src/ui/index.tsx CHANGED
@@ -10,6 +10,7 @@ export * from "./primitives/client";
10
10
  export * from "./forms";
11
11
  export * from "./data-display";
12
12
  export * from "./feedback";
13
+ export * from "./filters";
13
14
  export * from "./layout";
14
15
  export * from "./auth";
15
16
  // export * from "./crud"
@@ -245,13 +245,13 @@ export function CommandMenu({
245
245
  buttonClassName,
246
246
  )}
247
247
  onClick={() => setOpen(true)}
248
- aria-label={dictionary.search.search}
248
+ aria-label={dictionary?.search?.search ?? "Tìm kiếm"}
249
249
  {...props}
250
250
  >
251
251
  <Search className={cn("h-4 w-4", variant !== "icon" && "me-2")} />
252
252
  {variant !== "icon" && (
253
253
  <>
254
- <span>{dictionary.search.search}</span>
254
+ <span>{dictionary?.search?.search ?? "Tìm kiếm"}</span>
255
255
  {variant !== "fiori" && <Keyboard className="ms-auto">K</Keyboard>}
256
256
  </>
257
257
  )}
@@ -270,7 +270,7 @@ export function CommandMenu({
270
270
  <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 shrink-0 opacity-50" />
271
271
  <input
272
272
  className="flex h-11 w-full rounded-md bg-transparent py-3 pl-10 pr-10 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
273
- placeholder={dictionary.search.typeCommand}
273
+ placeholder={dictionary?.search?.typeCommand ?? "Nhập lệnh hoặc từ khoá…"}
274
274
  value={query}
275
275
  onChange={(e) => setQuery(e.target.value)}
276
276
  autoFocus
@@ -288,7 +288,7 @@ export function CommandMenu({
288
288
  </div>
289
289
  ) : (
290
290
  !searchResults?.length &&
291
- query && <CommandEmpty>{dictionary.search.noResults}</CommandEmpty>
291
+ query && <CommandEmpty>{dictionary?.search?.noResults ?? "Không có kết quả"}</CommandEmpty>
292
292
  )}
293
293
 
294
294
  <ScrollArea className="h-[300px] max-h-[300px]">
@@ -73,10 +73,10 @@ export function NotificationDropdown({
73
73
  <Card className="border-0 shadow-none">
74
74
  <div className="flex items-center justify-between border-b border-border p-3">
75
75
  <h3 className="text-sm font-semibold">
76
- {dictionary.navigation.notifications.notifications}
76
+ {dictionary?.navigation?.notifications?.notifications ?? "Thông báo"}
77
77
  </h3>
78
78
  <Button variant="link" className="text-primary h-auto p-0">
79
- {dictionary.navigation.notifications.dismissAll}
79
+ {dictionary?.navigation?.notifications?.dismissAll ?? "Bỏ qua tất cả"}
80
80
  </Button>
81
81
  </div>
82
82
  <ScrollArea className="max-h-[300px]">
@@ -117,7 +117,7 @@ export function NotificationDropdown({
117
117
  "text-primary text-center",
118
118
  )}
119
119
  >
120
- {dictionary.navigation.notifications.seeAllNotifications}
120
+ {dictionary?.navigation?.notifications?.seeAllNotifications ?? "Xem tất cả thông báo"}
121
121
  </Link>
122
122
  </CardFooter>
123
123
  </Card>
@@ -149,7 +149,7 @@ export function UserDropdown({
149
149
  className="flex items-center w-full"
150
150
  >
151
151
  <User className="me-2 size-4 text-muted-foreground group-hover:text-primary group-focus:text-primary transition-colors" />
152
- <span>{dictionary.navigation.userNav.profile}</span>
152
+ <span>{dictionary?.navigation?.userNav?.profile ?? "Hồ sơ"}</span>
153
153
  </Link>
154
154
  </DropdownMenuItem>
155
155
  {/* <DropdownMenuItem
@@ -161,7 +161,7 @@ export function UserDropdown({
161
161
  className="flex items-center w-full"
162
162
  >
163
163
  <UserCog className="me-2 size-4 text-muted-foreground group-hover:text-primary group-focus:text-primary transition-colors" />
164
- <span>{dictionary.navigation.userNav.settings}</span>
164
+ <span>{dictionary?.navigation?.userNav?.settings ?? "Cài đặt"}</span>
165
165
  </Link>
166
166
  </DropdownMenuItem> */}
167
167
  </DropdownMenuGroup>
@@ -173,7 +173,7 @@ export function UserDropdown({
173
173
  className="h-8 px-2 rounded-md cursor-pointer text-red-600 focus:bg-red-50 focus:text-red-700 dark:text-red-400 dark:focus:bg-red-950/30 dark:focus:text-red-300 transition-colors duration-200 group text-sm"
174
174
  >
175
175
  <LogOut className="me-2 size-4 group-hover:text-red-700 dark:group-hover:text-red-300 transition-colors" />
176
- <span>{dictionary.navigation.userNav.signOut}</span>
176
+ <span>{dictionary?.navigation?.userNav?.signOut ?? "Đăng xuất"}</span>
177
177
  </DropdownMenuItem>
178
178
  </DropdownMenuContent>
179
179
  </DropdownMenu>