@goplusvn/core 0.1.75 → 0.1.77
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 +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +301 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +173 -0
- package/src/workspace/pages/workspace-list-page.tsx +802 -0
- package/src/workspace/route-handlers.ts +550 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Khung TRÁI của trang workspace: mỗi tổ chức là một khối, mỗi bộ phận là một
|
|
4
|
+
// dòng chọn được. Chọn dòng nào thì khung PHẢI mở hồ sơ của dòng đó.
|
|
5
|
+
//
|
|
6
|
+
// Vì sao không phải cây thư mục: cây chỉ diễn tả được quan hệ "cha–con". Ở đây
|
|
7
|
+
// hai nút gốc không cùng loại — một bên là đơn vị VẬN HÀNH (thấy mọi tổ chức),
|
|
8
|
+
// bên kia là KHÁCH HÀNG (bị kẹp trong nhánh của mình). Vẽ cả hai bằng đúng một
|
|
9
|
+
// kiểu dòng thụt lề thì người xem không đọc ra sự khác nhau đó, mà đây lại là
|
|
10
|
+
// điều quan trọng nhất trên màn hình này.
|
|
11
|
+
//
|
|
12
|
+
// Vì sao khối này chỉ ĐIỀU HƯỚNG, không mang nút thao tác: cột trái hẹp, nhồi
|
|
13
|
+
// thêm mã, cấp, nút "+", menu ba chấm vào từng dòng thì tên bộ phận — thứ duy
|
|
14
|
+
// nhất người ta quét mắt tìm — bị ép còn vài chữ. Mã, cấp, nút bấm nằm ở khung
|
|
15
|
+
// phải, nơi đang nói về đúng một nút và có đủ chỗ.
|
|
16
|
+
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
|
|
19
|
+
import { Building2, Shield, Users } from "lucide-react";
|
|
20
|
+
|
|
21
|
+
import { cn } from "../../utils";
|
|
22
|
+
import type { WorkspaceTreeNode } from "./workspace-tree-view";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Danh tính của một tổ chức gốc — thứ core KHÔNG tự suy ra được.
|
|
26
|
+
*
|
|
27
|
+
* "Đơn vị vận hành" với "khách hàng" trong dữ liệu là hai dòng `workspace` y hệt
|
|
28
|
+
* nhau; cái phân biệt chúng là quyền `view-all-workspaces` nằm ở tầng vai trò
|
|
29
|
+
* của từng app. Nên app phải tự nói ra, core chỉ vẽ.
|
|
30
|
+
*/
|
|
31
|
+
export interface WorkspaceOrgAnnotation {
|
|
32
|
+
/** Huy hiệu cạnh tên, ví dụ "Vận hành" / "Khách hàng". */
|
|
33
|
+
badge?: string;
|
|
34
|
+
/** Một câu giải thích phạm vi, hiện dưới dòng số liệu. */
|
|
35
|
+
hint?: string;
|
|
36
|
+
/** `brand` = tổ chức của chính mình; `neutral` = tổ chức bên ngoài. */
|
|
37
|
+
tone?: "brand" | "neutral";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WorkspaceOrgBlockProps {
|
|
41
|
+
root: WorkspaceTreeNode;
|
|
42
|
+
annotation?: WorkspaceOrgAnnotation;
|
|
43
|
+
/** Nút đang mở ở khung phải. */
|
|
44
|
+
activeId?: string | null;
|
|
45
|
+
onSelect?: (node: WorkspaceTreeNode) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Nhãn cấp dưới của một nút, hoặc `null` nếu nút đó hết cấp — chỉ dùng để
|
|
48
|
+
* viết đúng chữ ("3 bộ phận" chứ không phải "3 cấp dưới"). Luật cấp nằm ở
|
|
49
|
+
* `kinds` của app nên trang cha quyết, khối chỉ hỏi.
|
|
50
|
+
*/
|
|
51
|
+
childKindLabel?: (node: WorkspaceTreeNode) => string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WorkspaceRollup {
|
|
55
|
+
/** Tổng thành viên của cả nhánh, kể cả nút gốc. */
|
|
56
|
+
members: number;
|
|
57
|
+
admins: number;
|
|
58
|
+
/** Số nút con cháu, KHÔNG tính chính nó. */
|
|
59
|
+
units: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cộng dồn cả nhánh.
|
|
64
|
+
*
|
|
65
|
+
* Con số ở tiêu đề khối phải là TỔNG, không phải số gán trực tiếp vào nút gốc:
|
|
66
|
+
* "Spartronics 2 người" trong khi ba bộ phận bên dưới có thêm 4 người nữa là
|
|
67
|
+
* câu trả lời sai cho câu hỏi người ta thực sự hỏi ("công ty này bao nhiêu
|
|
68
|
+
* người dùng?"). Số trực tiếp vẫn còn, nằm ở từng dòng.
|
|
69
|
+
*/
|
|
70
|
+
export function rollupCounts(node: WorkspaceTreeNode): WorkspaceRollup {
|
|
71
|
+
return node.children.reduce<WorkspaceRollup>(
|
|
72
|
+
(acc, child) => {
|
|
73
|
+
const sub = rollupCounts(child);
|
|
74
|
+
return {
|
|
75
|
+
members: acc.members + sub.members,
|
|
76
|
+
admins: acc.admins + sub.admins,
|
|
77
|
+
units: acc.units + sub.units + 1,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
members: node.memberCount ?? 0,
|
|
82
|
+
admins: node.adminCount ?? 0,
|
|
83
|
+
units: 0,
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Giữ nhánh nào có ÍT NHẤT một hậu duệ khớp — cắt cha thì mất ngữ cảnh. */
|
|
89
|
+
export function filterWorkspaceTree(
|
|
90
|
+
nodes: WorkspaceTreeNode[],
|
|
91
|
+
term: string,
|
|
92
|
+
): WorkspaceTreeNode[] {
|
|
93
|
+
const needle = term.trim().toLowerCase();
|
|
94
|
+
if (!needle) return nodes;
|
|
95
|
+
const walk = (node: WorkspaceTreeNode): WorkspaceTreeNode | null => {
|
|
96
|
+
const children = node.children
|
|
97
|
+
.map(walk)
|
|
98
|
+
.filter((n): n is WorkspaceTreeNode => n !== null);
|
|
99
|
+
const hit =
|
|
100
|
+
node.name.toLowerCase().includes(needle) ||
|
|
101
|
+
node.code.toLowerCase().includes(needle);
|
|
102
|
+
if (!hit && children.length === 0) return null;
|
|
103
|
+
return { ...node, children };
|
|
104
|
+
};
|
|
105
|
+
return nodes.map(walk).filter((n): n is WorkspaceTreeNode => n !== null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ô "2 người" ở cuối mỗi dòng.
|
|
110
|
+
*
|
|
111
|
+
* Có icon người đi kèm chứ không chỉ số trần: đây là dòng nhiều cột mà không có
|
|
112
|
+
* tiêu đề cột, con số một mình thì không tự nói ra nó đang đếm cái gì.
|
|
113
|
+
*/
|
|
114
|
+
function MemberStat({
|
|
115
|
+
members,
|
|
116
|
+
admins,
|
|
117
|
+
active,
|
|
118
|
+
}: {
|
|
119
|
+
members: number;
|
|
120
|
+
admins: number;
|
|
121
|
+
active?: boolean;
|
|
122
|
+
}) {
|
|
123
|
+
return (
|
|
124
|
+
<span
|
|
125
|
+
className={cn(
|
|
126
|
+
"flex shrink-0 items-center gap-1.5 text-xs",
|
|
127
|
+
active ? "text-foreground" : "text-muted-foreground",
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
<Users className="h-3.5 w-3.5" />
|
|
131
|
+
<span className="tabular-nums">{members}</span>
|
|
132
|
+
{admins > 0 ? (
|
|
133
|
+
<Shield
|
|
134
|
+
className="h-3.5 w-3.5 text-primary"
|
|
135
|
+
aria-label={`${admins} quản trị`}
|
|
136
|
+
/>
|
|
137
|
+
) : null}
|
|
138
|
+
</span>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function WorkspaceOrgBlock({
|
|
143
|
+
root,
|
|
144
|
+
annotation,
|
|
145
|
+
activeId,
|
|
146
|
+
onSelect,
|
|
147
|
+
childKindLabel,
|
|
148
|
+
}: WorkspaceOrgBlockProps) {
|
|
149
|
+
const total = React.useMemo(() => rollupCounts(root), [root]);
|
|
150
|
+
const brand = annotation?.tone === "brand";
|
|
151
|
+
const rootChildLabel = childKindLabel?.(root) ?? null;
|
|
152
|
+
const rootActive = activeId === root.id;
|
|
153
|
+
|
|
154
|
+
const renderRow = (
|
|
155
|
+
node: WorkspaceTreeNode,
|
|
156
|
+
depth: number,
|
|
157
|
+
): React.ReactNode => {
|
|
158
|
+
const active = activeId === node.id;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<React.Fragment key={node.id}>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={() => onSelect?.(node)}
|
|
165
|
+
aria-current={active ? "true" : undefined}
|
|
166
|
+
className={cn(
|
|
167
|
+
"flex w-full items-center gap-2 border-t border-border/60 py-2 pr-3 text-left text-sm transition-colors",
|
|
168
|
+
// Nền đậm hơn + chữ đậm, KHÔNG tô chữ bằng `text-primary`: ở chế độ
|
|
169
|
+
// tối màu primary ngả xanh lợt, chữ chìm hẳn. `/20` chứ không `/10`:
|
|
170
|
+
// trên nền tối, 10% primary gần như không phân biệt được với dòng
|
|
171
|
+
// thường.
|
|
172
|
+
active ? "bg-primary/20 font-medium" : "hover:bg-muted/50",
|
|
173
|
+
node.isActive === false && "opacity-55",
|
|
174
|
+
)}
|
|
175
|
+
style={{ paddingLeft: `${0.75 + depth * 1.25}rem` }}
|
|
176
|
+
>
|
|
177
|
+
<span className="min-w-0 flex-1 truncate">
|
|
178
|
+
{node.name}
|
|
179
|
+
{node.isActive === false ? (
|
|
180
|
+
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
|
181
|
+
(ngừng)
|
|
182
|
+
</span>
|
|
183
|
+
) : null}
|
|
184
|
+
</span>
|
|
185
|
+
<MemberStat
|
|
186
|
+
members={node.memberCount ?? 0}
|
|
187
|
+
admins={node.adminCount ?? 0}
|
|
188
|
+
active={active}
|
|
189
|
+
/>
|
|
190
|
+
</button>
|
|
191
|
+
|
|
192
|
+
{node.children.map((child) => renderRow(child, depth + 1))}
|
|
193
|
+
</React.Fragment>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<section
|
|
199
|
+
className={cn(
|
|
200
|
+
"overflow-hidden rounded-xl border bg-card",
|
|
201
|
+
brand ? "border-primary/25" : "border-border",
|
|
202
|
+
root.isActive === false && "opacity-60",
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{/* Cả đầu khối là một nút: tổ chức gốc cũng gán thành viên trực tiếp
|
|
206
|
+
được, nên nó phải chọn được hệt như bộ phận bên dưới. */}
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={() => onSelect?.(root)}
|
|
210
|
+
aria-current={rootActive ? "true" : undefined}
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors",
|
|
213
|
+
rootActive
|
|
214
|
+
? "bg-primary/20"
|
|
215
|
+
: brand
|
|
216
|
+
? "bg-primary/10 hover:bg-primary/20"
|
|
217
|
+
: "bg-muted/30 hover:bg-muted/60",
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
<span
|
|
221
|
+
aria-hidden
|
|
222
|
+
className={cn(
|
|
223
|
+
"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
|
|
224
|
+
brand
|
|
225
|
+
? "bg-primary/15 text-primary"
|
|
226
|
+
: "bg-background text-muted-foreground ring-1 ring-border",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<Building2 className="h-4 w-4" />
|
|
230
|
+
</span>
|
|
231
|
+
|
|
232
|
+
<span className="min-w-0 flex-1">
|
|
233
|
+
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
234
|
+
<span className="truncate text-[15px] font-semibold">
|
|
235
|
+
{root.name}
|
|
236
|
+
</span>
|
|
237
|
+
{annotation?.badge ? (
|
|
238
|
+
<span
|
|
239
|
+
className={cn(
|
|
240
|
+
"inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
|
241
|
+
// Chữ `foreground` chứ không `primary`: nhãn này phải đọc
|
|
242
|
+
// được ở cả hai chế độ, sắc thái brand để nền và viền lo.
|
|
243
|
+
brand
|
|
244
|
+
? "border-primary/40 bg-primary/20 text-foreground"
|
|
245
|
+
: "border-border bg-background text-muted-foreground",
|
|
246
|
+
)}
|
|
247
|
+
>
|
|
248
|
+
{annotation.badge}
|
|
249
|
+
</span>
|
|
250
|
+
) : null}
|
|
251
|
+
{root.isActive === false ? (
|
|
252
|
+
<span className="text-xs text-muted-foreground">
|
|
253
|
+
(đã ngừng hoạt động)
|
|
254
|
+
</span>
|
|
255
|
+
) : null}
|
|
256
|
+
</span>
|
|
257
|
+
|
|
258
|
+
{/* Số ở đây là TỔNG cả nhánh, số trên từng dòng là số trực tiếp. Nói
|
|
259
|
+
rõ "cả nhánh" khi có cấp dưới, không thì hai con số khác nhau trên
|
|
260
|
+
cùng màn hình mà không ai biết vì sao. */}
|
|
261
|
+
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
|
262
|
+
<span className="tabular-nums">
|
|
263
|
+
{total.members} người{total.units > 0 ? " cả nhánh" : ""}
|
|
264
|
+
</span>
|
|
265
|
+
<span aria-hidden>·</span>
|
|
266
|
+
<span className="tabular-nums">{total.admins} quản trị</span>
|
|
267
|
+
<span aria-hidden>·</span>
|
|
268
|
+
<span className="tabular-nums">
|
|
269
|
+
{total.units} {(rootChildLabel ?? "cấp dưới").toLowerCase()}
|
|
270
|
+
</span>
|
|
271
|
+
</span>
|
|
272
|
+
|
|
273
|
+
{annotation?.hint ? (
|
|
274
|
+
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
|
|
275
|
+
{annotation.hint}
|
|
276
|
+
</span>
|
|
277
|
+
) : null}
|
|
278
|
+
</span>
|
|
279
|
+
</button>
|
|
280
|
+
|
|
281
|
+
{root.children.length > 0 ? (
|
|
282
|
+
<div>{root.children.map((child) => renderRow(child, 0))}</div>
|
|
283
|
+
) : (
|
|
284
|
+
// Rỗng phải nói ra HỆ QUẢ, không chỉ báo trống: chưa có cấp dưới nghĩa
|
|
285
|
+
// là mọi người của tổ chức này dùng chung một phạm vi dữ liệu.
|
|
286
|
+
<p className="border-t border-border/60 px-3 py-2.5 text-xs text-muted-foreground">
|
|
287
|
+
Chưa chia {(rootChildLabel ?? "cấp dưới").toLowerCase()} — cả{" "}
|
|
288
|
+
{total.members} người dùng chung một phạm vi dữ liệu.
|
|
289
|
+
</p>
|
|
290
|
+
)}
|
|
291
|
+
</section>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -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ả workspace" (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ả workspace",
|
|
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
|
+
}
|