@goplusvn/core 0.1.79 → 0.1.82
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 +69 -0
- package/package.json +1 -1
- package/src/rbac/__tests__/route-handlers.test.ts +69 -0
- package/src/rbac/components/roles/role-card.tsx +24 -0
- package/src/rbac/pages/role-form-page.tsx +86 -3
- package/src/rbac/role-service.ts +27 -0
- package/src/rbac/route-handlers.ts +13 -1
- package/src/rbac/types.ts +6 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +73 -6
- package/src/workspace/components/workspace-member-picker-dialog.tsx +492 -0
- package/src/workspace/components/workspace-members-panel.tsx +63 -161
- package/src/workspace/components/workspace-tree-panel.tsx +369 -0
- package/src/workspace/delegation.ts +6 -8
- package/src/workspace/index.ts +13 -3
- package/src/workspace/pages/workspace-list-page.tsx +106 -46
- package/src/workspace/route-handlers.ts +218 -4
- package/src/workspace/service.ts +22 -0
- package/src/workspace/components/workspace-org-block.tsx +0 -293
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
buildTree,
|
|
26
26
|
createWorkspace,
|
|
27
27
|
deactivateSubtree,
|
|
28
|
+
reactivateSubtree,
|
|
28
29
|
moveWorkspace,
|
|
29
30
|
} from "./service";
|
|
30
31
|
import { WorkspaceTreeError } from "./tree";
|
|
@@ -50,6 +51,54 @@ export interface WorkspaceHandlerDeps {
|
|
|
50
51
|
* cho tới khi người dùng F5.
|
|
51
52
|
*/
|
|
52
53
|
kinds?: { key: string; label: string }[];
|
|
54
|
+
/**
|
|
55
|
+
* Bộ lọc ứng viên do APP định nghĩa, cho màn hình gán hàng loạt.
|
|
56
|
+
*
|
|
57
|
+
* Core cố ý không biết "chi nhánh" hay "bộ phận" là gì — mỗi app có trục
|
|
58
|
+
* khác nhau. App nhận `URLSearchParams` và trả về một mảnh `where` của
|
|
59
|
+
* Prisma trên model `User`; core chỉ AND nó vào truy vấn ứng viên. Bỏ trống
|
|
60
|
+
* thì hộp tìm kiếm chạy y như cũ.
|
|
61
|
+
*
|
|
62
|
+
* Mảnh `where` này KHÔNG thay thế `memberScopeWhere(..., adminOnly)` — nó
|
|
63
|
+
* chồng thêm, nên app không thể dùng nó để với ra ngoài phạm vi quản trị.
|
|
64
|
+
*/
|
|
65
|
+
candidateFilter?: (params: URLSearchParams) => unknown;
|
|
66
|
+
/**
|
|
67
|
+
* Các nhánh tìm kiếm THÊM cho ô "tìm người", ngoài tên và email của core.
|
|
68
|
+
*
|
|
69
|
+
* Người đi gán gõ MÃ NHÂN VIÊN, không gõ email — nhưng mã nhân viên nằm ở
|
|
70
|
+
* bảng hồ sơ của app (`Employee.employee_code` ở spartronics), core không
|
|
71
|
+
* biết bảng đó tồn tại. App trả về các mảnh `where` sẽ được OR vào cùng tên
|
|
72
|
+
* và email. Bỏ trống thì tìm kiếm giữ nguyên phạm vi tên + email.
|
|
73
|
+
*/
|
|
74
|
+
candidateSearch?: (query: string) => unknown[];
|
|
75
|
+
/**
|
|
76
|
+
* Danh tính người dùng để hiện KÈM mỗi dòng: chi nhánh nào, vai trò gì.
|
|
77
|
+
*
|
|
78
|
+
* Cùng lý do với `candidateFilter`: "chi nhánh" và "vai trò" là quan hệ của
|
|
79
|
+
* app, không phải của core — nhét thẳng `userBranches` / `userRoles` vào
|
|
80
|
+
* truy vấn thì app nào không có đúng hai quan hệ đó sẽ vỡ ngay lúc chạy.
|
|
81
|
+
* Nên app đưa `select` của mình vào, và tự rút gọn dòng user thành nhãn.
|
|
82
|
+
*
|
|
83
|
+
* Bỏ trống thì danh sách chỉ còn tên + email + các không gian đang thuộc về
|
|
84
|
+
* (phần đó core tự biết) — đúng như trước khi có tuỳ chọn này.
|
|
85
|
+
*/
|
|
86
|
+
memberIdentity?: {
|
|
87
|
+
/** Mảnh `select` ghép thêm vào truy vấn `User`. */
|
|
88
|
+
select: Record<string, unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* Rút gọn một dòng user thành nhãn hiển thị.
|
|
91
|
+
*
|
|
92
|
+
* `code` là mã nhân viên (hoặc mã nhân sự tương đương) — hiện ngay cạnh
|
|
93
|
+
* tên, vì đó là thứ người đi gán đọc để chắc chắn không nhầm hai người
|
|
94
|
+
* trùng tên.
|
|
95
|
+
*/
|
|
96
|
+
describe: (user: any) => {
|
|
97
|
+
branch?: string | null;
|
|
98
|
+
roles?: string[];
|
|
99
|
+
code?: string | null;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
53
102
|
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
54
103
|
}
|
|
55
104
|
|
|
@@ -235,6 +284,17 @@ export function createWorkspaceItemHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
235
284
|
if (body?.name !== undefined) data.name = String(body.name).trim();
|
|
236
285
|
if (body?.kind !== undefined) data.kind = body.kind;
|
|
237
286
|
if (body?.settings !== undefined) data.settings = body.settings;
|
|
287
|
+
|
|
288
|
+
// Bật lại một nút đã ngừng — đi cả nhánh, nên tách khỏi `update` thường.
|
|
289
|
+
// Chỉ nhận `isActive: true`: TẮT vẫn phải đi qua DELETE, nơi có câu xác
|
|
290
|
+
// nhận nói rõ là cả nhánh cùng ngừng.
|
|
291
|
+
if (body?.isActive === true) {
|
|
292
|
+
const count = await reactivateSubtree(deps.prisma, id);
|
|
293
|
+
if (Object.keys(data).length > 0) {
|
|
294
|
+
await deps.prisma.workspace.update({ where: { id }, data });
|
|
295
|
+
}
|
|
296
|
+
return json({ reactivated: count });
|
|
297
|
+
}
|
|
238
298
|
// `path` / `depth` / `parentId` cố ý KHÔNG nhận từ body: sửa tay là cách
|
|
239
299
|
// làm hỏng cây mà UI vẫn trông đúng. Đổi cha đi qua `/move`.
|
|
240
300
|
|
|
@@ -329,6 +389,33 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
329
389
|
};
|
|
330
390
|
}
|
|
331
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Các không gian một người ĐANG thuộc về, do core tự đọc.
|
|
394
|
+
*
|
|
395
|
+
* Không phải trang trí: phạm vi đọc của một người là HỢP các không gian họ
|
|
396
|
+
* thuộc về, nên "còn ở đâu nữa" là thứ quyết định việc gán này có cô lập
|
|
397
|
+
* được họ hay không. Thiếu cột đó thì người gán bấm "Thêm" và tưởng đã xong.
|
|
398
|
+
*/
|
|
399
|
+
const membershipSelect = {
|
|
400
|
+
userWorkspaces: {
|
|
401
|
+
select: { workspaceId: true, workspace: { select: { name: true } } },
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Ghép nhãn danh tính vào một dòng user; `skipId` bỏ chính không gian đang mở. */
|
|
406
|
+
function describeUser(user: any, skipId?: string) {
|
|
407
|
+
const extra = deps.memberIdentity?.describe(user) ?? {};
|
|
408
|
+
return {
|
|
409
|
+
code: extra.code ?? null,
|
|
410
|
+
branch: extra.branch ?? null,
|
|
411
|
+
roles: extra.roles ?? [],
|
|
412
|
+
workspaces: (user.userWorkspaces ?? [])
|
|
413
|
+
.filter((m: any) => m.workspaceId !== skipId)
|
|
414
|
+
.map((m: any) => m.workspace?.name)
|
|
415
|
+
.filter(Boolean) as string[],
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
332
419
|
async function GET(req: Request, ctx: Ctx) {
|
|
333
420
|
try {
|
|
334
421
|
const actor = await resolveActor(deps);
|
|
@@ -353,26 +440,64 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
353
440
|
// Ứng viên chỉ lấy trong nhánh mình QUẢN TRỊ (adminOnly), không phải
|
|
354
441
|
// nhánh mình xem được: xem được cả công ty mà thêm được ai cũng vào
|
|
355
442
|
// nhánh mình thì D1 chỉ còn là trang trí.
|
|
443
|
+
// Gán hàng loạt cần nhìn thấy cả danh sách để tick, không phải 20 dòng
|
|
444
|
+
// đầu — nhưng vẫn có trần, danh sách vài nghìn dòng thì trình duyệt
|
|
445
|
+
// đơ chứ không giúp ai chọn nhanh hơn.
|
|
446
|
+
const take = Math.min(
|
|
447
|
+
Math.max(Number(url.searchParams.get("take")) || 20, 1),
|
|
448
|
+
200,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* "Lấy người đang ở bộ phận X" — trục lọc do CORE lo, không phải app.
|
|
453
|
+
*
|
|
454
|
+
* Bảng `userWorkspace` là của core, nên đây là trục duy nhất core tự
|
|
455
|
+
* dựng được. Nó cũng là cách người dùng thật mô tả việc mình đang làm
|
|
456
|
+
* ("chuyển cả tổ QC sang Kho"), trong khi `candidateFilter` của app lo
|
|
457
|
+
* những trục app mới biết (chi nhánh, vai trò).
|
|
458
|
+
*/
|
|
459
|
+
const inWorkspaceId = url.searchParams.get("inWorkspaceId") ?? "";
|
|
460
|
+
|
|
356
461
|
const rows = await deps.prisma.user.findMany({
|
|
357
462
|
where: {
|
|
358
463
|
AND: [
|
|
359
464
|
memberScopeWhere(actor.scope, { adminOnly: true }),
|
|
360
465
|
{ userWorkspaces: { none: { workspaceId: id } } },
|
|
466
|
+
inWorkspaceId
|
|
467
|
+
? { userWorkspaces: { some: { workspaceId: inWorkspaceId } } }
|
|
468
|
+
: {},
|
|
361
469
|
query
|
|
362
470
|
? {
|
|
363
471
|
OR: [
|
|
364
472
|
{ name: { contains: query, mode: "insensitive" } },
|
|
365
473
|
{ email: { contains: query, mode: "insensitive" } },
|
|
474
|
+
...(deps.candidateSearch?.(query) ?? []),
|
|
366
475
|
],
|
|
367
476
|
}
|
|
368
477
|
: {},
|
|
478
|
+
(deps.candidateFilter?.(url.searchParams) ?? {}) as object,
|
|
369
479
|
],
|
|
370
480
|
},
|
|
371
|
-
select: {
|
|
481
|
+
select: {
|
|
482
|
+
id: true,
|
|
483
|
+
name: true,
|
|
484
|
+
email: true,
|
|
485
|
+
isActive: true,
|
|
486
|
+
...membershipSelect,
|
|
487
|
+
...(deps.memberIdentity?.select ?? {}),
|
|
488
|
+
},
|
|
372
489
|
orderBy: { name: "asc" },
|
|
373
|
-
take
|
|
490
|
+
take,
|
|
374
491
|
});
|
|
375
|
-
return json(
|
|
492
|
+
return json(
|
|
493
|
+
rows.map((u: any) => ({
|
|
494
|
+
id: u.id,
|
|
495
|
+
name: u.name,
|
|
496
|
+
email: u.email,
|
|
497
|
+
isActive: u.isActive,
|
|
498
|
+
...describeUser(u),
|
|
499
|
+
})),
|
|
500
|
+
);
|
|
376
501
|
}
|
|
377
502
|
|
|
378
503
|
const rows = await deps.prisma.userWorkspace.findMany({
|
|
@@ -381,7 +506,14 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
381
506
|
isAdmin: true,
|
|
382
507
|
isDefault: true,
|
|
383
508
|
user: {
|
|
384
|
-
select: {
|
|
509
|
+
select: {
|
|
510
|
+
id: true,
|
|
511
|
+
name: true,
|
|
512
|
+
email: true,
|
|
513
|
+
isActive: true,
|
|
514
|
+
...membershipSelect,
|
|
515
|
+
...(deps.memberIdentity?.select ?? {}),
|
|
516
|
+
},
|
|
385
517
|
},
|
|
386
518
|
},
|
|
387
519
|
orderBy: [{ isAdmin: "desc" }, { user: { name: "asc" } }],
|
|
@@ -394,6 +526,9 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
394
526
|
isActive: r.user.isActive,
|
|
395
527
|
isAdmin: r.isAdmin,
|
|
396
528
|
isDefault: r.isDefault,
|
|
529
|
+
// Bỏ chính nút đang mở ra khỏi danh sách "cũng ở": ai cũng thuộc về
|
|
530
|
+
// nó, nhắc lại trên mọi dòng thì cột này không còn nói được gì.
|
|
531
|
+
...describeUser(r.user, id),
|
|
397
532
|
})),
|
|
398
533
|
);
|
|
399
534
|
} catch (error) {
|
|
@@ -411,6 +546,85 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
411
546
|
if (denied) return denied;
|
|
412
547
|
|
|
413
548
|
const body = await req.json();
|
|
549
|
+
|
|
550
|
+
// Đường GÁN HÀNG LOẠT. Tách hẳn khỏi đường một-người bên dưới vì ngữ
|
|
551
|
+
// nghĩa khác nhau: ở đây `mode: "move"` GỠ mọi membership khác của người
|
|
552
|
+
// đó. Phải gỡ thật — phạm vi là HỢP của các membership, nên "thêm vào bộ
|
|
553
|
+
// phận Kho" mà vẫn giữ membership gốc thì người đó vẫn thấy toàn hệ
|
|
554
|
+
// thống, tức là bấm gán xong mà không cô lập được gì.
|
|
555
|
+
if (Array.isArray(body?.userIds)) {
|
|
556
|
+
const userIds = [
|
|
557
|
+
...new Set(
|
|
558
|
+
body.userIds
|
|
559
|
+
.filter((v: unknown) => typeof v === "string")
|
|
560
|
+
.map((v: string) => v.trim())
|
|
561
|
+
.filter(Boolean),
|
|
562
|
+
),
|
|
563
|
+
] as string[];
|
|
564
|
+
if (userIds.length === 0) {
|
|
565
|
+
return json({ error: "Chưa chọn người dùng nào." }, 400);
|
|
566
|
+
}
|
|
567
|
+
const move = body?.mode !== "add";
|
|
568
|
+
|
|
569
|
+
const failed: { userId: string; error: string }[] = [];
|
|
570
|
+
let changed = 0;
|
|
571
|
+
|
|
572
|
+
for (const target0 of userIds) {
|
|
573
|
+
const target = await loadTarget(target0);
|
|
574
|
+
if (!target) {
|
|
575
|
+
failed.push({
|
|
576
|
+
userId: target0,
|
|
577
|
+
error: "Không tìm thấy người dùng.",
|
|
578
|
+
});
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const next = move ? [id] : [...new Set([...target.workspaceIds, id])];
|
|
582
|
+
try {
|
|
583
|
+
assertCanUpdateUser(toDelegationActor(actor), target, {
|
|
584
|
+
workspaceIds: next,
|
|
585
|
+
});
|
|
586
|
+
} catch (error) {
|
|
587
|
+
failed.push({
|
|
588
|
+
userId: target0,
|
|
589
|
+
error:
|
|
590
|
+
error instanceof DelegationError
|
|
591
|
+
? error.message
|
|
592
|
+
: "Không cập nhật được.",
|
|
593
|
+
});
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
await deps.prisma.$transaction(async (tx: any) => {
|
|
598
|
+
if (move) {
|
|
599
|
+
await tx.userWorkspace.deleteMany({
|
|
600
|
+
where: { userId: target0, NOT: { workspaceId: id } },
|
|
601
|
+
});
|
|
602
|
+
} else {
|
|
603
|
+
await tx.userWorkspace.updateMany({
|
|
604
|
+
where: {
|
|
605
|
+
userId: target0,
|
|
606
|
+
isDefault: true,
|
|
607
|
+
NOT: { workspaceId: id },
|
|
608
|
+
},
|
|
609
|
+
data: { isDefault: false },
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
await tx.userWorkspace.upsert({
|
|
613
|
+
where: {
|
|
614
|
+
userId_workspaceId: { userId: target0, workspaceId: id },
|
|
615
|
+
},
|
|
616
|
+
// Không đụng `isAdmin` của người đã ở sẵn trong nút: gán hàng loạt
|
|
617
|
+
// mà hạ cờ quản trị của họ là một tác dụng phụ không ai yêu cầu.
|
|
618
|
+
create: { userId: target0, workspaceId: id, isDefault: true },
|
|
619
|
+
update: { isDefault: true },
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
changed += 1;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return json({ changed, failed, mode: move ? "move" : "add" });
|
|
626
|
+
}
|
|
627
|
+
|
|
414
628
|
const userId = String(body?.userId ?? "").trim();
|
|
415
629
|
if (!userId) return json({ error: "Thiếu userId." }, 400);
|
|
416
630
|
|
package/src/workspace/service.ts
CHANGED
|
@@ -255,6 +255,28 @@ export async function deactivateSubtree(
|
|
|
255
255
|
return nodes.length;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Bật lại cả nhánh — đối xứng với `deactivateSubtree`.
|
|
260
|
+
*
|
|
261
|
+
* Phải có, nếu không thì "xoá" là một chiều: nút đã ngừng nằm mãi trong danh
|
|
262
|
+
* sách với chữ "(ngừng)" và không đường nào đưa về. Bật lại CẢ NHÁNH chứ không
|
|
263
|
+
* riêng nút được bấm: nhánh tắt theo nhau thì cũng phải bật theo nhau, bật mỗi
|
|
264
|
+
* nút cha để lại một cây nửa sống nửa chết mà giao diện không nói ra được.
|
|
265
|
+
*/
|
|
266
|
+
export async function reactivateSubtree(
|
|
267
|
+
db: WorkspaceServiceDb,
|
|
268
|
+
rootId: string,
|
|
269
|
+
): Promise<number> {
|
|
270
|
+
const nodes = await listSubtree(db, rootId);
|
|
271
|
+
for (const node of nodes) {
|
|
272
|
+
await db.workspace.update({
|
|
273
|
+
where: { id: node.id },
|
|
274
|
+
data: { isActive: true },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return nodes.length;
|
|
278
|
+
}
|
|
279
|
+
|
|
258
280
|
/** Dựng cây lồng nhau cho UI từ danh sách phẳng. */
|
|
259
281
|
export interface WorkspaceTreeItem extends WorkspaceNode {
|
|
260
282
|
children: WorkspaceTreeItem[];
|
|
@@ -1,293 +0,0 @@
|
|
|
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
|
-
}
|