[CLAUDE] App+Api+FE-Admin: RolesPage CRUD (/system/roles)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m58s

User feedback: /system/roles trỏ tới placeholder "chưa được build" — build
trang quản lý 12 role mặc định + custom role admin tự thêm.

## BE — PermissionFeatures.cs

3 command mới:
- CreateRoleCommand — Name regex `^[A-Za-z][A-Za-z0-9_]*$` (chỉ chữ/số/
  underscore, bắt đầu chữ), throw ConflictException nếu code đã tồn tại
- UpdateRoleCommand — CHỈ update ShortName + Description. KHÔNG đổi
  Name (Identity FK trong UserRoles + WorkflowStepApprover.AssignmentValue
  + [Authorize(Roles="...")] attr — đổi = data corruption widespread)
- DeleteRoleCommand — block 2 trường hợp:
  * Role thuộc AppRoles.All hardcoded (workflow guard reference)
  * Còn user assigned (UserManager.GetUsersInRoleAsync count > 0)

ValidationException reference fully-qualified để tránh ambiguous với
FluentValidation.ValidationException.

## BE — RolesController

3 endpoint mới (POST/PUT/DELETE) — Authorize Admin role.

## FE — RolesPage

Table list 12 + custom roles với 5 column (Mã code / Mã viết tắt / Tên
đầy đủ / Loại badge / Ngày tạo) + actions Edit/Delete:
- Edit dialog: chỉ ShortName + Description editable, Name disabled với
  hint "Không đổi được sau khi tạo"
- Delete: block với toast nếu role mặc định (HARDCODED_ROLES set check
  client-side trước khi gọi BE — UX faster, BE vẫn double-check)
- Create dialog: 3 field Name (regex pattern HTML5) + ShortName + Description
- Banner amber warning về Mã code FK constraint
- Loại badge: Mặc định (slate) vs Tùy chỉnh (brand)

## FE — App.tsx

+ import RolesPage + route /system/roles → RolesPage.

## Build

