@goplusvn/core 0.1.74 → 0.1.76

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/goerp-features.mjs +11 -1
  3. package/features/workspaces/README.md +72 -0
  4. package/features/workspaces/migrations/0001_init.sql +63 -0
  5. package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
  6. package/features/workspaces/schema.prisma +56 -0
  7. package/package.json +2 -1
  8. package/scripts/feature-sync.mjs +31 -3
  9. package/src/branch-scope/context.ts +20 -37
  10. package/src/features/__tests__/feature-sync.test.ts +41 -0
  11. package/src/guardrails/__tests__/guardrails.test.ts +47 -0
  12. package/src/guardrails/primitives.ts +14 -1
  13. package/src/guardrails/rules/one-door.ts +23 -0
  14. package/src/guardrails/scanner.ts +9 -0
  15. package/src/guardrails/types.ts +7 -0
  16. package/src/ui/auth/auth-layout.tsx +106 -82
  17. package/src/user/__tests__/user-service-scope.test.ts +148 -0
  18. package/src/user/user-service.ts +64 -10
  19. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  20. package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -0
  21. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  22. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  23. package/src/workspace/components/scope-level-select.tsx +91 -0
  24. package/src/workspace/components/workspace-switcher.tsx +139 -0
  25. package/src/workspace/components/workspace-tree-view.tsx +260 -0
  26. package/src/workspace/context.ts +78 -0
  27. package/src/workspace/delegation.ts +400 -0
  28. package/src/workspace/guard.ts +138 -0
  29. package/src/workspace/index.ts +157 -0
  30. package/src/workspace/pages/workspace-list-page.tsx +430 -0
  31. package/src/workspace/route-handlers.ts +274 -0
  32. package/src/workspace/scope.ts +396 -0
  33. package/src/workspace/service.ts +301 -0
  34. package/src/workspace/tree.ts +193 -0
  35. package/src/workspace/types.ts +182 -0
