@goplusvn/core 0.1.47 → 0.1.49
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/package.json +1 -1
- package/src/crud/__tests__/filter-tree.test.ts +124 -0
- package/src/crud/lib/filter-tree.ts +150 -0
- package/src/crud/server-service.ts +14 -0
- package/src/crud/server.ts +1 -0
- package/src/rbac/pages/action-list-page.tsx +181 -256
- package/src/rbac/pages/index.ts +5 -0
- package/src/rbac/pages/lib/action-api.ts +38 -0
- package/src/rbac/pages/lib/group-taxonomy.ts +110 -0
- package/src/rbac/pages/lib/rbac-page-shell.tsx +148 -0
- package/src/rbac/pages/resource-list-page.tsx +350 -499
- package/src/schemas/branch.schema.ts +35 -0
- package/src/schemas/user.schema.ts +2 -0
- package/src/schemas/warehouse.schema.ts +2 -0
- package/src/types/index.ts +2 -0
- package/src/user/components/unified-profile-dialog.tsx +81 -81
- package/src/user/components/users-card-view.tsx +12 -12
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Chi tiết sử dụng = chip màu theo loại + "+n khác".
|
|
7
7
|
// API contract: GET/POST/PUT/DELETE /api/actions, GET /api/actions/usage
|
|
8
8
|
// (usage optional — trang vẫn chạy khi endpoint không tồn tại).
|
|
9
|
-
import { useEffect, useMemo, useState } from "react"
|
|
9
|
+
import { useEffect, useMemo, useState } from "react";
|
|
10
10
|
import {
|
|
11
11
|
Badge,
|
|
12
12
|
Button,
|
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
Label,
|
|
21
21
|
Skeleton,
|
|
22
22
|
Textarea,
|
|
23
|
-
} from "../../ui"
|
|
24
|
-
import { toast } from "sonner"
|
|
23
|
+
} from "../../ui";
|
|
24
|
+
import { toast } from "sonner";
|
|
25
25
|
import {
|
|
26
26
|
BadgeCheck,
|
|
27
27
|
Ban,
|
|
@@ -29,8 +29,6 @@ import {
|
|
|
29
29
|
ChevronRight,
|
|
30
30
|
Download,
|
|
31
31
|
Eye,
|
|
32
|
-
LayoutGrid,
|
|
33
|
-
List,
|
|
34
32
|
PenLine,
|
|
35
33
|
Pencil,
|
|
36
34
|
Plus,
|
|
@@ -40,47 +38,47 @@ import {
|
|
|
40
38
|
Settings2,
|
|
41
39
|
Trash2,
|
|
42
40
|
Upload,
|
|
43
|
-
X,
|
|
44
41
|
Zap,
|
|
45
|
-
} from "lucide-react"
|
|
42
|
+
} from "lucide-react";
|
|
46
43
|
|
|
47
|
-
import type { LucideIcon } from "lucide-react"
|
|
48
|
-
|
|
49
|
-
import { errorMessage, throwFetchError } from "../lib/fetch-error"
|
|
50
|
-
import { cn } from "../../utils"
|
|
44
|
+
import type { LucideIcon } from "lucide-react";
|
|
51
45
|
|
|
46
|
+
import { errorMessage } from "../lib/fetch-error";
|
|
47
|
+
import { deleteActionById, saveAction } from "./lib/action-api";
|
|
48
|
+
import { RbacPageBar, RbacStickyToolbar } from "./lib/rbac-page-shell";
|
|
49
|
+
import { cn } from "../../utils";
|
|
52
50
|
|
|
53
51
|
// --- Types ---
|
|
54
52
|
interface Action {
|
|
55
|
-
id: string
|
|
56
|
-
code: string
|
|
57
|
-
name: string
|
|
58
|
-
description?: string
|
|
59
|
-
isDefault?: boolean
|
|
53
|
+
id: string;
|
|
54
|
+
code: string;
|
|
55
|
+
name: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
isDefault?: boolean;
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
interface UsageEntry {
|
|
63
|
-
resources: { code: string; name: string }[]
|
|
64
|
-
roles: { code: string; name: string }[]
|
|
65
|
-
users: { id: string; name: string | null; email: string }[]
|
|
61
|
+
resources: { code: string; name: string }[];
|
|
62
|
+
roles: { code: string; name: string }[];
|
|
63
|
+
users: { id: string; name: string | null; email: string }[];
|
|
66
64
|
}
|
|
67
65
|
|
|
68
|
-
type UsageMap = Record<string, UsageEntry
|
|
66
|
+
type UsageMap = Record<string, UsageEntry>;
|
|
69
67
|
|
|
70
|
-
const EMPTY_USAGE: UsageEntry = { resources: [], roles: [], users: [] }
|
|
68
|
+
const EMPTY_USAGE: UsageEntry = { resources: [], roles: [], users: [] };
|
|
71
69
|
|
|
72
70
|
const normalize = (s: string) =>
|
|
73
71
|
(s || "")
|
|
74
72
|
.toLowerCase()
|
|
75
73
|
.normalize("NFD")
|
|
76
74
|
.replace(/[\u0300-\u036f]/g, "")
|
|
77
|
-
.replace(/đ/g, "d")
|
|
75
|
+
.replace(/đ/g, "d");
|
|
78
76
|
|
|
79
77
|
// --- Icon + màu theo LOẠI action (đồng bộ chip quyền trang sửa vai trò) ---
|
|
80
78
|
const ACTION_VISUAL_RULES: Array<{
|
|
81
|
-
re: RegExp
|
|
82
|
-
icon: LucideIcon
|
|
83
|
-
cls: string
|
|
79
|
+
re: RegExp;
|
|
80
|
+
icon: LucideIcon;
|
|
81
|
+
cls: string;
|
|
84
82
|
}> = [
|
|
85
83
|
{ re: /^(view|read|get|list)/, icon: Eye, cls: "text-blue-500" },
|
|
86
84
|
{ re: /^(create|add|new)/, icon: Plus, cls: "text-emerald-500" },
|
|
@@ -99,201 +97,184 @@ const ACTION_VISUAL_RULES: Array<{
|
|
|
99
97
|
{ re: /^sync/, icon: RefreshCw, cls: "text-cyan-600" },
|
|
100
98
|
{ re: /^(manage|config)/, icon: Settings2, cls: "text-orange-500" },
|
|
101
99
|
{ re: /^auto/, icon: Bot, cls: "text-purple-500" },
|
|
102
|
-
]
|
|
100
|
+
];
|
|
103
101
|
|
|
104
102
|
const getActionVisual = (code: string) =>
|
|
105
103
|
ACTION_VISUAL_RULES.find((r) => r.re.test(code)) ?? {
|
|
106
104
|
icon: Zap,
|
|
107
105
|
cls: "text-slate-400",
|
|
108
|
-
}
|
|
106
|
+
};
|
|
109
107
|
|
|
110
108
|
interface Permissions {
|
|
111
|
-
create?: boolean
|
|
112
|
-
update?: boolean
|
|
113
|
-
delete?: boolean
|
|
109
|
+
create?: boolean;
|
|
110
|
+
update?: boolean;
|
|
111
|
+
delete?: boolean;
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
export interface ActionListPageProps {
|
|
117
|
-
permissions?: Permissions
|
|
115
|
+
permissions?: Permissions;
|
|
118
116
|
/** Catalog hành động do Permission Registry trong code quản lý — ẩn mọi nút ghi. */
|
|
119
|
-
readOnly?: boolean
|
|
117
|
+
readOnly?: boolean;
|
|
120
118
|
}
|
|
121
119
|
|
|
122
120
|
export function ActionListPage({
|
|
123
121
|
permissions = {},
|
|
124
122
|
readOnly = false,
|
|
125
123
|
}: ActionListPageProps) {
|
|
126
|
-
const [loading, setLoading] = useState(true)
|
|
127
|
-
const [actions, setActions] = useState<Action[]>([])
|
|
128
|
-
const [usage, setUsage] = useState<UsageMap>({})
|
|
129
|
-
const [searchTerm, setSearchTerm] = useState("")
|
|
130
|
-
const [view, setView] = useState<"list" | "grid">("grid")
|
|
131
|
-
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
132
|
-
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
|
124
|
+
const [loading, setLoading] = useState(true);
|
|
125
|
+
const [actions, setActions] = useState<Action[]>([]);
|
|
126
|
+
const [usage, setUsage] = useState<UsageMap>({});
|
|
127
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
128
|
+
const [view, setView] = useState<"list" | "grid">("grid");
|
|
129
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
130
|
+
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
|
133
131
|
|
|
134
132
|
// Create/Edit dialog
|
|
135
|
-
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
|
136
|
-
const [editing, setEditing] = useState<Action | null>(null)
|
|
133
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
134
|
+
const [editing, setEditing] = useState<Action | null>(null);
|
|
137
135
|
const [formData, setFormData] = useState({
|
|
138
136
|
code: "",
|
|
139
137
|
name: "",
|
|
140
138
|
description: "",
|
|
141
|
-
})
|
|
139
|
+
});
|
|
142
140
|
|
|
143
141
|
// Delete confirm
|
|
144
|
-
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
145
|
-
const [toDelete, setToDelete] = useState<Action | null>(null)
|
|
146
|
-
const [isDeleting, setIsDeleting] = useState(false)
|
|
142
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
143
|
+
const [toDelete, setToDelete] = useState<Action | null>(null);
|
|
144
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
147
145
|
|
|
148
|
-
const canCreate = !readOnly && !!permissions.create
|
|
149
|
-
const canUpdate = !readOnly && !!permissions.update
|
|
150
|
-
const canDelete = !readOnly && !!permissions.delete
|
|
146
|
+
const canCreate = !readOnly && !!permissions.create;
|
|
147
|
+
const canUpdate = !readOnly && !!permissions.update;
|
|
148
|
+
const canDelete = !readOnly && !!permissions.delete;
|
|
151
149
|
|
|
152
150
|
const fetchData = async () => {
|
|
153
151
|
try {
|
|
154
|
-
setLoading(true)
|
|
152
|
+
setLoading(true);
|
|
155
153
|
const [resActions, resUsage] = await Promise.all([
|
|
156
154
|
fetch("/api/actions?pageSize=1000"),
|
|
157
155
|
fetch("/api/actions/usage"),
|
|
158
|
-
])
|
|
159
|
-
if (!resActions.ok) throw new Error("Không tải được danh sách hành động")
|
|
156
|
+
]);
|
|
157
|
+
if (!resActions.ok) throw new Error("Không tải được danh sách hành động");
|
|
160
158
|
|
|
161
|
-
const actionsData = await resActions.json()
|
|
162
|
-
setActions(actionsData.items || [])
|
|
159
|
+
const actionsData = await resActions.json();
|
|
160
|
+
setActions(actionsData.items || []);
|
|
163
161
|
|
|
164
162
|
if (resUsage.ok) {
|
|
165
|
-
const usageData = await resUsage.json()
|
|
166
|
-
setUsage(usageData.usage || {})
|
|
163
|
+
const usageData = await resUsage.json();
|
|
164
|
+
setUsage(usageData.usage || {});
|
|
167
165
|
}
|
|
168
166
|
} catch (error) {
|
|
169
|
-
toast.error(errorMessage(error, "Lỗi tải dữ liệu"))
|
|
167
|
+
toast.error(errorMessage(error, "Lỗi tải dữ liệu"));
|
|
170
168
|
} finally {
|
|
171
|
-
setLoading(false)
|
|
172
|
-
setIsRefreshing(false)
|
|
169
|
+
setLoading(false);
|
|
170
|
+
setIsRefreshing(false);
|
|
173
171
|
}
|
|
174
|
-
}
|
|
172
|
+
};
|
|
175
173
|
|
|
176
174
|
useEffect(() => {
|
|
177
|
-
fetchData()
|
|
178
|
-
}, [])
|
|
175
|
+
fetchData();
|
|
176
|
+
}, []);
|
|
179
177
|
|
|
180
178
|
const handleRefresh = () => {
|
|
181
|
-
setIsRefreshing(true)
|
|
182
|
-
fetchData()
|
|
183
|
-
}
|
|
179
|
+
setIsRefreshing(true);
|
|
180
|
+
fetchData();
|
|
181
|
+
};
|
|
184
182
|
|
|
185
183
|
const toggleRow = (id: string) => {
|
|
186
184
|
setExpandedRows((prev) => {
|
|
187
|
-
const next = new Set(prev)
|
|
188
|
-
if (next.has(id)) next.delete(id)
|
|
189
|
-
else next.add(id)
|
|
190
|
-
return next
|
|
191
|
-
})
|
|
192
|
-
}
|
|
185
|
+
const next = new Set(prev);
|
|
186
|
+
if (next.has(id)) next.delete(id);
|
|
187
|
+
else next.add(id);
|
|
188
|
+
return next;
|
|
189
|
+
});
|
|
190
|
+
};
|
|
193
191
|
|
|
194
192
|
const filtered = useMemo(() => {
|
|
195
|
-
const q = normalize(searchTerm.trim())
|
|
193
|
+
const q = normalize(searchTerm.trim());
|
|
196
194
|
const list = q
|
|
197
195
|
? actions.filter(
|
|
198
196
|
(a) =>
|
|
199
197
|
normalize(a.name).includes(q) ||
|
|
200
198
|
normalize(a.code).includes(q) ||
|
|
201
|
-
normalize(a.description || "").includes(q)
|
|
199
|
+
normalize(a.description || "").includes(q),
|
|
202
200
|
)
|
|
203
|
-
: actions
|
|
204
|
-
return [...list].sort((a, b) => a.name.localeCompare(b.name, "vi"))
|
|
205
|
-
}, [actions, searchTerm])
|
|
201
|
+
: actions;
|
|
202
|
+
return [...list].sort((a, b) => a.name.localeCompare(b.name, "vi"));
|
|
203
|
+
}, [actions, searchTerm]);
|
|
206
204
|
|
|
207
205
|
// --- CRUD ---
|
|
208
206
|
const openNew = () => {
|
|
209
|
-
setEditing(null)
|
|
210
|
-
setFormData({ code: "", name: "", description: "" })
|
|
211
|
-
setIsDialogOpen(true)
|
|
212
|
-
}
|
|
207
|
+
setEditing(null);
|
|
208
|
+
setFormData({ code: "", name: "", description: "" });
|
|
209
|
+
setIsDialogOpen(true);
|
|
210
|
+
};
|
|
213
211
|
|
|
214
212
|
const openEdit = (action: Action) => {
|
|
215
|
-
setEditing(action)
|
|
213
|
+
setEditing(action);
|
|
216
214
|
setFormData({
|
|
217
215
|
code: action.code,
|
|
218
216
|
name: action.name,
|
|
219
217
|
description: action.description || "",
|
|
220
|
-
})
|
|
221
|
-
setIsDialogOpen(true)
|
|
222
|
-
}
|
|
218
|
+
});
|
|
219
|
+
setIsDialogOpen(true);
|
|
220
|
+
};
|
|
223
221
|
|
|
224
222
|
const handleSubmit = async () => {
|
|
225
223
|
if (!formData.code || !formData.name) {
|
|
226
|
-
toast.error("Vui lòng nhập Mã và Tên hành động")
|
|
227
|
-
return
|
|
224
|
+
toast.error("Vui lòng nhập Mã và Tên hành động");
|
|
225
|
+
return;
|
|
228
226
|
}
|
|
229
227
|
try {
|
|
230
|
-
const
|
|
231
|
-
const res = await fetch("/api/actions", {
|
|
232
|
-
method: editing ? "PUT" : "POST",
|
|
233
|
-
headers: { "Content-Type": "application/json" },
|
|
234
|
-
body: JSON.stringify(
|
|
235
|
-
editing ? { id: editing.id, ...payload } : payload
|
|
236
|
-
),
|
|
237
|
-
})
|
|
238
|
-
if (!res.ok) {
|
|
239
|
-
await throwFetchError(res, "Operation failed")
|
|
240
|
-
}
|
|
241
|
-
const saved = await res.json()
|
|
228
|
+
const saved = await saveAction<Action>(formData, editing?.id);
|
|
242
229
|
if (editing) {
|
|
243
|
-
setActions((prev) => prev.map((a) => (a.id === saved.id ? saved : a)))
|
|
244
|
-
toast.success("Cập nhật hành động thành công")
|
|
230
|
+
setActions((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
|
231
|
+
toast.success("Cập nhật hành động thành công");
|
|
245
232
|
} else {
|
|
246
|
-
setActions((prev) => [saved, ...prev])
|
|
247
|
-
toast.success("Tạo hành động thành công")
|
|
233
|
+
setActions((prev) => [saved, ...prev]);
|
|
234
|
+
toast.success("Tạo hành động thành công");
|
|
248
235
|
}
|
|
249
|
-
setIsDialogOpen(false)
|
|
236
|
+
setIsDialogOpen(false);
|
|
250
237
|
} catch (error) {
|
|
251
|
-
toast.error(errorMessage(error, "Có lỗi xảy ra"))
|
|
238
|
+
toast.error(errorMessage(error, "Có lỗi xảy ra"));
|
|
252
239
|
}
|
|
253
|
-
}
|
|
240
|
+
};
|
|
254
241
|
|
|
255
242
|
const requestDelete = (action: Action) => {
|
|
256
|
-
setToDelete(action)
|
|
257
|
-
setConfirmOpen(true)
|
|
258
|
-
}
|
|
243
|
+
setToDelete(action);
|
|
244
|
+
setConfirmOpen(true);
|
|
245
|
+
};
|
|
259
246
|
|
|
260
247
|
const handleConfirmDelete = async () => {
|
|
261
|
-
if (!toDelete) return
|
|
262
|
-
setIsDeleting(true)
|
|
248
|
+
if (!toDelete) return;
|
|
249
|
+
setIsDeleting(true);
|
|
263
250
|
try {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
setActions((prev) => prev.filter((a) => a.id !== toDelete.id))
|
|
271
|
-
toast.success("Xóa hành động thành công")
|
|
272
|
-
setConfirmOpen(false)
|
|
273
|
-
setToDelete(null)
|
|
251
|
+
await deleteActionById(toDelete.id);
|
|
252
|
+
setActions((prev) => prev.filter((a) => a.id !== toDelete.id));
|
|
253
|
+
toast.success("Xóa hành động thành công");
|
|
254
|
+
setConfirmOpen(false);
|
|
255
|
+
setToDelete(null);
|
|
274
256
|
} catch (error) {
|
|
275
|
-
toast.error(errorMessage(error, "Không thể xóa hành động"))
|
|
257
|
+
toast.error(errorMessage(error, "Không thể xóa hành động"));
|
|
276
258
|
} finally {
|
|
277
|
-
setIsDeleting(false)
|
|
259
|
+
setIsDeleting(false);
|
|
278
260
|
}
|
|
279
|
-
}
|
|
261
|
+
};
|
|
280
262
|
|
|
281
|
-
const isSearching = searchTerm.trim().length > 0
|
|
263
|
+
const isSearching = searchTerm.trim().length > 0;
|
|
282
264
|
|
|
283
265
|
return (
|
|
284
266
|
<div className="flex flex-col">
|
|
285
|
-
{/* ===== Bar navy 1 dòng —
|
|
286
|
-
<
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
</span>
|
|
267
|
+
{/* ===== Bar navy 1 dòng — skeleton dùng chung RbacPageBar ===== */}
|
|
268
|
+
<RbacPageBar
|
|
269
|
+
icon={<Zap className="h-4 w-4 text-primary-foreground/80" />}
|
|
270
|
+
title="Quản lý hành động"
|
|
271
|
+
subtitle={
|
|
272
|
+
<>
|
|
273
|
+
{actions.length} hành động · dùng cho tài nguyên · vai trò · người
|
|
274
|
+
dùng
|
|
275
|
+
</>
|
|
276
|
+
}
|
|
277
|
+
>
|
|
297
278
|
{canCreate && (
|
|
298
279
|
<Button
|
|
299
280
|
onClick={openNew}
|
|
@@ -304,31 +285,21 @@ export function ActionListPage({
|
|
|
304
285
|
<span className="ml-1.5 sm:hidden">Thêm</span>
|
|
305
286
|
</Button>
|
|
306
287
|
)}
|
|
307
|
-
</
|
|
288
|
+
</RbacPageBar>
|
|
308
289
|
|
|
309
290
|
{/* ===== Khối trắng: toolbar sticky + danh sách ===== */}
|
|
310
291
|
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
311
292
|
{/* Toolbar sticky */}
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
<button
|
|
323
|
-
type="button"
|
|
324
|
-
onClick={() => setSearchTerm("")}
|
|
325
|
-
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
326
|
-
title="Xóa tìm kiếm"
|
|
327
|
-
>
|
|
328
|
-
<X className="h-3.5 w-3.5" />
|
|
329
|
-
</button>
|
|
330
|
-
)}
|
|
331
|
-
</div>
|
|
293
|
+
<RbacStickyToolbar
|
|
294
|
+
searchTerm={searchTerm}
|
|
295
|
+
onSearchChange={setSearchTerm}
|
|
296
|
+
searchPlaceholder="Tìm hành động (tên, mã, mô tả)…"
|
|
297
|
+
count={actions.length}
|
|
298
|
+
countLabel="hành động"
|
|
299
|
+
matchedCount={filtered.length}
|
|
300
|
+
view={view}
|
|
301
|
+
onViewChange={setView}
|
|
302
|
+
>
|
|
332
303
|
<Button
|
|
333
304
|
variant="outline"
|
|
334
305
|
size="icon"
|
|
@@ -341,53 +312,7 @@ export function ActionListPage({
|
|
|
341
312
|
className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")}
|
|
342
313
|
/>
|
|
343
314
|
</Button>
|
|
344
|
-
|
|
345
|
-
{isSearching ? (
|
|
346
|
-
<>
|
|
347
|
-
<span className="font-semibold tabular-nums text-foreground">
|
|
348
|
-
{filtered.length}
|
|
349
|
-
</span>{" "}
|
|
350
|
-
khớp
|
|
351
|
-
</>
|
|
352
|
-
) : (
|
|
353
|
-
<>
|
|
354
|
-
<span className="font-semibold tabular-nums text-foreground">
|
|
355
|
-
{actions.length}
|
|
356
|
-
</span>{" "}
|
|
357
|
-
hành động
|
|
358
|
-
</>
|
|
359
|
-
)}
|
|
360
|
-
</p>
|
|
361
|
-
{/* Chuyển dạng hiển thị: dòng / thẻ */}
|
|
362
|
-
<div className="flex shrink-0 overflow-hidden rounded-md border border-border">
|
|
363
|
-
<button
|
|
364
|
-
type="button"
|
|
365
|
-
onClick={() => setView("list")}
|
|
366
|
-
title="Dạng dòng"
|
|
367
|
-
className={cn(
|
|
368
|
-
"px-2 py-1.5 transition-colors",
|
|
369
|
-
view === "list"
|
|
370
|
-
? "bg-muted text-foreground"
|
|
371
|
-
: "bg-card text-muted-foreground hover:text-foreground"
|
|
372
|
-
)}
|
|
373
|
-
>
|
|
374
|
-
<List className="h-3.5 w-3.5" />
|
|
375
|
-
</button>
|
|
376
|
-
<button
|
|
377
|
-
type="button"
|
|
378
|
-
onClick={() => setView("grid")}
|
|
379
|
-
title="Dạng thẻ"
|
|
380
|
-
className={cn(
|
|
381
|
-
"border-l border-border px-2 py-1.5 transition-colors",
|
|
382
|
-
view === "grid"
|
|
383
|
-
? "bg-muted text-foreground"
|
|
384
|
-
: "bg-card text-muted-foreground hover:text-foreground"
|
|
385
|
-
)}
|
|
386
|
-
>
|
|
387
|
-
<LayoutGrid className="h-3.5 w-3.5" />
|
|
388
|
-
</button>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
315
|
+
</RbacStickyToolbar>
|
|
391
316
|
|
|
392
317
|
{/* Body */}
|
|
393
318
|
{loading && !isRefreshing ? (
|
|
@@ -516,7 +441,7 @@ export function ActionListPage({
|
|
|
516
441
|
loading={isDeleting}
|
|
517
442
|
/>
|
|
518
443
|
</div>
|
|
519
|
-
)
|
|
444
|
+
);
|
|
520
445
|
}
|
|
521
446
|
|
|
522
447
|
// --- Một dòng hành động: tên + mã·mô tả; số liệu dạng chữ; bung ra hiển thị
|
|
@@ -532,15 +457,15 @@ function ActionRow({
|
|
|
532
457
|
onEdit,
|
|
533
458
|
onDelete,
|
|
534
459
|
}: {
|
|
535
|
-
index: number
|
|
536
|
-
action: Action
|
|
537
|
-
usage: UsageEntry
|
|
538
|
-
expanded: boolean
|
|
539
|
-
canUpdate: boolean
|
|
540
|
-
canDelete: boolean
|
|
541
|
-
onToggleExpand: () => void
|
|
542
|
-
onEdit: (a: Action) => void
|
|
543
|
-
onDelete: (a: Action) => void
|
|
460
|
+
index: number;
|
|
461
|
+
action: Action;
|
|
462
|
+
usage: UsageEntry;
|
|
463
|
+
expanded: boolean;
|
|
464
|
+
canUpdate: boolean;
|
|
465
|
+
canDelete: boolean;
|
|
466
|
+
onToggleExpand: () => void;
|
|
467
|
+
onEdit: (a: Action) => void;
|
|
468
|
+
onDelete: (a: Action) => void;
|
|
544
469
|
}) {
|
|
545
470
|
return (
|
|
546
471
|
<li>
|
|
@@ -551,8 +476,8 @@ function ActionRow({
|
|
|
551
476
|
onClick={onToggleExpand}
|
|
552
477
|
onKeyDown={(e) => {
|
|
553
478
|
if (e.key === "Enter" || e.key === " ") {
|
|
554
|
-
e.preventDefault()
|
|
555
|
-
onToggleExpand()
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
onToggleExpand();
|
|
556
481
|
}
|
|
557
482
|
}}
|
|
558
483
|
className="flex cursor-pointer items-center gap-3 bg-card px-4 py-3 transition-colors hover:bg-muted/40"
|
|
@@ -580,8 +505,8 @@ function ActionRow({
|
|
|
580
505
|
<button
|
|
581
506
|
type="button"
|
|
582
507
|
onClick={(e) => {
|
|
583
|
-
e.stopPropagation()
|
|
584
|
-
onEdit(action)
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
onEdit(action);
|
|
585
510
|
}}
|
|
586
511
|
className="shrink-0 rounded-md border border-border bg-card p-1.5 text-amber-500 shadow-sm transition-colors hover:border-amber-300 hover:bg-amber-50 hover:text-amber-600 dark:hover:border-amber-500/40 dark:hover:bg-amber-500/10"
|
|
587
512
|
title="Sửa hành động"
|
|
@@ -593,8 +518,8 @@ function ActionRow({
|
|
|
593
518
|
<button
|
|
594
519
|
type="button"
|
|
595
520
|
onClick={(e) => {
|
|
596
|
-
e.stopPropagation()
|
|
597
|
-
onDelete(action)
|
|
521
|
+
e.stopPropagation();
|
|
522
|
+
onDelete(action);
|
|
598
523
|
}}
|
|
599
524
|
className="shrink-0 rounded-md border border-border bg-card p-1.5 text-destructive shadow-sm transition-colors hover:border-destructive/40 hover:bg-destructive/10"
|
|
600
525
|
title="Xóa hành động"
|
|
@@ -605,7 +530,7 @@ function ActionRow({
|
|
|
605
530
|
<ChevronRight
|
|
606
531
|
className={cn(
|
|
607
532
|
"h-4 w-4 shrink-0 text-muted-foreground/50 transition-transform",
|
|
608
|
-
expanded && "rotate-90"
|
|
533
|
+
expanded && "rotate-90",
|
|
609
534
|
)}
|
|
610
535
|
/>
|
|
611
536
|
</div>
|
|
@@ -639,7 +564,7 @@ function ActionRow({
|
|
|
639
564
|
</div>
|
|
640
565
|
)}
|
|
641
566
|
</li>
|
|
642
|
-
)
|
|
567
|
+
);
|
|
643
568
|
}
|
|
644
569
|
|
|
645
570
|
// --- Thẻ hành động (grid, MẶC ĐỊNH) — phong cách thẻ VAI TRÒ: icon loại
|
|
@@ -656,18 +581,18 @@ function ActionCard({
|
|
|
656
581
|
onEdit,
|
|
657
582
|
onDelete,
|
|
658
583
|
}: {
|
|
659
|
-
index: number
|
|
660
|
-
action: Action
|
|
661
|
-
usage: UsageEntry
|
|
662
|
-
expanded: boolean
|
|
663
|
-
canUpdate: boolean
|
|
664
|
-
canDelete: boolean
|
|
665
|
-
onToggleExpand: () => void
|
|
666
|
-
onEdit: (a: Action) => void
|
|
667
|
-
onDelete: (a: Action) => void
|
|
584
|
+
index: number;
|
|
585
|
+
action: Action;
|
|
586
|
+
usage: UsageEntry;
|
|
587
|
+
expanded: boolean;
|
|
588
|
+
canUpdate: boolean;
|
|
589
|
+
canDelete: boolean;
|
|
590
|
+
onToggleExpand: () => void;
|
|
591
|
+
onEdit: (a: Action) => void;
|
|
592
|
+
onDelete: (a: Action) => void;
|
|
668
593
|
}) {
|
|
669
|
-
const visual = getActionVisual(action.code)
|
|
670
|
-
const ActionIcon = visual.icon
|
|
594
|
+
const visual = getActionVisual(action.code);
|
|
595
|
+
const ActionIcon = visual.icon;
|
|
671
596
|
|
|
672
597
|
return (
|
|
673
598
|
<div
|
|
@@ -677,13 +602,13 @@ function ActionCard({
|
|
|
677
602
|
onClick={onToggleExpand}
|
|
678
603
|
onKeyDown={(e) => {
|
|
679
604
|
if (e.key === "Enter" || e.key === " ") {
|
|
680
|
-
e.preventDefault()
|
|
681
|
-
onToggleExpand()
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
onToggleExpand();
|
|
682
607
|
}
|
|
683
608
|
}}
|
|
684
609
|
className={cn(
|
|
685
610
|
"cursor-pointer overflow-hidden rounded-[12px] border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
|
|
686
|
-
expanded && "col-span-full border-primary/40"
|
|
611
|
+
expanded && "col-span-full border-primary/40",
|
|
687
612
|
)}
|
|
688
613
|
>
|
|
689
614
|
{/* Header: icon loại + tên/mã | badge */}
|
|
@@ -758,8 +683,8 @@ function ActionCard({
|
|
|
758
683
|
size="sm"
|
|
759
684
|
className="h-8 flex-1 rounded-[8px] bg-card text-[12px] font-medium shadow-sm transition-colors hover:border-amber-300 hover:bg-amber-50 dark:hover:border-amber-500/40 dark:hover:bg-amber-500/10"
|
|
760
685
|
onClick={(e) => {
|
|
761
|
-
e.stopPropagation()
|
|
762
|
-
onEdit(action)
|
|
686
|
+
e.stopPropagation();
|
|
687
|
+
onEdit(action);
|
|
763
688
|
}}
|
|
764
689
|
>
|
|
765
690
|
<Pencil className="mr-1.5 h-3.5 w-3.5 text-amber-500" />
|
|
@@ -772,8 +697,8 @@ function ActionCard({
|
|
|
772
697
|
size="sm"
|
|
773
698
|
className="h-8 flex-1 rounded-[8px] bg-card text-[12px] font-medium text-destructive shadow-sm transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
774
699
|
onClick={(e) => {
|
|
775
|
-
e.stopPropagation()
|
|
776
|
-
onDelete(action)
|
|
700
|
+
e.stopPropagation();
|
|
701
|
+
onDelete(action);
|
|
777
702
|
}}
|
|
778
703
|
>
|
|
779
704
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
@@ -811,7 +736,7 @@ function ActionCard({
|
|
|
811
736
|
</div>
|
|
812
737
|
)}
|
|
813
738
|
</div>
|
|
814
|
-
)
|
|
739
|
+
);
|
|
815
740
|
}
|
|
816
741
|
|
|
817
742
|
// Số liệu kiểu chữ: "30 tài nguyên · 8 vai trò · 30 người dùng"
|
|
@@ -819,19 +744,19 @@ function Metrics({
|
|
|
819
744
|
usage,
|
|
820
745
|
className,
|
|
821
746
|
}: {
|
|
822
|
-
usage: UsageEntry
|
|
823
|
-
className?: string
|
|
747
|
+
usage: UsageEntry;
|
|
748
|
+
className?: string;
|
|
824
749
|
}) {
|
|
825
750
|
const parts = [
|
|
826
751
|
{ n: usage.resources.length, label: "tài nguyên" },
|
|
827
752
|
{ n: usage.roles.length, label: "vai trò" },
|
|
828
753
|
{ n: usage.users.length, label: "người dùng" },
|
|
829
|
-
]
|
|
754
|
+
];
|
|
830
755
|
return (
|
|
831
756
|
<div
|
|
832
757
|
className={cn(
|
|
833
758
|
"shrink-0 whitespace-nowrap text-xs text-muted-foreground",
|
|
834
|
-
className
|
|
759
|
+
className,
|
|
835
760
|
)}
|
|
836
761
|
>
|
|
837
762
|
{parts.map((p, i) => (
|
|
@@ -840,7 +765,7 @@ function Metrics({
|
|
|
840
765
|
<span
|
|
841
766
|
className={cn(
|
|
842
767
|
"tabular-nums",
|
|
843
|
-
p.n > 0 && "font-medium text-foreground"
|
|
768
|
+
p.n > 0 && "font-medium text-foreground",
|
|
844
769
|
)}
|
|
845
770
|
>
|
|
846
771
|
{p.n}
|
|
@@ -849,12 +774,12 @@ function Metrics({
|
|
|
849
774
|
</span>
|
|
850
775
|
))}
|
|
851
776
|
</div>
|
|
852
|
-
)
|
|
777
|
+
);
|
|
853
778
|
}
|
|
854
779
|
|
|
855
780
|
// 1 dòng quan hệ: nhãn (small caps) + số đếm | CHIP wrap ngang (thay cho
|
|
856
781
|
// danh sách phẩy khó quét). Quá MAX_DEF_CHIPS → nút "+n khác" / "Thu gọn".
|
|
857
|
-
const MAX_DEF_CHIPS = 15
|
|
782
|
+
const MAX_DEF_CHIPS = 15;
|
|
858
783
|
|
|
859
784
|
function DefRow({
|
|
860
785
|
label,
|
|
@@ -862,14 +787,14 @@ function DefRow({
|
|
|
862
787
|
items,
|
|
863
788
|
chipClass,
|
|
864
789
|
}: {
|
|
865
|
-
label: string
|
|
866
|
-
empty: string
|
|
867
|
-
items: { key: string; text: string; title?: string }[]
|
|
868
|
-
chipClass?: string
|
|
790
|
+
label: string;
|
|
791
|
+
empty: string;
|
|
792
|
+
items: { key: string; text: string; title?: string }[];
|
|
793
|
+
chipClass?: string;
|
|
869
794
|
}) {
|
|
870
|
-
const [showAll, setShowAll] = useState(false)
|
|
871
|
-
const shown = showAll ? items : items.slice(0, MAX_DEF_CHIPS)
|
|
872
|
-
const overflow = items.length - shown.length
|
|
795
|
+
const [showAll, setShowAll] = useState(false);
|
|
796
|
+
const shown = showAll ? items : items.slice(0, MAX_DEF_CHIPS);
|
|
797
|
+
const overflow = items.length - shown.length;
|
|
873
798
|
|
|
874
799
|
return (
|
|
875
800
|
<div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
|
|
@@ -890,7 +815,7 @@ function DefRow({
|
|
|
890
815
|
title={it.title}
|
|
891
816
|
className={cn(
|
|
892
817
|
"inline-flex max-w-56 items-center truncate rounded-md border px-1.5 py-0.5 text-[11px] leading-4",
|
|
893
|
-
chipClass || "border-border bg-card text-foreground/80"
|
|
818
|
+
chipClass || "border-border bg-card text-foreground/80",
|
|
894
819
|
)}
|
|
895
820
|
>
|
|
896
821
|
{it.text}
|
|
@@ -918,5 +843,5 @@ function DefRow({
|
|
|
918
843
|
)}
|
|
919
844
|
</div>
|
|
920
845
|
</div>
|
|
921
|
-
)
|
|
846
|
+
);
|
|
922
847
|
}
|