- BE: dotnet build pass (0 error)
- fe-admin: tsc + vite pass (13.88s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 14:57:36 +07:00
parent ff5e35f279
commit 072ad6d014
4 changed files with 398 additions and 0 deletions

View File

@ -10,6 +10,7 @@ import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { CatalogsPage } from '@/pages/master/CatalogsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { RolesPage } from '@/pages/system/RolesPage'
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
@ -38,6 +39,7 @@ function App() {
<Route path="/master/catalogs" element={<Navigate to="/master/catalogs/units" replace />} />
<Route path="/master/catalogs/:kind" element={<CatalogsPage />} />
<Route path="/system/users" element={<UsersPage />} />
<Route path="/system/roles" element={<RolesPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/system/workflows" element={<WorkflowsPage />} />
<Route path="/system/workflows/:typeCode" element={<WorkflowsPage />} />

View File

@ -0,0 +1,268 @@
// Quản lý 12 role mặc định + custom role admin tự thêm. Edit chỉ ShortName +
// Description (Mã = Identity Name là FK + [Authorize] attr — không cho đổi).
// Delete chỉ cho custom role chưa có user assigned (BE block 12 hardcoded).
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Shield, Trash2, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { AVAILABLE_ROLES } from '@/types/users'
import type { Role } from '@/types/menu'
const HARDCODED_ROLES = new Set<string>(AVAILABLE_ROLES)
const fmtDate = (s: string) => new Date(s).toLocaleDateString('vi-VN')
export function RolesPage() {
const qc = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState({ name: '', shortName: '', description: '' })
const [editTarget, setEditTarget] = useState<Role | null>(null)
const [editForm, setEditForm] = useState({ shortName: '', description: '' })
const list = useQuery({
queryKey: ['roles'],
queryFn: async () => (await api.get<Role[]>('/roles')).data,
})
const createMut = useMutation({
mutationFn: async () => {
await api.post('/roles', {
name: createForm.name.trim(),
shortName: createForm.shortName.trim() || null,
description: createForm.description.trim() || null,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['roles'] })
toast.success('Đã tạo role')
setCreateOpen(false)
setCreateForm({ name: '', shortName: '', description: '' })
},
onError: err => toast.error(getErrorMessage(err)),
})
const editMut = useMutation({
mutationFn: async () => {
if (!editTarget) return
await api.put(`/roles/${editTarget.id}`, {
id: editTarget.id,
shortName: editForm.shortName.trim() || null,
description: editForm.description.trim() || null,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['roles'] })
toast.success('Đã lưu')
setEditTarget(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
const deleteMut = useMutation({
mutationFn: async (id: string) => { await api.delete(`/roles/${id}`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['roles'] })
toast.success('Đã xóa role')
},
onError: err => toast.error(getErrorMessage(err)),
})
function openEdit(r: Role) {
setEditTarget(r)
setEditForm({ shortName: r.shortName ?? '', description: r.description ?? '' })
}
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Vai trò (Roles)
</span>
}
description="12 role mặc định seed lúc startup + custom role admin thêm. Chỉ sửa Mã viết tắt + Tên đầy đủ; không đổi Mã code (FK)."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Thêm role tùy chỉnh
</Button>
}
/>
<div className="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
code (Name) khóa kỹ thuật KHÔNG đi sau khi tạo (tham chiếu UserRoles + WorkflowStepApprover + [Authorize]).
Chỉ viết tắt + Tên đy đ tiếng Việt đưc sửa.
</div>
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr>
<th className="px-3 py-2 text-left"> code</th>
<th className="px-3 py-2 text-left"> viết tắt</th>
<th className="px-3 py-2 text-left">Tên đy đ</th>
<th className="px-3 py-2 text-left">Loại</th>
<th className="w-28 px-3 py-2 text-left">Ngày tạo</th>
<th className="w-24 px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{list.isLoading && <tr><td colSpan={6} className="p-6 text-center text-slate-400">Đang tải</td></tr>}
{!list.isLoading && (list.data?.length ?? 0) === 0 && (
<tr><td colSpan={6} className="p-6 text-center text-slate-400">Không role.</td></tr>
)}
{list.data?.map(r => {
const isSystem = HARDCODED_ROLES.has(r.name)
return (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-3 py-2 font-mono text-xs">{r.name}</td>
<td className="px-3 py-2 font-semibold text-brand-700">{r.shortName ?? '—'}</td>
<td className="px-3 py-2">{r.description ?? <span className="text-slate-400"></span>}</td>
<td className="px-3 py-2">
{isSystem ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">Mặc đnh</span>
) : (
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] text-brand-700">Tùy chỉnh</span>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-500">{fmtDate(r.createdAt)}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => openEdit(r)}
title="Sửa Mã viết tắt + Tên đầy đủ"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (isSystem) {
toast.error('Không xóa được role mặc định')
return
}
if (confirm(`Xóa role "${r.name}"?`)) deleteMut.mutate(r.id)
}}
title={isSystem ? 'Role mặc định — không xóa được' : 'Xóa role'}
disabled={isSystem || deleteMut.isPending}
className={`rounded p-1 transition ${
isSystem
? 'cursor-not-allowed text-slate-300'
: 'text-slate-500 hover:bg-slate-100 hover:text-red-600'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Create custom role */}
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="Thêm role tùy chỉnh"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Hủy</Button>
<Button onClick={(e: FormEvent) => { e.preventDefault(); createMut.mutate() }} disabled={createMut.isPending}>
{createMut.isPending ? 'Đang tạo…' : 'Tạo'}
</Button>
</>
}
>
<form className="space-y-3" onSubmit={e => { e.preventDefault(); createMut.mutate() }}>
<div className="space-y-1.5">
<Label> code (English, không đi sau tạo) *</Label>
<Input
value={createForm.name}
onChange={e => setCreateForm(f => ({ ...f, name: e.target.value }))}
placeholder="Auditor, ITSupport, Reception..."
required
pattern="^[A-Za-z][A-Za-z0-9_]*$"
/>
<div className="text-xs text-slate-500">
Chỉ chữ + số + underscore, bắt đu bằng chữ. Dùng cho [Authorize(Roles="...")] + workflow guard.
</div>
</div>
<div className="space-y-1.5">
<Label> viết tắt (Vietnamese)</Label>
<Input
value={createForm.shortName}
onChange={e => setCreateForm(f => ({ ...f, shortName: e.target.value }))}
placeholder="vd: KSV, IT, LT..."
/>
</div>
<div className="space-y-1.5">
<Label>Tên đy đ tiếng Việt</Label>
<Textarea
rows={2}
value={createForm.description}
onChange={e => setCreateForm(f => ({ ...f, description: e.target.value }))}
placeholder="vd: Kiểm soát viên nội bộ"
/>
</div>
</form>
</Dialog>
{/* Edit existing role */}
<Dialog
open={!!editTarget}
onClose={() => setEditTarget(null)}
title={`Sửa role: ${editTarget?.name}`}
size="md"
footer={
<>
<Button variant="outline" onClick={() => setEditTarget(null)}>Hủy</Button>
<Button onClick={() => editMut.mutate()} disabled={editMut.isPending}>
{editMut.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</>
}
>
{editTarget && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label> code</Label>
<Input value={editTarget.name} disabled className="bg-slate-50 font-mono" />
<div className="text-xs text-slate-500">Không đi đưc sau khi tạo.</div>
</div>
<div className="space-y-1.5">
<Label> viết tắt</Label>
<Input
value={editForm.shortName}
onChange={e => setEditForm(f => ({ ...f, shortName: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label>Tên đy đ tiếng Việt</Label>
<Textarea
rows={2}
value={editForm.description}
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
</div>
)}
</Dialog>
</div>
)
}