@@ -0,0 +1,339 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { configureWorkspaces, resetWorkspaceConfig } from "../scope";
4
+ import {
5
+ buildTree,
6
+ createWorkspace,
7
+ deactivateSubtree,
8
+ listSubtree,
9
+ moveWorkspace,
10
+ } from "../service";
11
+ import { WorkspaceTreeError } from "../tree";
12
+ import type { WorkspaceServiceDb } from "../service";
13
+ import type { WorkspaceNode } from "../types";
14
+
15
+ interface Row {
16
+ id: string;
17
+ code: string;
18
+ name: string;
19
+ kind: string;
20
+ parentId: string | null;
21
+ path: string;
22
+ depth: number;
23
+ isActive: boolean;
24
+ settings?: unknown;
25
+ }
26
+
27
+ /**
28
+ * DB giả trong bộ nhớ. Chỉ hiểu đúng những dạng `where` mà service thật sự phát
29
+ * ra (`id`, `parentId`, `path.startsWith`) — where lạ thì NÉM, để test không âm
30
+ * thầm đúng khi service đổi cách truy vấn.
31
+ */
32
+ function fakeDb(seed: Row[] = []) {
33
+ const rows = new Map<string, Row>(seed.map((r) => [r.id, r]));
34
+
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ const match = (row: Row, where: any): boolean => {
37
+ if (!where) return true;
38
+ for (const [key, cond] of Object.entries(where)) {
39
+ if (key === "path" && cond && typeof cond === "object") {
40
+ const startsWith = (cond as { startsWith?: string }).startsWith;
41
+ if (startsWith === undefined) throw new Error("where.path lạ");
42
+ if (!row.path.startsWith(startsWith)) return false;
43
+ continue;
44
+ }
45
+ if (key !== "id" && key !== "parentId") {
46
+ throw new Error(`fakeDb chưa hiểu where.${key}`);
47
+ }
48
+ if ((row as unknown as Record<string, unknown>)[key] !== cond)
49
+ return false;
50
+ }
51
+ return true;
52
+ };
53
+
54
+ const db: WorkspaceServiceDb & { rows: Map<string, Row> } = {
55
+ rows,
56
+ workspace: {
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ findMany: async (args?: any) => {
59
+ const out = [...rows.values()].filter((r) => match(r, args?.where));
60
+ if (args?.orderBy?.path === "asc")
61
+ out.sort((a, b) => a.path.localeCompare(b.path));
62
+ return out;
63
+ },
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ findUnique: async (args: any) => rows.get(args.where.id) ?? null,
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ create: async (args: any) => {
68
+ const row = { isActive: true, ...args.data } as Row;
69
+ rows.set(row.id, row);
70
+ return row;
71
+ },
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ update: async (args: any) => {
74
+ const row = rows.get(args.where.id);
75
+ if (!row) throw new Error("không có dòng để update");
76
+ Object.assign(row, args.data);
77
+ return row;
78
+ },
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ count: async (args?: any) =>
81
+ [...rows.values()].filter((r) => match(r, args?.where)).length,
82
+ },
83
+ };
84
+ return db;
85
+ }
86
+
87
+ /** Cây mẫu giống bộ test phạm vi: 2 đơn vị, Spartronics có 2 phòng ban. */
88
+ function seedTree(): Row[] {
89
+ return [
90
+ row("tanloc", null, "/tanloc/", 1, "unit"),
91
+ row("spa", null, "/spa/", 1, "unit"),
92
+ row("spa-qc", "spa", "/spa/spa-qc/", 2, "department"),
93
+ row("spa-kho", "spa", "/spa/spa-kho/", 2, "department"),
94
+ ];
95
+ }
96
+
97
+ function row(
98
+ id: string,
99
+ parentId: string | null,
100
+ path: string,
101
+ depth: number,
102
+ kind: string,
103
+ ): Row {
104
+ return {
105
+ id,
106
+ code: id.toUpperCase(),
107
+ name: id,
108
+ kind,
109
+ parentId,
110
+ path,
111
+ depth,
112
+ isActive: true,
113
+ };
114
+ }
115
+
116
+ beforeEach(() => {
117
+ resetWorkspaceConfig();
118
+ configureWorkspaces({
119
+ getUserId: () => undefined,
120
+ canViewAll: () => false,
121
+ });
122
+ });
123
+
124
+ describe("createWorkspace", () => {
125
+ it("nút gốc: path là /{id}/ và depth 1", async () => {
126
+ const db = fakeDb();
127
+ const created = await createWorkspace(db, { code: "TL", name: "Tấn Lộc" });
128
+ expect(created.path).toBe(`/${created.id}/`);
129
+ expect(created.depth).toBe(1);
130
+ expect(created.parentId).toBeNull();
131
+ });
132
+
133
+ it("nút con nối path của cha và id nằm TRONG path", async () => {
134
+ const db = fakeDb(seedTree());
135
+ const created = await createWorkspace(db, {
136
+ code: "SPA-IT",
137
+ name: "IT",
138
+ kind: "department",
139
+ parentId: "spa",
140
+ });
141
+ expect(created.path).toBe(`/spa/${created.id}/`);
142
+ expect(created.depth).toBe(2);
143
+ });
144
+
145
+ it("id truyền vào được giữ nguyên — đường backfill workspace.id = branch.id", async () => {
146
+ const db = fakeDb();
147
+ const created = await createWorkspace(db, {
148
+ id: "cn-hcm",
149
+ code: "HCM",
150
+ name: "Hồ Chí Minh",
151
+ kind: "branch",
152
+ });
153
+ expect(created.id).toBe("cn-hcm");
154
+ expect(created.path).toBe("/cn-hcm/");
155
+ });
156
+
157
+ it("cha không tồn tại thì ném, không tạo nút mồ côi", async () => {
158
+ const db = fakeDb(seedTree());
159
+ await expect(
160
+ createWorkspace(db, { code: "X", name: "X", parentId: "khong-co" }),
161
+ ).rejects.toThrow(WorkspaceTreeError);
162
+ expect(db.rows.size).toBe(4);
163
+ });
164
+
165
+ it("vượt trần độ sâu thì ném TRƯỚC khi ghi", async () => {
166
+ resetWorkspaceConfig();
167
+ configureWorkspaces({
168
+ getUserId: () => undefined,
169
+ canViewAll: () => false,
170
+ maxDepth: 2,
171
+ });
172
+ const db = fakeDb(seedTree());
173
+ await expect(
174
+ createWorkspace(db, {
175
+ code: "X",
176
+ name: "X",
177
+ kind: "department",
178
+ parentId: "spa-qc",
179
+ }),
180
+ ).rejects.toThrow(WorkspaceTreeError);
181
+ expect(db.rows.size).toBe(4);
182
+ });
183
+
184
+ it("kind khai canHaveChildren: false thì chặn tạo con", async () => {
185
+ resetWorkspaceConfig();
186
+ configureWorkspaces({
187
+ getUserId: () => undefined,
188
+ canViewAll: () => false,
189
+ kinds: [
190
+ { key: "unit", label: "Đơn vị", childKinds: ["department"] },
191
+ { key: "department", label: "Bộ phận", canHaveChildren: false },
192
+ ],
193
+ });
194
+ const db = fakeDb(seedTree());
195
+ await expect(
196
+ createWorkspace(db, {
197
+ code: "X",
198
+ name: "X",
199
+ kind: "department",
200
+ parentId: "spa-qc",
201
+ }),
202
+ ).rejects.toThrow(/không được có không gian con/);
203
+ });
204
+
205
+ it("kind ngoài childKinds của cha thì chặn", async () => {
206
+ resetWorkspaceConfig();
207
+ configureWorkspaces({
208
+ getUserId: () => undefined,
209
+ canViewAll: () => false,
210
+ kinds: [
211
+ { key: "unit", label: "Đơn vị", childKinds: ["department"] },
212
+ { key: "department", label: "Bộ phận" },
213
+ ],
214
+ });
215
+ const db = fakeDb(seedTree());
216
+ await expect(
217
+ createWorkspace(db, {
218
+ code: "X",
219
+ name: "X",
220
+ kind: "unit",
221
+ parentId: "spa",
222
+ }),
223
+ ).rejects.toThrow(/chỉ đặt được/);
224
+ });
225
+
226
+ it("quá nhiều con thì CẢNH BÁO chứ không chặn", async () => {
227
+ resetWorkspaceConfig();
228
+ configureWorkspaces({
229
+ getUserId: () => undefined,
230
+ canViewAll: () => false,
231
+ maxChildren: 2,
232
+ });
233
+ const db = fakeDb(seedTree());
234
+ const onWarn = vi.fn();
235
+ const created = await createWorkspace(
236
+ db,
237
+ { code: "X", name: "X", kind: "department", parentId: "spa" },
238
+ { onWarn },
239
+ );
240
+ expect(created.id).toBeTruthy();
241
+ expect(onWarn).toHaveBeenCalledOnce();
242
+ expect(onWarn.mock.calls[0][0]).toMatch(/2 nút con/);
243
+ });
244
+ });
245
+
246
+ describe("moveWorkspace", () => {
247
+ it("chuyển nhánh thì repath CẢ con cháu", async () => {
248
+ const db = fakeDb(seedTree());
249
+ const count = await moveWorkspace(db, { id: "spa", newParentId: "tanloc" });
250
+ expect(count).toBe(3);
251
+ expect(db.rows.get("spa")!.path).toBe("/tanloc/spa/");
252
+ expect(db.rows.get("spa-qc")!.path).toBe("/tanloc/spa/spa-qc/");
253
+ expect(db.rows.get("spa-kho")!.depth).toBe(3);
254
+ });
255
+
256
+ it("chỉ nút được chuyển đổi parentId; con cháu giữ nguyên cha của chúng", async () => {
257
+ const db = fakeDb(seedTree());
258
+ await moveWorkspace(db, { id: "spa", newParentId: "tanloc" });
259
+ expect(db.rows.get("spa")!.parentId).toBe("tanloc");
260
+ expect(db.rows.get("spa-qc")!.parentId).toBe("spa");
261
+ });
262
+
263
+ it("chuyển lên gốc", async () => {
264
+ const db = fakeDb(seedTree());
265
+ await moveWorkspace(db, { id: "spa-qc", newParentId: null });
266
+ expect(db.rows.get("spa-qc")!.path).toBe("/spa-qc/");
267
+ expect(db.rows.get("spa-qc")!.parentId).toBeNull();
268
+ expect(db.rows.get("spa-qc")!.depth).toBe(1);
269
+ });
270
+
271
+ it("chuyển vào chính con cháu mình = chu trình → ném, không ghi gì", async () => {
272
+ const db = fakeDb(seedTree());
273
+ await expect(
274
+ moveWorkspace(db, { id: "spa", newParentId: "spa-qc" }),
275
+ ).rejects.toThrow(WorkspaceTreeError);
276
+ expect(db.rows.get("spa")!.path).toBe("/spa/");
277
+ expect(db.rows.get("spa-qc")!.path).toBe("/spa/spa-qc/");
278
+ });
279
+
280
+ it("nhánh sâu chuyển xuống làm vượt trần → ném TRƯỚC khi ghi dòng nào", async () => {
281
+ resetWorkspaceConfig();
282
+ configureWorkspaces({
283
+ getUserId: () => undefined,
284
+ canViewAll: () => false,
285
+ maxDepth: 2,
286
+ });
287
+ const db = fakeDb(seedTree());
288
+ // /spa/ (1) + con (2) chuyển xuống /tanloc/ ⇒ con thành depth 3 > trần 2.
289
+ await expect(
290
+ moveWorkspace(db, { id: "spa", newParentId: "tanloc" }),
291
+ ).rejects.toThrow(WorkspaceTreeError);
292
+ expect(db.rows.get("spa")!.path).toBe("/spa/");
293
+ expect(db.rows.get("spa-qc")!.path).toBe("/spa/spa-qc/");
294
+ });
295
+ });
296
+
297
+ describe("listSubtree / deactivateSubtree", () => {
298
+ it("listSubtree gồm cả chính nó, sắp theo path", async () => {
299
+ const db = fakeDb(seedTree());
300
+ const rows = await listSubtree(db, "spa");
301
+ expect(rows.map((r) => r.id)).toEqual(["spa", "spa-kho", "spa-qc"]);
302
+ });
303
+
304
+ it("nút không tồn tại → mảng rỗng", async () => {
305
+ const db = fakeDb(seedTree());
306
+ expect(await listSubtree(db, "khong-co")).toEqual([]);
307
+ });
308
+
309
+ it("deactivateSubtree tắt cả nhánh, KHÔNG xoá dòng nào", async () => {
310
+ const db = fakeDb(seedTree());
311
+ const count = await deactivateSubtree(db, "spa");
312
+ expect(count).toBe(3);
313
+ expect(db.rows.size).toBe(4);
314
+ expect(db.rows.get("spa-qc")!.isActive).toBe(false);
315
+ expect(db.rows.get("tanloc")!.isActive).toBe(true);
316
+ });
317
+ });
318
+
319
+ describe("buildTree", () => {
320
+ it("dựng cây lồng nhau từ danh sách phẳng", () => {
321
+ const nodes: WorkspaceNode[] = seedTree().map((r) => ({
322
+ id: r.id,
323
+ parentId: r.parentId,
324
+ path: r.path,
325
+ kind: r.kind,
326
+ }));
327
+ const roots = buildTree(nodes);
328
+ expect(roots.map((r) => r.id)).toEqual(["tanloc", "spa"]);
329
+ expect(roots[1].children.map((c) => c.id)).toEqual(["spa-qc", "spa-kho"]);
330
+ expect(roots[0].children).toEqual([]);
331
+ });
332
+
333
+ it("nút có cha KHÔNG nằm trong danh sách vẫn hiện ra như gốc, không biến mất", () => {
334
+ const roots = buildTree([
335
+ { id: "spa-qc", parentId: "spa", path: "/spa/spa-qc/", kind: "dept" },
336
+ ]);
337
+ expect(roots.map((r) => r.id)).toEqual(["spa-qc"]);
338
+ });
339
+ });
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ // Thang phạm vi 5 nấc (Δ1 — theo Dynamics 365). Dùng ở trang Vai trò: mỗi quyền
4
+ // không chỉ có/không, mà còn "rộng tới đâu".
5
+ //
6
+ // Lý do là một component riêng chứ không phải `<Select>` gõ tay tại chỗ: chữ
7
+ // hiển thị của mỗi nấc phụ thuộc NHÃN của app ("Chi nhánh của tôi" ở vinhhoa,
8
+ // "Đơn vị của tôi" ở spartronics). Rải ra call-site thì mỗi trang một chữ.
9
+ import * as React from "react";
10
+
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "../../ui";
18
+ import { SCOPE_LEVELS, SCOPE_LEVEL_LABELS } from "../types";
19
+ import type { ScopeLevel } from "../types";
20
+
21
+ /** Mô tả ngắn dưới mỗi nấc — người tick quyền cần biết "rộng tới đâu". */
22
+ const HINTS: Record<ScopeLevel, string> = {
23
+ none: "Không thấy gì",
24
+ own: "Chỉ bản ghi do chính mình tạo",
25
+ workspace: "Đúng không gian được gán",
26
+ subtree: "Không gian được gán và cấp dưới",
27
+ all: "Toàn hệ thống",
28
+ };
29
+
30
+ export interface ScopeLevelSelectProps {
31
+ value: ScopeLevel;
32
+ onChange: (level: ScopeLevel) => void;
33
+ /**
34
+ * Nấc CAO NHẤT được chọn. Admin nhánh không được cấp đi nấc `all` — cắt ngay ở
35
+ * dropdown thay vì để họ chọn rồi server mới từ chối.
36
+ */
37
+ maxLevel?: ScopeLevel;
38
+ /** Nhãn thay cho "Không gian" theo app: "Chi nhánh", "Đơn vị", "Phòng ban". */
39
+ kindLabel?: string;
40
+ disabled?: boolean;
41
+ className?: string;
42
+ id?: string;
43
+ }
44
+
45
+ /** Nhãn nấc, đã thay chữ "Không gian" bằng nhãn của app. */
46
+ export function scopeLevelLabel(level: ScopeLevel, kindLabel?: string): string {
47
+ const base = SCOPE_LEVEL_LABELS[level];
48
+ if (!kindLabel) return base;
49
+ if (level === "workspace") return `${kindLabel} của tôi`;
50
+ if (level === "subtree") return `${kindLabel} và cấp dưới`;
51
+ return base;
52
+ }
53
+
54
+ export function ScopeLevelSelect({
55
+ value,
56
+ onChange,
57
+ maxLevel = "all",
58
+ kindLabel,
59
+ disabled,
60
+ className,
61
+ id,
62
+ }: ScopeLevelSelectProps) {
63
+ const options = React.useMemo(() => {
64
+ const cap = SCOPE_LEVELS.indexOf(maxLevel);
65
+ return SCOPE_LEVELS.slice(0, cap >= 0 ? cap + 1 : SCOPE_LEVELS.length);
66
+ }, [maxLevel]);
67
+
68
+ return (
69
+ <Select
70
+ value={value}
71
+ onValueChange={(v) => onChange(v as ScopeLevel)}
72
+ disabled={disabled}
73
+ >
74
+ <SelectTrigger id={id} className={className}>
75
+ <SelectValue placeholder="Chọn phạm vi" />
76
+ </SelectTrigger>
77
+ <SelectContent>
78
+ {options.map((level) => (
79
+ <SelectItem key={level} value={level}>
80
+ <span className="flex flex-col items-start">
81
+ <span>{scopeLevelLabel(level, kindLabel)}</span>
82
+ <span className="text-xs text-muted-foreground">
83
+ {HINTS[level]}
84
+ </span>
85
+ </span>
86
+ </SelectItem>
87
+ ))}
88
+ </SelectContent>
89
+ </Select>
90
+ );
91
+ }
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ // Bộ chuyển không gian — đứng ở header, cạnh tên người dùng.
4
+ //
5
+ // Chỉ hiện khi người dùng thuộc TỪ HAI không gian trở lên. Một không gian mà vẫn
6
+ // đẩy một dropdown vào header là thêm một thứ để đọc mà không mang thông tin gì
7
+ // (L11: mọi pixel phải mang dữ liệu).
8
+ //
9
+ // Đây là bộ lọc GIAO DIỆN, không phải hàng rào bảo mật: nó thu hẹp cái đang xem
10
+ // trong phạm vi server đã cho phép. Đổi giá trị ở đây KHÔNG bao giờ mở rộng
11
+ // được phạm vi — server luôn kẹp lại bằng `clampScopeFilter`.
12
+ import * as React from "react";
13
+
14
+ import { Building2, Check, ChevronsUpDown } from "lucide-react";
15
+
16
+ import {
17
+ Button,
18
+ Popover,
19
+ PopoverContent,
20
+ PopoverTrigger,
21
+ ScrollArea,
22
+ } from "../../ui";
23
+ import { cn } from "../../utils";
24
+
25
+ export interface WorkspaceOption {
26
+ id: string;
27
+ name: string;
28
+ /** Chữ nhãn đã dịch sẵn ("Chi nhánh", "Phòng ban") — hiện mờ bên phải. */
29
+ kindLabel?: string;
30
+ /** Độ sâu trong cây, để thụt đầu dòng. */
31
+ depth?: number;
32
+ }
33
+
34
+ export interface WorkspaceSwitcherProps {
35
+ options: WorkspaceOption[];
36
+ /** `null` = "Tất cả không gian" (chỉ hiện khi `allowAll`). */
37
+ value: string | null;
38
+ onChange: (workspaceId: string | null) => void;
39
+ /** Cho chọn "Tất cả" — chỉ bật cho người có quyền xem toàn bộ. */
40
+ allowAll?: boolean;
41
+ allLabel?: string;
42
+ className?: string;
43
+ }
44
+
45
+ export function WorkspaceSwitcher({
46
+ options,
47
+ value,
48
+ onChange,
49
+ allowAll = false,
50
+ allLabel = "Tất cả không gian",
51
+ className,
52
+ }: WorkspaceSwitcherProps) {
53
+ const [open, setOpen] = React.useState(false);
54
+
55
+ // Một lựa chọn duy nhất ⇒ không có gì để chuyển.
56
+ if (options.length <= 1 && !allowAll) return null;
57
+
58
+ const current = options.find((o) => o.id === value);
59
+ const label = current?.name ?? allLabel;
60
+
61
+ return (
62
+ <Popover open={open} onOpenChange={setOpen}>
63
+ <PopoverTrigger asChild>
64
+ <Button
65
+ variant="outline"
66
+ size="sm"
67
+ role="combobox"
68
+ aria-expanded={open}
69
+ className={cn("h-8 max-w-52 justify-between gap-1.5", className)}
70
+ >
71
+ <Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
72
+ <span className="truncate text-sm font-medium">{label}</span>
73
+ <ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
74
+ </Button>
75
+ </PopoverTrigger>
76
+ <PopoverContent align="start" className="w-64 p-1">
77
+ <ScrollArea className="max-h-72">
78
+ {allowAll ? (
79
+ <Row
80
+ label={allLabel}
81
+ selected={value === null}
82
+ onSelect={() => {
83
+ onChange(null);
84
+ setOpen(false);
85
+ }}
86
+ />
87
+ ) : null}
88
+ {options.map((option) => (
89
+ <Row
90
+ key={option.id}
91
+ label={option.name}
92
+ hint={option.kindLabel}
93
+ depth={option.depth}
94
+ selected={option.id === value}
95
+ onSelect={() => {
96
+ onChange(option.id);
97
+ setOpen(false);
98
+ }}
99
+ />
100
+ ))}
101
+ </ScrollArea>
102
+ </PopoverContent>
103
+ </Popover>
104
+ );
105
+ }
106
+
107
+ function Row({
108
+ label,
109
+ hint,
110
+ depth = 1,
111
+ selected,
112
+ onSelect,
113
+ }: {
114
+ label: string;
115
+ hint?: string;
116
+ depth?: number;
117
+ selected: boolean;
118
+ onSelect: () => void;
119
+ }) {
120
+ return (
121
+ <button
122
+ type="button"
123
+ onClick={onSelect}
124
+ className={cn(
125
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
126
+ selected ? "bg-muted font-medium" : "hover:bg-muted/60",
127
+ )}
128
+ style={{ paddingLeft: `${0.5 + Math.max(0, depth - 1) * 0.75}rem` }}
129
+ >
130
+ <Check
131
+ className={cn("h-3.5 w-3.5 shrink-0", selected ? "" : "opacity-0")}
132
+ />
133
+ <span className="min-w-0 flex-1 truncate">{label}</span>
134
+ {hint ? (
135
+ <span className="shrink-0 text-xs text-muted-foreground">{hint}</span>
136
+ ) : null}
137
+ </button>
138
+ );
139
+ }