Commit c16e4af5 authored by ThinhNC's avatar ThinhNC

feat: add debt management pages, admin roles, and permission constants

parent 7b2032de
...@@ -87,10 +87,10 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -87,10 +87,10 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module tích hợp hệ thống thuộc Trung tâm quản trị tại `/admin/integrations`, kết nối API Key và Webhook API qua TanStack Query; gồm tạo/thu hồi key, giới hạn scope/IP/hết hạn, tạo/sửa/tắt/xóa/test webhook, xem lịch sử delivery và phát lại delivery lỗi, với secret một lần, loading/error/empty state và bản dịch Việt/Anh. - Module tích hợp hệ thống thuộc Trung tâm quản trị tại `/admin/integrations`, kết nối API Key và Webhook API qua TanStack Query; gồm tạo/thu hồi key, giới hạn scope/IP/hết hạn, tạo/sửa/tắt/xóa/test webhook, xem lịch sử delivery và phát lại delivery lỗi, với secret một lần, loading/error/empty state và bản dịch Việt/Anh.
- **Dynamic RBAC & Phân quyền Frontend (Upgrade 7)**: - **Dynamic RBAC & Phân quyền Frontend (Upgrade 7)**:
- Phân quyền dựa trên danh sách `user.permissions` động từ API `/auth/me` và JWT token. - Phân quyền dựa trên danh sách `user.permissions` động từ API `/auth/me` và JWT token.
- Sử dụng `<PermissionGate permission={PERMISSIONS.*} fallback={<AccessDenied />}>` để bảo vệ routes và ẩn/hiện action buttons trên UI. - Sử dụng `<PermissionGate permission={PERMISSIONS.*} fallback={<AccessDenied />}>` để bảo vệ routes và ẩn/hiện action buttons trên UI. Lối vào Quản lý Nợ trên Trang chủ được bảo vệ bởi `PERMISSIONS.DEBT_READ`, các nút Tạo/Sửa/Xóa/Thanh toán/Tất toán được bảo vệ tương ứng bởi `DEBT_CREATE`, `DEBT_UPDATE`, `DEBT_DELETE`, `DEBT_SETTLE`.
- QUY TẮC BẮT BUỘC: Tất cả tên quyền (Permission names) PHẢI được import từ `@/common/constants/permission.constant.ts` (hoặc `@/common/constants`), TUYỆT ĐỐI KHÔNG hardcode chuỗi string permission rải rác trong code. - QUY TẮC BẮT BUỘC: Tất cả tên quyền (Permission names) PHẢI được import từ `@/common/constants/permission.constant.ts` (hoặc `@/common/constants`), TUYỆT ĐỐI KHÔNG hardcode chuỗi string permission rải rác trong code.
- Các vai trò hệ thống mặc định/bất biến được định nghĩa tập trung qua `SYSTEM_ROLES` trong `src/common/constants/system-role.constant.ts`. - Các vai trò hệ thống mặc định/bất biến được định nghĩa tập trung qua `SYSTEM_ROLES` trong `src/common/constants/system-role.constant.ts`.
- Màn hình quản lý phân quyền Claymorphism tại `/roles` và `/admin/roles` gồm: Danh sách Roles, Ma trận phân quyền theo Resource/Action, Thêm/Sửa/Xóa vai trò tùy chỉnh và Nhật ký kiểm toán (Audit Logs). - Màn hình quản lý phân quyền Claymorphism tại `/roles` và `/admin/roles` gồm: Danh sách Roles, Ma trận phân quyền theo Resource/Action (hỗ trợ đầy đủ các action badges: `READ`, `CREATE`, `UPDATE`, `DELETE`, `EXECUTE`, `ASSIGN`, `USE`, `MANAGE`, `EVALUATE`, `CONFIG_UPDATE`, `USAGE_READ`, `MAINTENANCE_UPDATE`, `TEMPLATE_READ`, `TEMPLATE_UPDATE`, `RETRY`, `SETTLE`, `EXPORT`, `IMPORT`, `INITIATE`, `CLAIM`), Thêm/Sửa/Xóa vai trò tùy chỉnh và Nhật ký kiểm toán (Audit Logs).
- Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục (lịch sử kiểm toán được quản lý tập trung tại `/admin/audit-logs`), cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`. - Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục (lịch sử kiểm toán được quản lý tập trung tại `/admin/audit-logs`), cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`.
- Mọi lối vào module trên trang chủ phải được ẩn khi người dùng thiếu quyền đang bảo vệ route tương ứng; tiêu đề nhóm Tính năng Nâng cao cũng ẩn khi không có bất kỳ module con nào khả dụng. Lối vào Hồ sơ vẫn hiển thị vì route này không có permission riêng; lối vào Style Guide mặc định ẩn với vai trò hệ thống `USER` và hiển thị cho các vai trò còn lại. - Mọi lối vào module trên trang chủ phải được ẩn khi người dùng thiếu quyền đang bảo vệ route tương ứng; tiêu đề nhóm Tính năng Nâng cao cũng ẩn khi không có bất kỳ module con nào khả dụng. Lối vào Hồ sơ vẫn hiển thị vì route này không có permission riêng; lối vào Style Guide mặc định ẩn với vai trò hệ thống `USER` và hiển thị cho các vai trò còn lại.
- Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users` và `GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa. - Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users` và `GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa.
......
...@@ -130,6 +130,21 @@ export const PERMISSIONS = { ...@@ -130,6 +130,21 @@ export const PERMISSIONS = {
// STATEMENT EXPORT // STATEMENT EXPORT
STATEMENT_READ: 'STATEMENT_READ', STATEMENT_READ: 'STATEMENT_READ',
STATEMENT_EXPORT: 'STATEMENT_EXPORT', STATEMENT_EXPORT: 'STATEMENT_EXPORT',
// DEBT & LOAN MANAGEMENT
DEBT_READ: 'DEBT_READ',
DEBT_CREATE: 'DEBT_CREATE',
DEBT_UPDATE: 'DEBT_UPDATE',
DEBT_DELETE: 'DEBT_DELETE',
DEBT_SETTLE: 'DEBT_SETTLE',
// DATA BACKUP & RESTORE
BACKUP_EXPORT: 'BACKUP_EXPORT',
BACKUP_IMPORT: 'BACKUP_IMPORT',
// ACCOUNT HANDOVER
HANDOVER_INITIATE: 'HANDOVER_INITIATE',
HANDOVER_CLAIM: 'HANDOVER_CLAIM',
} as const; } as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
...@@ -41,6 +41,11 @@ const ACTION_COLORS: Record<string, string> = { ...@@ -41,6 +41,11 @@ const ACTION_COLORS: Record<string, string> = {
TEMPLATE_READ: 'bg-blue-500/15 text-blue-600 border-blue-500/30', TEMPLATE_READ: 'bg-blue-500/15 text-blue-600 border-blue-500/30',
TEMPLATE_UPDATE: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30', TEMPLATE_UPDATE: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
RETRY: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30', RETRY: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30',
SETTLE: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30',
EXPORT: 'bg-cyan-500/15 text-cyan-600 border-cyan-500/30',
IMPORT: 'bg-fuchsia-500/15 text-fuchsia-600 border-fuchsia-500/30',
INITIATE: 'bg-sky-500/15 text-sky-600 border-sky-500/30',
CLAIM: 'bg-violet-500/15 text-violet-600 border-violet-500/30',
}; };
......
...@@ -34,6 +34,8 @@ import { ...@@ -34,6 +34,8 @@ import {
DebtStatus, DebtStatus,
} from "@/types/debt"; } from "@/types/debt";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { PERMISSIONS } from "@/common/constants";
import { PermissionGate } from "@/components/shared/PermissionGate";
const DebtDetailPage: React.FC = () => { const DebtDetailPage: React.FC = () => {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
...@@ -393,37 +395,43 @@ const DebtDetailPage: React.FC = () => { ...@@ -393,37 +395,43 @@ const DebtDetailPage: React.FC = () => {
{/* Action Buttons: Edit, Settle Early, Archive */} {/* Action Buttons: Edit, Settle Early, Archive */}
<div className="pt-2 flex items-center gap-2"> <div className="pt-2 flex items-center gap-2">
<Button <PermissionGate permission={PERMISSIONS.DEBT_UPDATE}>
variant="secondary"
shape="pill"
onClick={() => setIsEditOpen(true)}
className="py-2 px-3.5 text-xs sm:text-sm bg-clay-surface text-clay-text gap-1.5 shadow-clay-raised"
>
<EditIcon size={15} /> {t("debts.detail.editBtn")}
</Button>
{debt.status === "ACTIVE" && (
<Button <Button
variant="secondary" variant="secondary"
shape="pill" shape="pill"
onClick={() => { onClick={() => setIsEditOpen(true)}
setSettleWalletId(debt.walletId || ""); className="py-2 px-3.5 text-xs sm:text-sm bg-clay-surface text-clay-text gap-1.5 shadow-clay-raised"
setPenaltyFeeRaw("0");
setIsSettleEarlyOpen(true);
}}
className="flex-1 py-2 text-xs sm:text-sm bg-clay-primary/10 text-clay-primary border-clay-primary/20 gap-1.5"
> >
<ZapIcon size={16} /> {t("debts.detail.settleEarlyBtn")} <EditIcon size={15} /> {t("debts.detail.editBtn")}
</Button> </Button>
</PermissionGate>
{debt.status === "ACTIVE" && (
<PermissionGate permission={PERMISSIONS.DEBT_SETTLE}>
<Button
variant="secondary"
shape="pill"
onClick={() => {
setSettleWalletId(debt.walletId || "");
setPenaltyFeeRaw("0");
setIsSettleEarlyOpen(true);
}}
className="flex-1 py-2 text-xs sm:text-sm bg-clay-primary/10 text-clay-primary border-clay-primary/20 gap-1.5"
>
<ZapIcon size={16} /> {t("debts.detail.settleEarlyBtn")}
</Button>
</PermissionGate>
)} )}
<Button <PermissionGate permission={PERMISSIONS.DEBT_DELETE}>
variant="ghost" <Button
shape="pill" variant="ghost"
onClick={() => setIsArchiveConfirmOpen(true)} shape="pill"
className="px-3 py-2 text-clay-text-muted hover:text-clay-expense text-xs sm:text-sm gap-1.5" onClick={() => setIsArchiveConfirmOpen(true)}
title={t("debts.detail.archive")} className="px-3 py-2 text-clay-text-muted hover:text-clay-expense text-xs sm:text-sm gap-1.5"
> title={t("debts.detail.archive")}
<TrashIcon size={16} /> {t("common.delete")} >
</Button> <TrashIcon size={16} /> {t("common.delete")}
</Button>
</PermissionGate>
</div> </div>
</Card> </Card>
...@@ -474,14 +482,16 @@ const DebtDetailPage: React.FC = () => { ...@@ -474,14 +482,16 @@ const DebtDetailPage: React.FC = () => {
{scheduleStatusLabel[item.status]} {scheduleStatusLabel[item.status]}
</Badge> </Badge>
{canPay && ( {canPay && (
<Button <PermissionGate permission={PERMISSIONS.DEBT_SETTLE}>
variant="primary" <Button
shape="pill" variant="primary"
onClick={() => handleOpenPayModal(item)} shape="pill"
className="!px-3 !py-1 text-xs font-baloo" onClick={() => handleOpenPayModal(item)}
> className="!px-3 !py-1 text-xs font-baloo"
{t("debts.detail.payNow")} >
</Button> {t("debts.detail.payNow")}
</Button>
</PermissionGate>
)} )}
</div> </div>
</div> </div>
......
...@@ -16,6 +16,8 @@ import { ...@@ -16,6 +16,8 @@ import {
IconGradients, IconGradients,
PlusIcon, PlusIcon,
} from "@/components/ui/icons"; } from "@/components/ui/icons";
import { PERMISSIONS } from "@/common/constants";
import { PermissionGate } from "@/components/shared/PermissionGate";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { DebtType } from "@/types/debt"; import { DebtType } from "@/types/debt";
...@@ -70,14 +72,16 @@ const DebtsPage: React.FC = () => { ...@@ -70,14 +72,16 @@ const DebtsPage: React.FC = () => {
<p className="clay-caption">{t("debts.subtitle")}</p> <p className="clay-caption">{t("debts.subtitle")}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <PermissionGate permission={PERMISSIONS.DEBT_CREATE}>
variant="primary" <Button
shape="pill" variant="primary"
onClick={handleOpenCreate} shape="pill"
className="gap-1.5 px-3.5 py-2 text-xs sm:text-sm shadow-clay-raised" onClick={handleOpenCreate}
> className="gap-1.5 px-3.5 py-2 text-xs sm:text-sm shadow-clay-raised"
<PlusIcon size={16} /> {t("debts.createDebtBtn")} >
</Button> <PlusIcon size={16} /> {t("debts.createDebtBtn")}
</Button>
</PermissionGate>
<Button <Button
variant="secondary" variant="secondary"
shape="pill" shape="pill"
......
...@@ -212,9 +212,11 @@ function HomePage() { ...@@ -212,9 +212,11 @@ function HomePage() {
{t("home.wallets")} {t("home.wallets")}
</Button> </Button>
</PermissionGate> </PermissionGate>
<Button variant="secondary" fullWidth onClick={() => navigate("/debts")} className="gap-2"> <PermissionGate permission={PERMISSIONS.DEBT_READ}>
{t("home.debts")} <Button variant="secondary" fullWidth onClick={() => navigate("/debts")} className="gap-2">
</Button> {t("home.debts")}
</Button>
</PermissionGate>
<PermissionGate permission={PERMISSIONS.TRANSACTION_READ}> <PermissionGate permission={PERMISSIONS.TRANSACTION_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/transactions")}> <Button variant="secondary" fullWidth onClick={() => navigate("/transactions")}>
{t("home.transactions")} {t("home.transactions")}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment