@goplusvn/core 0.1.21 → 0.1.23
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 +22 -0
- package/package.json +1 -1
- package/src/rbac/components/roles/role-card.tsx +25 -24
- package/src/rbac/components/roles/role-toolbar.tsx +70 -84
- package/src/rbac/index.ts +8 -0
- package/src/rbac/lib/fetch-error.ts +26 -0
- package/src/rbac/pages/action-list-page.tsx +581 -19
- package/src/rbac/pages/index.ts +13 -0
- package/src/rbac/pages/resource-list-page.tsx +1415 -19
- package/src/rbac/pages/role-form-page.tsx +704 -0
- package/src/rbac/pages/role-list-page.tsx +14 -12
- package/src/ui/forms/date-picker.tsx +167 -19
- package/src/ui/forms/multi-select.tsx +475 -153
|
@@ -1,25 +1,587 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
// Trang "Quản lý hành động" — danh sách hành động RBAC kèm ngữ cảnh sử dụng
|
|
4
|
+
// (tài nguyên · vai trò · người dùng). Bề mặt trắng (bg-card), nút tác vụ
|
|
5
|
+
// LUÔN hiển thị (không ẩn sau hover — mobile không có hover).
|
|
6
|
+
//
|
|
7
|
+
// API contract: GET/POST/PUT/DELETE /api/actions, GET /api/actions/usage
|
|
8
|
+
// (usage là optional — trang vẫn chạy khi endpoint không tồn tại).
|
|
9
|
+
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { toast } from "sonner";
|
|
12
|
+
import {
|
|
13
|
+
ChevronRight,
|
|
14
|
+
Pencil,
|
|
15
|
+
Plus,
|
|
16
|
+
RefreshCw,
|
|
17
|
+
Search,
|
|
18
|
+
Trash2,
|
|
19
|
+
X,
|
|
20
|
+
Zap,
|
|
21
|
+
} from "lucide-react";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Badge,
|
|
25
|
+
Button,
|
|
26
|
+
ConfirmDialog,
|
|
27
|
+
Dialog,
|
|
28
|
+
DialogContent,
|
|
29
|
+
DialogFooter,
|
|
30
|
+
DialogHeader,
|
|
31
|
+
DialogTitle,
|
|
32
|
+
Input,
|
|
33
|
+
Label,
|
|
34
|
+
PageHeader,
|
|
35
|
+
Skeleton,
|
|
36
|
+
Textarea,
|
|
37
|
+
} from "../../ui";
|
|
38
|
+
import { cn } from "../../utils";
|
|
39
|
+
import { errorMessage, throwFetchError } from "../lib/fetch-error";
|
|
40
|
+
|
|
41
|
+
// --- Types ---
|
|
42
|
+
interface Action {
|
|
43
|
+
id: string;
|
|
44
|
+
code: string;
|
|
45
|
+
name: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
isDefault?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface UsageEntry {
|
|
51
|
+
resources: { code: string; name: string }[];
|
|
52
|
+
roles: { code: string; name: string }[];
|
|
53
|
+
users: { id: string; name: string | null; email: string }[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type UsageMap = Record<string, UsageEntry>;
|
|
57
|
+
|
|
58
|
+
const EMPTY_USAGE: UsageEntry = { resources: [], roles: [], users: [] };
|
|
59
|
+
|
|
60
|
+
const normalize = (s: string) =>
|
|
61
|
+
(s || "")
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.normalize("NFD")
|
|
64
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
65
|
+
.replace(/đ/g, "d");
|
|
66
|
+
|
|
67
|
+
interface Permissions {
|
|
68
|
+
create?: boolean;
|
|
69
|
+
update?: boolean;
|
|
70
|
+
delete?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ActionListPageProps {
|
|
74
|
+
permissions?: Permissions;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ActionListPage({ permissions = {} }: ActionListPageProps) {
|
|
78
|
+
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [actions, setActions] = useState<Action[]>([]);
|
|
80
|
+
const [usage, setUsage] = useState<UsageMap>({});
|
|
81
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
82
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
83
|
+
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
|
84
|
+
|
|
85
|
+
// Create/Edit dialog
|
|
86
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
87
|
+
const [editing, setEditing] = useState<Action | null>(null);
|
|
88
|
+
const [formData, setFormData] = useState({
|
|
89
|
+
code: "",
|
|
90
|
+
name: "",
|
|
91
|
+
description: "",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Delete confirm
|
|
95
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
96
|
+
const [toDelete, setToDelete] = useState<Action | null>(null);
|
|
97
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
98
|
+
|
|
99
|
+
const canCreate = !!permissions.create;
|
|
100
|
+
const canUpdate = !!permissions.update;
|
|
101
|
+
const canDelete = !!permissions.delete;
|
|
102
|
+
|
|
103
|
+
const fetchData = async () => {
|
|
104
|
+
try {
|
|
105
|
+
setLoading(true);
|
|
106
|
+
const [resActions, resUsage] = await Promise.all([
|
|
107
|
+
fetch("/api/actions?pageSize=1000"),
|
|
108
|
+
fetch("/api/actions/usage"),
|
|
109
|
+
]);
|
|
110
|
+
if (!resActions.ok) throw new Error("Không tải được danh sách hành động");
|
|
111
|
+
|
|
112
|
+
const actionsData = await resActions.json();
|
|
113
|
+
setActions(actionsData.items || []);
|
|
114
|
+
|
|
115
|
+
if (resUsage.ok) {
|
|
116
|
+
const usageData = await resUsage.json();
|
|
117
|
+
setUsage(usageData.usage || {});
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
|
|
121
|
+
} finally {
|
|
122
|
+
setLoading(false);
|
|
123
|
+
setIsRefreshing(false);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
fetchData();
|
|
129
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const handleRefresh = () => {
|
|
133
|
+
setIsRefreshing(true);
|
|
134
|
+
fetchData();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const toggleRow = (id: string) => {
|
|
138
|
+
setExpandedRows((prev) => {
|
|
139
|
+
const next = new Set(prev);
|
|
140
|
+
if (next.has(id)) next.delete(id);
|
|
141
|
+
else next.add(id);
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const filtered = useMemo(() => {
|
|
147
|
+
const q = normalize(searchTerm.trim());
|
|
148
|
+
const list = q
|
|
149
|
+
? actions.filter(
|
|
150
|
+
(a) =>
|
|
151
|
+
normalize(a.name).includes(q) ||
|
|
152
|
+
normalize(a.code).includes(q) ||
|
|
153
|
+
normalize(a.description || "").includes(q),
|
|
154
|
+
)
|
|
155
|
+
: actions;
|
|
156
|
+
return [...list].sort((a, b) => a.name.localeCompare(b.name, "vi"));
|
|
157
|
+
}, [actions, searchTerm]);
|
|
158
|
+
|
|
159
|
+
// --- CRUD ---
|
|
160
|
+
const openNew = () => {
|
|
161
|
+
setEditing(null);
|
|
162
|
+
setFormData({ code: "", name: "", description: "" });
|
|
163
|
+
setIsDialogOpen(true);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const openEdit = (action: Action) => {
|
|
167
|
+
setEditing(action);
|
|
168
|
+
setFormData({
|
|
169
|
+
code: action.code,
|
|
170
|
+
name: action.name,
|
|
171
|
+
description: action.description || "",
|
|
172
|
+
});
|
|
173
|
+
setIsDialogOpen(true);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleSubmit = async () => {
|
|
177
|
+
if (!formData.code || !formData.name) {
|
|
178
|
+
toast.error("Vui lòng nhập Mã và Tên hành động");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const payload = { ...formData, status: "active" };
|
|
183
|
+
const res = await fetch("/api/actions", {
|
|
184
|
+
method: editing ? "PUT" : "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify(editing ? { id: editing.id, ...payload } : payload),
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
await throwFetchError(res, "Operation failed");
|
|
190
|
+
}
|
|
191
|
+
const saved = await res.json();
|
|
192
|
+
if (editing) {
|
|
193
|
+
setActions((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
|
194
|
+
toast.success("Cập nhật hành động thành công");
|
|
195
|
+
} else {
|
|
196
|
+
setActions((prev) => [saved, ...prev]);
|
|
197
|
+
toast.success("Tạo hành động thành công");
|
|
198
|
+
}
|
|
199
|
+
setIsDialogOpen(false);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
toast.error(errorMessage(error, "Có lỗi xảy ra"));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const requestDelete = (action: Action) => {
|
|
206
|
+
setToDelete(action);
|
|
207
|
+
setConfirmOpen(true);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleConfirmDelete = async () => {
|
|
211
|
+
if (!toDelete) return;
|
|
212
|
+
setIsDeleting(true);
|
|
213
|
+
try {
|
|
214
|
+
const res = await fetch(`/api/actions?id=${toDelete.id}`, {
|
|
215
|
+
method: "DELETE",
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
await throwFetchError(res, "Delete failed");
|
|
219
|
+
}
|
|
220
|
+
setActions((prev) => prev.filter((a) => a.id !== toDelete.id));
|
|
221
|
+
toast.success("Xóa hành động thành công");
|
|
222
|
+
setConfirmOpen(false);
|
|
223
|
+
setToDelete(null);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
toast.error(errorMessage(error, "Không thể xóa hành động"));
|
|
226
|
+
} finally {
|
|
227
|
+
setIsDeleting(false);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const isSearching = searchTerm.trim().length > 0;
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<section className="w-full space-y-4 px-4 pb-6 pt-2 md:px-6 lg:px-8">
|
|
235
|
+
<PageHeader
|
|
236
|
+
title="Quản lý hành động"
|
|
237
|
+
description="Danh sách hành động và ngữ cảnh sử dụng (tài nguyên · vai trò · người dùng)"
|
|
238
|
+
icon={<Zap className="h-5 w-5" />}
|
|
239
|
+
actions={
|
|
240
|
+
canCreate ? (
|
|
241
|
+
<Button
|
|
242
|
+
variant="accent"
|
|
243
|
+
onClick={openNew}
|
|
244
|
+
prependIcon={<Plus className="h-4 w-4" />}
|
|
245
|
+
>
|
|
246
|
+
<span className="hidden sm:inline">Thêm hành động</span>
|
|
247
|
+
<span className="sm:hidden">Thêm</span>
|
|
248
|
+
</Button>
|
|
249
|
+
) : undefined
|
|
250
|
+
}
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
{/* Search + refresh */}
|
|
254
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
255
|
+
<div className="relative min-w-0 flex-1 sm:max-w-sm sm:flex-none">
|
|
256
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
257
|
+
<Input
|
|
258
|
+
placeholder="Tìm hành động (tên, mã, mô tả)…"
|
|
259
|
+
className="h-9 w-full bg-card pl-9 pr-9"
|
|
260
|
+
value={searchTerm}
|
|
261
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
262
|
+
/>
|
|
263
|
+
{searchTerm && (
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={() => setSearchTerm("")}
|
|
267
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
268
|
+
title="Xóa tìm kiếm"
|
|
269
|
+
>
|
|
270
|
+
<X className="h-4 w-4" />
|
|
271
|
+
</button>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
<Button
|
|
275
|
+
variant="outline"
|
|
276
|
+
size="icon"
|
|
277
|
+
onClick={handleRefresh}
|
|
278
|
+
disabled={isRefreshing}
|
|
279
|
+
title="Làm mới"
|
|
280
|
+
>
|
|
281
|
+
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
|
282
|
+
</Button>
|
|
283
|
+
{isSearching && (
|
|
284
|
+
<Badge variant="secondary" className="ml-auto font-normal sm:ml-1">
|
|
285
|
+
{filtered.length} hành động
|
|
286
|
+
</Badge>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{/* Body */}
|
|
291
|
+
{loading && !isRefreshing ? (
|
|
292
|
+
<div className="space-y-2 rounded-lg border border-border bg-card p-4">
|
|
293
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
294
|
+
<Skeleton key={i} className="h-14 w-full" />
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
) : filtered.length === 0 ? (
|
|
298
|
+
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card py-20 text-center">
|
|
299
|
+
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
|
300
|
+
<Search className="h-6 w-6 text-muted-foreground" />
|
|
301
|
+
</div>
|
|
302
|
+
<h3 className="text-base font-semibold text-foreground">
|
|
303
|
+
{isSearching ? "Không tìm thấy hành động" : "Chưa có hành động nào"}
|
|
304
|
+
</h3>
|
|
305
|
+
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
|
306
|
+
{isSearching
|
|
307
|
+
? "Thử từ khóa khác."
|
|
308
|
+
: "Tạo hành động đầu tiên để bắt đầu phân quyền."}
|
|
309
|
+
</p>
|
|
310
|
+
</div>
|
|
311
|
+
) : (
|
|
312
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
|
|
313
|
+
<ul className="divide-y divide-border">
|
|
314
|
+
{filtered.map((action) => (
|
|
315
|
+
<ActionRow
|
|
316
|
+
key={action.id}
|
|
317
|
+
action={action}
|
|
318
|
+
usage={usage[action.code] || EMPTY_USAGE}
|
|
319
|
+
expanded={expandedRows.has(action.id)}
|
|
320
|
+
canUpdate={canUpdate}
|
|
321
|
+
canDelete={canDelete}
|
|
322
|
+
onToggleExpand={() => toggleRow(action.id)}
|
|
323
|
+
onEdit={openEdit}
|
|
324
|
+
onDelete={requestDelete}
|
|
325
|
+
/>
|
|
326
|
+
))}
|
|
327
|
+
</ul>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{/* Create / Edit dialog */}
|
|
332
|
+
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
333
|
+
<DialogContent className="sm:max-w-[460px]">
|
|
334
|
+
<DialogHeader>
|
|
335
|
+
<DialogTitle>
|
|
336
|
+
{editing ? "Chỉnh sửa hành động" : "Thêm hành động mới"}
|
|
337
|
+
</DialogTitle>
|
|
338
|
+
</DialogHeader>
|
|
339
|
+
<div className="grid gap-4 py-2">
|
|
340
|
+
<div className="grid gap-2">
|
|
341
|
+
<Label htmlFor="action-code">Mã hành động</Label>
|
|
342
|
+
<Input
|
|
343
|
+
id="action-code"
|
|
344
|
+
placeholder="vd: view-report"
|
|
345
|
+
value={formData.code}
|
|
346
|
+
onChange={(e) =>
|
|
347
|
+
setFormData({ ...formData, code: e.target.value })
|
|
348
|
+
}
|
|
349
|
+
disabled={!!editing}
|
|
350
|
+
/>
|
|
351
|
+
<p className="text-[11px] text-muted-foreground">
|
|
352
|
+
Mã duy nhất dùng để kiểm tra quyền.
|
|
353
|
+
</p>
|
|
354
|
+
</div>
|
|
355
|
+
<div className="grid gap-2">
|
|
356
|
+
<Label htmlFor="action-name">Tên hiển thị</Label>
|
|
357
|
+
<Input
|
|
358
|
+
id="action-name"
|
|
359
|
+
placeholder="vd: Xem báo cáo"
|
|
360
|
+
value={formData.name}
|
|
361
|
+
onChange={(e) =>
|
|
362
|
+
setFormData({ ...formData, name: e.target.value })
|
|
363
|
+
}
|
|
364
|
+
/>
|
|
365
|
+
</div>
|
|
366
|
+
<div className="grid gap-2">
|
|
367
|
+
<Label htmlFor="action-desc">Mô tả</Label>
|
|
368
|
+
<Textarea
|
|
369
|
+
id="action-desc"
|
|
370
|
+
placeholder="Mô tả chi tiết về hành động này…"
|
|
371
|
+
value={formData.description}
|
|
372
|
+
onChange={(e) =>
|
|
373
|
+
setFormData({ ...formData, description: e.target.value })
|
|
374
|
+
}
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
<DialogFooter>
|
|
379
|
+
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
|
380
|
+
Hủy
|
|
381
|
+
</Button>
|
|
382
|
+
<Button variant="accent" onClick={handleSubmit}>
|
|
383
|
+
{editing ? "Cập nhật" : "Tạo mới"}
|
|
384
|
+
</Button>
|
|
385
|
+
</DialogFooter>
|
|
386
|
+
</DialogContent>
|
|
387
|
+
</Dialog>
|
|
388
|
+
|
|
389
|
+
<ConfirmDialog
|
|
390
|
+
open={confirmOpen}
|
|
391
|
+
onOpenChange={setConfirmOpen}
|
|
392
|
+
title="Xác nhận xóa"
|
|
393
|
+
description={`Bạn có chắc chắn muốn xóa hành động "${toDelete?.name}" không? Hành động này không thể hoàn tác.`}
|
|
394
|
+
onConfirm={handleConfirmDelete}
|
|
395
|
+
loading={isDeleting}
|
|
396
|
+
/>
|
|
397
|
+
</section>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- Một dòng hành động (editorial): tên + mã·mô tả; số liệu dạng chữ; bung ra
|
|
402
|
+
// hiển thị quan hệ dạng DANH SÁCH CHỮ. Nút sửa/xóa hiển thị thường trực để
|
|
403
|
+
// dùng được trên mobile (không có trạng thái hover). ---
|
|
404
|
+
function ActionRow({
|
|
405
|
+
action,
|
|
406
|
+
usage,
|
|
407
|
+
expanded,
|
|
408
|
+
canUpdate,
|
|
409
|
+
canDelete,
|
|
410
|
+
onToggleExpand,
|
|
411
|
+
onEdit,
|
|
412
|
+
onDelete,
|
|
413
|
+
}: {
|
|
414
|
+
action: Action;
|
|
415
|
+
usage: UsageEntry;
|
|
416
|
+
expanded: boolean;
|
|
417
|
+
canUpdate: boolean;
|
|
418
|
+
canDelete: boolean;
|
|
419
|
+
onToggleExpand: () => void;
|
|
420
|
+
onEdit: (a: Action) => void;
|
|
421
|
+
onDelete: (a: Action) => void;
|
|
422
|
+
}) {
|
|
423
|
+
return (
|
|
424
|
+
<li>
|
|
425
|
+
<div
|
|
426
|
+
role="button"
|
|
427
|
+
tabIndex={0}
|
|
428
|
+
aria-expanded={expanded}
|
|
429
|
+
onClick={onToggleExpand}
|
|
430
|
+
onKeyDown={(e) => {
|
|
431
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
onToggleExpand();
|
|
434
|
+
}
|
|
435
|
+
}}
|
|
436
|
+
className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
|
|
437
|
+
>
|
|
438
|
+
{/* Tên + mã · mô tả */}
|
|
439
|
+
<div className="min-w-0 flex-1">
|
|
440
|
+
<div className="truncate text-sm font-medium text-foreground">
|
|
441
|
+
{action.name}
|
|
442
|
+
</div>
|
|
443
|
+
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
444
|
+
<code className="font-mono text-foreground/70">{action.code}</code>
|
|
445
|
+
{action.description ? ` · ${action.description}` : ""}
|
|
446
|
+
</p>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{/* Số liệu — chữ thường, không pill, không icon */}
|
|
450
|
+
<Metrics usage={usage} className="hidden sm:block" />
|
|
451
|
+
|
|
452
|
+
{/* Tác vụ — luôn hiện (mobile không có hover) */}
|
|
453
|
+
{canUpdate && (
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
onClick={(e) => {
|
|
457
|
+
e.stopPropagation();
|
|
458
|
+
onEdit(action);
|
|
459
|
+
}}
|
|
460
|
+
className="shrink-0 rounded-md border border-border bg-card p-1.5 text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
|
461
|
+
title="Sửa hành động"
|
|
462
|
+
>
|
|
463
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
464
|
+
</button>
|
|
465
|
+
)}
|
|
466
|
+
{canDelete && (
|
|
467
|
+
<button
|
|
468
|
+
type="button"
|
|
469
|
+
onClick={(e) => {
|
|
470
|
+
e.stopPropagation();
|
|
471
|
+
onDelete(action);
|
|
472
|
+
}}
|
|
473
|
+
className="shrink-0 rounded-md border border-border bg-card p-1.5 text-muted-foreground shadow-sm transition-colors hover:border-destructive/30 hover:bg-destructive/10 hover:text-destructive"
|
|
474
|
+
title="Xóa hành động"
|
|
475
|
+
>
|
|
476
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
477
|
+
</button>
|
|
478
|
+
)}
|
|
479
|
+
<ChevronRight
|
|
480
|
+
className={cn(
|
|
481
|
+
"h-4 w-4 shrink-0 text-muted-foreground/50 transition-transform",
|
|
482
|
+
expanded && "rotate-90",
|
|
483
|
+
)}
|
|
484
|
+
/>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
{/* Bung: quan hệ dạng danh sách chữ (definition list) — nền trắng */}
|
|
488
|
+
{expanded && (
|
|
489
|
+
<div className="space-y-2.5 border-t border-border bg-card px-4 py-3.5">
|
|
490
|
+
<Metrics usage={usage} className="block sm:hidden" />
|
|
491
|
+
<DefRow
|
|
492
|
+
label="Tài nguyên"
|
|
493
|
+
empty="Chưa gán cho tài nguyên nào"
|
|
494
|
+
items={usage.resources.map((r) => ({ key: r.code, text: r.name }))}
|
|
495
|
+
/>
|
|
496
|
+
<DefRow
|
|
497
|
+
label="Vai trò"
|
|
498
|
+
empty="Chưa thuộc vai trò nào"
|
|
499
|
+
items={usage.roles.map((r) => ({ key: r.code, text: r.name }))}
|
|
500
|
+
/>
|
|
501
|
+
<DefRow
|
|
502
|
+
label="Người dùng"
|
|
503
|
+
empty="Chưa có người dùng nào được cấp"
|
|
504
|
+
items={usage.users.map((u) => ({
|
|
505
|
+
key: u.id,
|
|
506
|
+
text: u.name || u.email,
|
|
507
|
+
title: u.email,
|
|
508
|
+
}))}
|
|
509
|
+
/>
|
|
510
|
+
</div>
|
|
511
|
+
)}
|
|
512
|
+
</li>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Số liệu kiểu chữ: "30 tài nguyên · 8 vai trò · 30 người dùng" (số tabular,
|
|
517
|
+
// đậm khi > 0; nhãn + dấu chấm phân tách màu nhạt).
|
|
518
|
+
function Metrics({
|
|
519
|
+
usage,
|
|
520
|
+
className,
|
|
521
|
+
}: {
|
|
522
|
+
usage: UsageEntry;
|
|
523
|
+
className?: string;
|
|
524
|
+
}) {
|
|
525
|
+
const parts = [
|
|
526
|
+
{ n: usage.resources.length, label: "tài nguyên" },
|
|
527
|
+
{ n: usage.roles.length, label: "vai trò" },
|
|
528
|
+
{ n: usage.users.length, label: "người dùng" },
|
|
529
|
+
];
|
|
530
|
+
return (
|
|
531
|
+
<div
|
|
532
|
+
className={cn(
|
|
533
|
+
"shrink-0 whitespace-nowrap text-xs text-muted-foreground",
|
|
534
|
+
className,
|
|
535
|
+
)}
|
|
536
|
+
>
|
|
537
|
+
{parts.map((p, i) => (
|
|
538
|
+
<span key={p.label}>
|
|
539
|
+
{i > 0 && <span className="mx-1.5 text-border">·</span>}
|
|
540
|
+
<span
|
|
541
|
+
className={cn(
|
|
542
|
+
"tabular-nums",
|
|
543
|
+
p.n > 0 && "font-medium text-foreground",
|
|
544
|
+
)}
|
|
545
|
+
>
|
|
546
|
+
{p.n}
|
|
547
|
+
</span>{" "}
|
|
548
|
+
{p.label}
|
|
549
|
+
</span>
|
|
550
|
+
))}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
8
553
|
}
|
|
9
554
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}:
|
|
555
|
+
// 1 dòng quan hệ: nhãn (small caps) + số đếm | danh sách tên phân tách dấu phẩy.
|
|
556
|
+
function DefRow({
|
|
557
|
+
label,
|
|
558
|
+
empty,
|
|
559
|
+
items,
|
|
560
|
+
}: {
|
|
561
|
+
label: string;
|
|
562
|
+
empty: string;
|
|
563
|
+
items: { key: string; text: string; title?: string }[];
|
|
564
|
+
}) {
|
|
16
565
|
return (
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
566
|
+
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
|
|
567
|
+
<div className="flex w-28 shrink-0 items-baseline gap-1.5 pt-px text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
568
|
+
<span className="font-medium">{label}</span>
|
|
569
|
+
<span className="tabular-nums text-muted-foreground/60">
|
|
570
|
+
{items.length}
|
|
571
|
+
</span>
|
|
572
|
+
</div>
|
|
573
|
+
<div className="flex-1 text-xs leading-relaxed text-foreground/80">
|
|
574
|
+
{items.length === 0 ? (
|
|
575
|
+
<span className="italic text-muted-foreground">{empty}</span>
|
|
576
|
+
) : (
|
|
577
|
+
items.map((it, i) => (
|
|
578
|
+
<span key={it.key}>
|
|
579
|
+
{i > 0 ? ", " : ""}
|
|
580
|
+
<span title={it.title}>{it.text}</span>
|
|
581
|
+
</span>
|
|
582
|
+
))
|
|
583
|
+
)}
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
24
586
|
);
|
|
25
587
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Barrel CLIENT-SAFE cho các trang RBAC — import qua `@goerp/core/rbac/pages`
|
|
2
|
+
// để không kéo theo phần server (cache, service) của `@goerp/core/rbac`.
|
|
3
|
+
|
|
4
|
+
export { RoleListPage } from "./role-list-page";
|
|
5
|
+
export type { RoleListPageProps } from "./role-list-page";
|
|
6
|
+
export { RoleFormPage } from "./role-form-page";
|
|
7
|
+
export type {
|
|
8
|
+
RoleFormPageProps,
|
|
9
|
+
ActionLabelConfig,
|
|
10
|
+
} from "./role-form-page";
|
|
11
|
+
export { ResourceListPage } from "./resource-list-page";
|
|
12
|
+
export { ActionListPage } from "./action-list-page";
|
|
13
|
+
export type { ActionListPageProps } from "./action-list-page";
|