@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.
- 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/ui/auth/auth-layout.tsx +106 -82
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -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-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +260 -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 +157 -0
- package/src/workspace/pages/workspace-list-page.tsx +430 -0
- package/src/workspace/route-handlers.ts +274 -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,430 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Trang "Không gian làm việc" — cây tổ chức + thao tác nhánh.
|
|
4
|
+
//
|
|
5
|
+
// Dùng lại khung `RbacPageBar` / `RbacStickyToolbar` của các trang RBAC thay vì
|
|
6
|
+
// tự dựng bar riêng: đây là trang quản trị cùng họ, lệch khung một chút là người
|
|
7
|
+
// dùng nhận ra ngay (L1 — tái dùng, không hand-roll).
|
|
8
|
+
//
|
|
9
|
+
// API contract: GET/POST `/api/workspaces`, PATCH/DELETE `/api/workspaces/:id`,
|
|
10
|
+
// POST `/api/workspaces/:id/move`.
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
import { Network } from "lucide-react";
|
|
14
|
+
import { toast } from "sonner";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Button,
|
|
18
|
+
ConfirmDialog,
|
|
19
|
+
Dialog,
|
|
20
|
+
DialogContent,
|
|
21
|
+
DialogDescription,
|
|
22
|
+
DialogFooter,
|
|
23
|
+
DialogHeader,
|
|
24
|
+
DialogTitle,
|
|
25
|
+
Input,
|
|
26
|
+
Label,
|
|
27
|
+
Select,
|
|
28
|
+
SelectContent,
|
|
29
|
+
SelectItem,
|
|
30
|
+
SelectTrigger,
|
|
31
|
+
SelectValue,
|
|
32
|
+
Skeleton,
|
|
33
|
+
} from "../../ui";
|
|
34
|
+
import { RbacPageBar } from "../../rbac/pages/lib/rbac-page-shell";
|
|
35
|
+
import { Search, X } from "lucide-react";
|
|
36
|
+
import { cn } from "../../utils";
|
|
37
|
+
import {
|
|
38
|
+
countTreeNodes,
|
|
39
|
+
WorkspaceTreeView,
|
|
40
|
+
} from "../components/workspace-tree-view";
|
|
41
|
+
import type { WorkspaceTreeNode } from "../components/workspace-tree-view";
|
|
42
|
+
import type { WorkspaceKindConfig } from "../types";
|
|
43
|
+
|
|
44
|
+
export interface WorkspaceListPageProps {
|
|
45
|
+
/** Cây đã dựng sẵn ở server (`buildTree`) — trang này không tự gọi API lần đầu. */
|
|
46
|
+
initialTree: WorkspaceTreeNode[];
|
|
47
|
+
/** Nhãn cấp của app; rỗng = ẩn ô chọn cấp, mọi nút cùng một loại. */
|
|
48
|
+
kinds?: WorkspaceKindConfig[];
|
|
49
|
+
/** Nhánh người dùng được uỷ quyền quản trị (đã bung con cháu). */
|
|
50
|
+
adminIds?: string[];
|
|
51
|
+
canManageAll?: boolean;
|
|
52
|
+
canCreate?: boolean;
|
|
53
|
+
apiEndpoint?: string;
|
|
54
|
+
title?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface FormState {
|
|
58
|
+
mode: "create" | "edit";
|
|
59
|
+
id?: string;
|
|
60
|
+
parentId: string | null;
|
|
61
|
+
parentName?: string;
|
|
62
|
+
code: string;
|
|
63
|
+
name: string;
|
|
64
|
+
kind: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Nhãn con hợp lệ dưới một nút — theo `childKinds` của cấp cha. */
|
|
68
|
+
function allowedChildKinds(
|
|
69
|
+
kinds: WorkspaceKindConfig[],
|
|
70
|
+
parentKind?: string,
|
|
71
|
+
): WorkspaceKindConfig[] {
|
|
72
|
+
if (!parentKind) return kinds;
|
|
73
|
+
const parent = kinds.find((k) => k.key === parentKind);
|
|
74
|
+
if (!parent?.childKinds) return kinds;
|
|
75
|
+
return kinds.filter((k) => parent.childKinds?.includes(k.key));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findNode(
|
|
79
|
+
nodes: WorkspaceTreeNode[],
|
|
80
|
+
id: string,
|
|
81
|
+
): WorkspaceTreeNode | null {
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
if (node.id === id) return node;
|
|
84
|
+
const hit = findNode(node.children, id);
|
|
85
|
+
if (hit) return hit;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Làm phẳng cây thành danh sách chọn "chuyển vào nhánh nào". */
|
|
91
|
+
function flatten(
|
|
92
|
+
nodes: WorkspaceTreeNode[],
|
|
93
|
+
depth = 0,
|
|
94
|
+
): { id: string; name: string; depth: number }[] {
|
|
95
|
+
return nodes.flatMap((node) => [
|
|
96
|
+
{ id: node.id, name: node.name, depth },
|
|
97
|
+
...flatten(node.children, depth + 1),
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Nút và toàn bộ con cháu — không cho chuyển một nhánh vào chính nó. */
|
|
102
|
+
function subtreeIds(node: WorkspaceTreeNode): string[] {
|
|
103
|
+
return [node.id, ...node.children.flatMap(subtreeIds)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function WorkspaceListPage({
|
|
107
|
+
initialTree,
|
|
108
|
+
kinds = [],
|
|
109
|
+
adminIds,
|
|
110
|
+
canManageAll = false,
|
|
111
|
+
canCreate = false,
|
|
112
|
+
apiEndpoint = "/api/workspaces",
|
|
113
|
+
title = "Không gian làm việc",
|
|
114
|
+
}: WorkspaceListPageProps) {
|
|
115
|
+
const [tree, setTree] = React.useState(initialTree);
|
|
116
|
+
const [search, setSearch] = React.useState("");
|
|
117
|
+
const [busy, setBusy] = React.useState(false);
|
|
118
|
+
const [form, setForm] = React.useState<FormState | null>(null);
|
|
119
|
+
const [moving, setMoving] = React.useState<WorkspaceTreeNode | null>(null);
|
|
120
|
+
const [moveTarget, setMoveTarget] = React.useState<string>("");
|
|
121
|
+
const [deactivating, setDeactivating] =
|
|
122
|
+
React.useState<WorkspaceTreeNode | null>(null);
|
|
123
|
+
|
|
124
|
+
const total = React.useMemo(() => countTreeNodes(tree), [tree]);
|
|
125
|
+
const flat = React.useMemo(() => flatten(tree), [tree]);
|
|
126
|
+
|
|
127
|
+
const reload = React.useCallback(async () => {
|
|
128
|
+
const res = await fetch(apiEndpoint, { cache: "no-store" });
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
toast.error("Không tải lại được danh sách không gian.");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
setTree(await res.json());
|
|
134
|
+
}, [apiEndpoint]);
|
|
135
|
+
|
|
136
|
+
/** Mọi lời gọi ghi đi qua đây để thông báo lỗi của server tới thẳng người dùng. */
|
|
137
|
+
const submit = React.useCallback(
|
|
138
|
+
async (url: string, method: string, body?: unknown) => {
|
|
139
|
+
setBusy(true);
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(url, {
|
|
142
|
+
method,
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
const payload = await res.json().catch(() => null);
|
|
148
|
+
throw new Error(payload?.error ?? "Thao tác không thành công.");
|
|
149
|
+
}
|
|
150
|
+
await reload();
|
|
151
|
+
return true;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
toast.error(
|
|
154
|
+
error instanceof Error ? error.message : "Thao tác không thành công.",
|
|
155
|
+
);
|
|
156
|
+
return false;
|
|
157
|
+
} finally {
|
|
158
|
+
setBusy(false);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
[reload],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const openCreate = (parent?: WorkspaceTreeNode) => {
|
|
165
|
+
const options = allowedChildKinds(kinds, parent?.kind);
|
|
166
|
+
setForm({
|
|
167
|
+
mode: "create",
|
|
168
|
+
parentId: parent?.id ?? null,
|
|
169
|
+
parentName: parent?.name,
|
|
170
|
+
code: "",
|
|
171
|
+
name: "",
|
|
172
|
+
kind: options[0]?.key ?? kinds[0]?.key ?? "unit",
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const saveForm = async () => {
|
|
177
|
+
if (!form) return;
|
|
178
|
+
if (!form.code.trim() || !form.name.trim()) {
|
|
179
|
+
toast.error("Mã và tên không được để trống.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const ok =
|
|
183
|
+
form.mode === "create"
|
|
184
|
+
? await submit(apiEndpoint, "POST", {
|
|
185
|
+
code: form.code.trim(),
|
|
186
|
+
name: form.name.trim(),
|
|
187
|
+
kind: form.kind,
|
|
188
|
+
parentId: form.parentId,
|
|
189
|
+
})
|
|
190
|
+
: await submit(`${apiEndpoint}/${form.id}`, "PATCH", {
|
|
191
|
+
code: form.code.trim(),
|
|
192
|
+
name: form.name.trim(),
|
|
193
|
+
kind: form.kind,
|
|
194
|
+
});
|
|
195
|
+
if (ok) {
|
|
196
|
+
toast.success(form.mode === "create" ? "Đã thêm." : "Đã cập nhật.");
|
|
197
|
+
setForm(null);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const confirmMove = async () => {
|
|
202
|
+
if (!moving) return;
|
|
203
|
+
const ok = await submit(`${apiEndpoint}/${moving.id}/move`, "POST", {
|
|
204
|
+
newParentId: moveTarget === "__root__" ? null : moveTarget,
|
|
205
|
+
});
|
|
206
|
+
if (ok) {
|
|
207
|
+
toast.success("Đã chuyển nhánh.");
|
|
208
|
+
setMoving(null);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const confirmDeactivate = async () => {
|
|
213
|
+
if (!deactivating) return;
|
|
214
|
+
const ok = await submit(`${apiEndpoint}/${deactivating.id}`, "DELETE");
|
|
215
|
+
if (ok) {
|
|
216
|
+
toast.success("Đã ngừng hoạt động cả nhánh.");
|
|
217
|
+
setDeactivating(null);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Không cho chuyển vào chính mình hay con cháu — chặn ở dropdown luôn cho gọn.
|
|
222
|
+
const moveOptions = React.useMemo(() => {
|
|
223
|
+
if (!moving) return [];
|
|
224
|
+
const banned = new Set(subtreeIds(moving));
|
|
225
|
+
return flat.filter((item) => !banned.has(item.id));
|
|
226
|
+
}, [moving, flat]);
|
|
227
|
+
|
|
228
|
+
const kindOptions = React.useMemo(() => {
|
|
229
|
+
if (!form) return kinds;
|
|
230
|
+
const parent = form.parentId ? findNode(tree, form.parentId) : null;
|
|
231
|
+
return allowedChildKinds(kinds, parent?.kind);
|
|
232
|
+
}, [form, kinds, tree]);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div className="space-y-3">
|
|
236
|
+
<RbacPageBar
|
|
237
|
+
icon={<Network className="h-5 w-5 text-primary-foreground" />}
|
|
238
|
+
title={title}
|
|
239
|
+
subtitle={`${total} không gian`}
|
|
240
|
+
>
|
|
241
|
+
{canCreate ? (
|
|
242
|
+
<Button size="sm" onClick={() => openCreate()} disabled={busy}>
|
|
243
|
+
Thêm không gian gốc
|
|
244
|
+
</Button>
|
|
245
|
+
) : null}
|
|
246
|
+
</RbacPageBar>
|
|
247
|
+
|
|
248
|
+
<div className="overflow-hidden rounded-xl border border-border bg-card lg:mx-3">
|
|
249
|
+
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 border-b border-border bg-card/95 px-3 py-2 backdrop-blur sm:px-4">
|
|
250
|
+
<div className="relative min-w-[140px] flex-1 sm:max-w-72">
|
|
251
|
+
<Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
252
|
+
<Input
|
|
253
|
+
placeholder="Tìm theo tên hoặc mã…"
|
|
254
|
+
className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm"
|
|
255
|
+
value={search}
|
|
256
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
257
|
+
/>
|
|
258
|
+
{search ? (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => setSearch("")}
|
|
262
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
263
|
+
title="Xóa tìm kiếm"
|
|
264
|
+
>
|
|
265
|
+
<X className="h-3.5 w-3.5" />
|
|
266
|
+
</button>
|
|
267
|
+
) : null}
|
|
268
|
+
</div>
|
|
269
|
+
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
270
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
271
|
+
{total}
|
|
272
|
+
</span>{" "}
|
|
273
|
+
không gian
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div className={cn(busy && "pointer-events-none opacity-60")}>
|
|
278
|
+
{busy && tree.length === 0 ? (
|
|
279
|
+
<div className="space-y-2 p-4">
|
|
280
|
+
<Skeleton className="h-6 w-full" />
|
|
281
|
+
<Skeleton className="h-6 w-5/6" />
|
|
282
|
+
</div>
|
|
283
|
+
) : (
|
|
284
|
+
<WorkspaceTreeView
|
|
285
|
+
nodes={tree}
|
|
286
|
+
adminIds={adminIds}
|
|
287
|
+
canManageAll={canManageAll}
|
|
288
|
+
search={search}
|
|
289
|
+
onAddChild={canCreate ? openCreate : undefined}
|
|
290
|
+
onEdit={(node) =>
|
|
291
|
+
setForm({
|
|
292
|
+
mode: "edit",
|
|
293
|
+
id: node.id,
|
|
294
|
+
parentId: null,
|
|
295
|
+
code: node.code,
|
|
296
|
+
name: node.name,
|
|
297
|
+
kind: node.kind ?? kinds[0]?.key ?? "unit",
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
onMove={(node) => {
|
|
301
|
+
setMoving(node);
|
|
302
|
+
setMoveTarget("__root__");
|
|
303
|
+
}}
|
|
304
|
+
onDeactivate={setDeactivating}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<Dialog
|
|
311
|
+
open={form !== null}
|
|
312
|
+
onOpenChange={(open) => !open && setForm(null)}
|
|
313
|
+
>
|
|
314
|
+
<DialogContent>
|
|
315
|
+
<DialogHeader>
|
|
316
|
+
<DialogTitle>
|
|
317
|
+
{form?.mode === "create" ? "Thêm không gian" : "Sửa không gian"}
|
|
318
|
+
</DialogTitle>
|
|
319
|
+
{form?.parentName ? (
|
|
320
|
+
<DialogDescription>
|
|
321
|
+
Đặt dưới <strong>{form.parentName}</strong>
|
|
322
|
+
</DialogDescription>
|
|
323
|
+
) : null}
|
|
324
|
+
</DialogHeader>
|
|
325
|
+
<div className="space-y-3">
|
|
326
|
+
<div className="space-y-1.5">
|
|
327
|
+
<Label htmlFor="ws-code">Mã</Label>
|
|
328
|
+
<Input
|
|
329
|
+
id="ws-code"
|
|
330
|
+
value={form?.code ?? ""}
|
|
331
|
+
onChange={(e) =>
|
|
332
|
+
setForm((f) => (f ? { ...f, code: e.target.value } : f))
|
|
333
|
+
}
|
|
334
|
+
placeholder="VD: SPA-QC"
|
|
335
|
+
/>
|
|
336
|
+
</div>
|
|
337
|
+
<div className="space-y-1.5">
|
|
338
|
+
<Label htmlFor="ws-name">Tên</Label>
|
|
339
|
+
<Input
|
|
340
|
+
id="ws-name"
|
|
341
|
+
value={form?.name ?? ""}
|
|
342
|
+
onChange={(e) =>
|
|
343
|
+
setForm((f) => (f ? { ...f, name: e.target.value } : f))
|
|
344
|
+
}
|
|
345
|
+
placeholder="VD: Phòng QC"
|
|
346
|
+
/>
|
|
347
|
+
</div>
|
|
348
|
+
{kindOptions.length > 1 ? (
|
|
349
|
+
<div className="space-y-1.5">
|
|
350
|
+
<Label htmlFor="ws-kind">Cấp</Label>
|
|
351
|
+
<Select
|
|
352
|
+
value={form?.kind}
|
|
353
|
+
onValueChange={(v) =>
|
|
354
|
+
setForm((f) => (f ? { ...f, kind: v } : f))
|
|
355
|
+
}
|
|
356
|
+
>
|
|
357
|
+
<SelectTrigger id="ws-kind">
|
|
358
|
+
<SelectValue placeholder="Chọn cấp" />
|
|
359
|
+
</SelectTrigger>
|
|
360
|
+
<SelectContent>
|
|
361
|
+
{kindOptions.map((kind) => (
|
|
362
|
+
<SelectItem key={kind.key} value={kind.key}>
|
|
363
|
+
{kind.label}
|
|
364
|
+
</SelectItem>
|
|
365
|
+
))}
|
|
366
|
+
</SelectContent>
|
|
367
|
+
</Select>
|
|
368
|
+
</div>
|
|
369
|
+
) : null}
|
|
370
|
+
</div>
|
|
371
|
+
<DialogFooter>
|
|
372
|
+
<Button variant="outline" onClick={() => setForm(null)}>
|
|
373
|
+
Hủy
|
|
374
|
+
</Button>
|
|
375
|
+
<Button onClick={saveForm} disabled={busy}>
|
|
376
|
+
Lưu
|
|
377
|
+
</Button>
|
|
378
|
+
</DialogFooter>
|
|
379
|
+
</DialogContent>
|
|
380
|
+
</Dialog>
|
|
381
|
+
|
|
382
|
+
<Dialog
|
|
383
|
+
open={moving !== null}
|
|
384
|
+
onOpenChange={(open) => !open && setMoving(null)}
|
|
385
|
+
>
|
|
386
|
+
<DialogContent>
|
|
387
|
+
<DialogHeader>
|
|
388
|
+
<DialogTitle>Chuyển nhánh</DialogTitle>
|
|
389
|
+
<DialogDescription>
|
|
390
|
+
Chuyển <strong>{moving?.name}</strong> cùng toàn bộ cấp dưới sang
|
|
391
|
+
nhánh khác.
|
|
392
|
+
</DialogDescription>
|
|
393
|
+
</DialogHeader>
|
|
394
|
+
<Select value={moveTarget} onValueChange={setMoveTarget}>
|
|
395
|
+
<SelectTrigger>
|
|
396
|
+
<SelectValue placeholder="Chọn nhánh cha" />
|
|
397
|
+
</SelectTrigger>
|
|
398
|
+
<SelectContent>
|
|
399
|
+
<SelectItem value="__root__">— Cấp cao nhất —</SelectItem>
|
|
400
|
+
{moveOptions.map((item) => (
|
|
401
|
+
<SelectItem key={item.id} value={item.id}>
|
|
402
|
+
{" ".repeat(item.depth * 2)}
|
|
403
|
+
{item.name}
|
|
404
|
+
</SelectItem>
|
|
405
|
+
))}
|
|
406
|
+
</SelectContent>
|
|
407
|
+
</Select>
|
|
408
|
+
<DialogFooter>
|
|
409
|
+
<Button variant="outline" onClick={() => setMoving(null)}>
|
|
410
|
+
Hủy
|
|
411
|
+
</Button>
|
|
412
|
+
<Button onClick={confirmMove} disabled={busy}>
|
|
413
|
+
Chuyển
|
|
414
|
+
</Button>
|
|
415
|
+
</DialogFooter>
|
|
416
|
+
</DialogContent>
|
|
417
|
+
</Dialog>
|
|
418
|
+
|
|
419
|
+
<ConfirmDialog
|
|
420
|
+
open={deactivating !== null}
|
|
421
|
+
onOpenChange={(open) => !open && setDeactivating(null)}
|
|
422
|
+
title="Ngừng hoạt động không gian?"
|
|
423
|
+
description={`"${deactivating?.name}" và toàn bộ cấp dưới sẽ ngừng hoạt động. Dữ liệu cũ giữ nguyên, không bị xóa.`}
|
|
424
|
+
confirmText="Ngừng hoạt động"
|
|
425
|
+
variant="destructive"
|
|
426
|
+
onConfirm={confirmDeactivate}
|
|
427
|
+
/>
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// Route-handler cho `/api/workspaces` — nơi phạm vi được cưỡng chế THẬT.
|
|
3
|
+
//
|
|
4
|
+
// Trang cây ở `pages/workspace-list-page.tsx` đã ẩn nút ở nhánh không quản trị
|
|
5
|
+
// được, nhưng ẩn nút không phải là hàng rào: ai cũng gọi thẳng API được. Entra
|
|
6
|
+
// từng thừa nhận đúng lỗi này (UI lọc, API thì không) — nên mọi kiểm tra ở đây
|
|
7
|
+
// lặp lại độc lập với UI, không tin gì từ client.
|
|
8
|
+
//
|
|
9
|
+
// Cách dùng (phía app):
|
|
10
|
+
// // src/app/api/workspaces/route.ts
|
|
11
|
+
// import { createWorkspaceCollectionHandlers } from "@goerp/core/workspace/route-handlers"
|
|
12
|
+
// export const { GET, POST } = createWorkspaceCollectionHandlers({
|
|
13
|
+
// prisma, getSession, canManageAll: (s) => checkPermission(s, "workspace", "update"),
|
|
14
|
+
// })
|
|
15
|
+
|
|
16
|
+
import { getWorkspaceScope, isWorkspaceAdmin } from "./scope";
|
|
17
|
+
import {
|
|
18
|
+
buildTree,
|
|
19
|
+
createWorkspace,
|
|
20
|
+
deactivateSubtree,
|
|
21
|
+
moveWorkspace,
|
|
22
|
+
} from "./service";
|
|
23
|
+
import { WorkspaceTreeError } from "./tree";
|
|
24
|
+
import type { WorkspaceScope } from "./types";
|
|
25
|
+
|
|
26
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
27
|
+
|
|
28
|
+
export interface WorkspaceHandlerDeps {
|
|
29
|
+
prisma: any;
|
|
30
|
+
getSession: () => MaybePromise<any | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Quyền quản trị toàn hệ thống. Bỏ trống ⇒ suy từ `scope.canViewAll`, tức là
|
|
33
|
+
* "xem được tất" cũng "sửa được tất" — chỉ đúng với app một tổ chức.
|
|
34
|
+
*/
|
|
35
|
+
canManageAll?: (session: any) => MaybePromise<boolean>;
|
|
36
|
+
/** Gate đọc; bỏ trống = ai đăng nhập cũng đọc được cây (trong phạm vi của mình). */
|
|
37
|
+
canRead?: (session: any) => MaybePromise<boolean>;
|
|
38
|
+
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const json = (data: unknown, status = 200) =>
|
|
42
|
+
new Response(JSON.stringify(data), {
|
|
43
|
+
status,
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Lỗi cây là lỗi NGHIỆP VỤ (chu trình, quá sâu, sai cấp) → 400 kèm câu nguyên văn. */
|
|
48
|
+
function failure(error: unknown, req: Request, deps: WorkspaceHandlerDeps) {
|
|
49
|
+
if (error instanceof WorkspaceTreeError) {
|
|
50
|
+
return json({ error: error.message }, 400);
|
|
51
|
+
}
|
|
52
|
+
return deps.onError
|
|
53
|
+
? deps.onError(error, req)
|
|
54
|
+
: json({ error: "Internal error" }, 500);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Actor {
|
|
58
|
+
session: any;
|
|
59
|
+
scope: WorkspaceScope;
|
|
60
|
+
manageAll: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resolveActor(
|
|
64
|
+
deps: WorkspaceHandlerDeps,
|
|
65
|
+
): Promise<Actor | Response> {
|
|
66
|
+
const session = await deps.getSession();
|
|
67
|
+
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
68
|
+
if (deps.canRead && !(await deps.canRead(session))) {
|
|
69
|
+
return json({ error: "Forbidden" }, 403);
|
|
70
|
+
}
|
|
71
|
+
const scope = await getWorkspaceScope(session);
|
|
72
|
+
const manageAll = deps.canManageAll
|
|
73
|
+
? await deps.canManageAll(session)
|
|
74
|
+
: scope.canViewAll;
|
|
75
|
+
return { session, scope, manageAll };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Ghi được nút này không? Toàn quyền, hoặc là quản trị viên của đúng nhánh đó. */
|
|
79
|
+
function assertWritable(actor: Actor, workspaceId: string): Response | null {
|
|
80
|
+
if (actor.manageAll) return null;
|
|
81
|
+
if (isWorkspaceAdmin(actor.scope, workspaceId)) return null;
|
|
82
|
+
return json({ error: "Không gian này ngoài phạm vi quản trị của bạn." }, 403);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cắt `path` / `parentId` trước khi trả về client.
|
|
87
|
+
*
|
|
88
|
+
* Cây đã dựng xong ở server nên UI không cần hai trường này, mà `path` là chuỗi
|
|
89
|
+
* `/{tổ-tiên}/…/{chính-nó}/` — gửi nguyên đi là lộ id của những nút cha mà người
|
|
90
|
+
* này không được thấy. Ít thông tin, nhưng vẫn là thông tin về cấu trúc tổ chức
|
|
91
|
+
* của tenant khác, và trường thừa thì không có lý do gì để rò.
|
|
92
|
+
*/
|
|
93
|
+
function toClient(nodes: any[]): any[] {
|
|
94
|
+
return nodes.map(({ path: _path, parentId: _parentId, ...node }) => ({
|
|
95
|
+
...node,
|
|
96
|
+
children: toClient(node.children ?? []),
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** GET (cây) + POST (tạo nút) cho `/api/workspaces`. */
|
|
101
|
+
export function createWorkspaceCollectionHandlers(deps: WorkspaceHandlerDeps) {
|
|
102
|
+
async function GET(req: Request) {
|
|
103
|
+
try {
|
|
104
|
+
const actor = await resolveActor(deps);
|
|
105
|
+
if (actor instanceof Response) return actor;
|
|
106
|
+
|
|
107
|
+
const rows = await deps.prisma.workspace.findMany({
|
|
108
|
+
orderBy: { path: "asc" },
|
|
109
|
+
select: {
|
|
110
|
+
id: true,
|
|
111
|
+
code: true,
|
|
112
|
+
name: true,
|
|
113
|
+
kind: true,
|
|
114
|
+
parentId: true,
|
|
115
|
+
path: true,
|
|
116
|
+
depth: true,
|
|
117
|
+
isActive: true,
|
|
118
|
+
_count: { select: { members: true } },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Lọc ở SERVER, không ở trang. Người dùng chỉ nhận về nhánh mình thấy được
|
|
123
|
+
// — nếu không thì cây của mọi khách hàng khác nằm sẵn trong payload HTML.
|
|
124
|
+
const allowed = actor.scope.canViewAll
|
|
125
|
+
? null
|
|
126
|
+
: new Set(actor.scope.allowedIds ?? []);
|
|
127
|
+
const visible = allowed
|
|
128
|
+
? rows.filter((r: any) => allowed.has(r.id))
|
|
129
|
+
: rows;
|
|
130
|
+
|
|
131
|
+
const nodes = visible.map((r: any) => ({
|
|
132
|
+
id: r.id,
|
|
133
|
+
code: r.code,
|
|
134
|
+
name: r.name,
|
|
135
|
+
kind: r.kind,
|
|
136
|
+
parentId: r.parentId,
|
|
137
|
+
path: r.path,
|
|
138
|
+
isActive: r.isActive,
|
|
139
|
+
memberCount: r._count?.members ?? 0,
|
|
140
|
+
}));
|
|
141
|
+
return json(toClient(buildTree(nodes)));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return failure(error, req, deps);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function POST(req: Request) {
|
|
148
|
+
try {
|
|
149
|
+
const actor = await resolveActor(deps);
|
|
150
|
+
if (actor instanceof Response) return actor;
|
|
151
|
+
|
|
152
|
+
const body = await req.json();
|
|
153
|
+
const parentId: string | null = body?.parentId ?? null;
|
|
154
|
+
|
|
155
|
+
// Tạo nút GỐC là việc của toàn quyền: nút gốc nằm ngoài mọi nhánh nên
|
|
156
|
+
// không ai "quản trị" nó, và cho phép thì admin khách hàng tự dựng được
|
|
157
|
+
// một tổ chức song song bên cạnh tổ chức của mình.
|
|
158
|
+
if (!parentId) {
|
|
159
|
+
if (!actor.manageAll) {
|
|
160
|
+
return json(
|
|
161
|
+
{ error: "Chỉ quản trị toàn hệ thống tạo được không gian gốc." },
|
|
162
|
+
403,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
const denied = assertWritable(actor, parentId);
|
|
167
|
+
if (denied) return denied;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const created = await createWorkspace(deps.prisma, {
|
|
171
|
+
code: String(body?.code ?? "").trim(),
|
|
172
|
+
name: String(body?.name ?? "").trim(),
|
|
173
|
+
kind: body?.kind,
|
|
174
|
+
parentId,
|
|
175
|
+
});
|
|
176
|
+
return json(created, 201);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return failure(error, req, deps);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { GET, POST };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** PATCH (sửa) + DELETE (ngừng hoạt động cả nhánh) cho `/api/workspaces/[id]`. */
|
|
186
|
+
export function createWorkspaceItemHandlers(deps: WorkspaceHandlerDeps) {
|
|
187
|
+
type Ctx = { params: Promise<{ id: string }> | { id: string } };
|
|
188
|
+
|
|
189
|
+
const readId = async (ctx: Ctx) => (await ctx.params).id;
|
|
190
|
+
|
|
191
|
+
async function PATCH(req: Request, ctx: Ctx) {
|
|
192
|
+
try {
|
|
193
|
+
const actor = await resolveActor(deps);
|
|
194
|
+
if (actor instanceof Response) return actor;
|
|
195
|
+
const id = await readId(ctx);
|
|
196
|
+
const denied = assertWritable(actor, id);
|
|
197
|
+
if (denied) return denied;
|
|
198
|
+
|
|
199
|
+
const body = await req.json();
|
|
200
|
+
const data: Record<string, unknown> = {};
|
|
201
|
+
if (body?.code !== undefined) data.code = String(body.code).trim();
|
|
202
|
+
if (body?.name !== undefined) data.name = String(body.name).trim();
|
|
203
|
+
if (body?.kind !== undefined) data.kind = body.kind;
|
|
204
|
+
if (body?.settings !== undefined) data.settings = body.settings;
|
|
205
|
+
// `path` / `depth` / `parentId` cố ý KHÔNG nhận từ body: sửa tay là cách
|
|
206
|
+
// làm hỏng cây mà UI vẫn trông đúng. Đổi cha đi qua `/move`.
|
|
207
|
+
|
|
208
|
+
const updated = await deps.prisma.workspace.update({
|
|
209
|
+
where: { id },
|
|
210
|
+
data,
|
|
211
|
+
});
|
|
212
|
+
return json(updated);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return failure(error, req, deps);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function DELETE(req: Request, ctx: Ctx) {
|
|
219
|
+
try {
|
|
220
|
+
const actor = await resolveActor(deps);
|
|
221
|
+
if (actor instanceof Response) return actor;
|
|
222
|
+
const id = await readId(ctx);
|
|
223
|
+
const denied = assertWritable(actor, id);
|
|
224
|
+
if (denied) return denied;
|
|
225
|
+
|
|
226
|
+
// Soft-delete: xoá cứng thì phạm vi của chứng từ cũ thành mồ côi.
|
|
227
|
+
const count = await deactivateSubtree(deps.prisma, id);
|
|
228
|
+
return json({ deactivated: count });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return failure(error, req, deps);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { PATCH, DELETE };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** POST cho `/api/workspaces/[id]/move`. */
|
|
238
|
+
export function createWorkspaceMoveHandler(deps: WorkspaceHandlerDeps) {
|
|
239
|
+
type Ctx = { params: Promise<{ id: string }> | { id: string } };
|
|
240
|
+
|
|
241
|
+
async function POST(req: Request, ctx: Ctx) {
|
|
242
|
+
try {
|
|
243
|
+
const actor = await resolveActor(deps);
|
|
244
|
+
if (actor instanceof Response) return actor;
|
|
245
|
+
const { id } = await ctx.params;
|
|
246
|
+
|
|
247
|
+
const denied = assertWritable(actor, id);
|
|
248
|
+
if (denied) return denied;
|
|
249
|
+
|
|
250
|
+
const body = await req.json();
|
|
251
|
+
const newParentId: string | null = body?.newParentId ?? null;
|
|
252
|
+
|
|
253
|
+
// Phải quản trị được CẢ hai đầu. Chỉ kiểm nút bị chuyển thì admin nhánh
|
|
254
|
+
// đẩy được nhánh của mình sang dưới tổ chức khác — hoặc lên gốc, thành
|
|
255
|
+
// một tổ chức độc lập ngang hàng với tổ chức mẹ.
|
|
256
|
+
if (newParentId) {
|
|
257
|
+
const deniedTarget = assertWritable(actor, newParentId);
|
|
258
|
+
if (deniedTarget) return deniedTarget;
|
|
259
|
+
} else if (!actor.manageAll) {
|
|
260
|
+
return json(
|
|
261
|
+
{ error: "Chỉ quản trị toàn hệ thống chuyển được lên cấp cao nhất." },
|
|
262
|
+
403,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const moved = await moveWorkspace(deps.prisma, { id, newParentId });
|
|
267
|
+
return json({ updated: moved });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return failure(error, req, deps);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { POST };
|
|
274
|
+
}
|