360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
PlusIcon,
|
|
TrashIcon,
|
|
MagnifyingGlassIcon,
|
|
XIcon,
|
|
PencilSimpleIcon,
|
|
ChecksIcon,
|
|
} from "@phosphor-icons/react";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import {
|
|
CustomTable,
|
|
CustomInput,
|
|
CustomButton,
|
|
CustomStatus,
|
|
CustomActionMenu,
|
|
CustomActionItem,
|
|
CustomConfirmationModal,
|
|
CustomAlertBanner,
|
|
Skeleton,
|
|
} from "../../../components/custom";
|
|
import type { Column } from "../../../components/custom/CustomTable";
|
|
import type { PolicyEngineResponse } from "../PolicyEngineTypes";
|
|
import Can from "../../../components/common/Can";
|
|
import {
|
|
getPolicies,
|
|
deletePolicy,
|
|
updatePolicyStatus,
|
|
} from "../PolicyEngineApi";
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function HeaderLabel({ text }: { text: string }) {
|
|
return (
|
|
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">
|
|
{text}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CellText({ text }: { text: string | null | undefined }) {
|
|
return (
|
|
<span style={{ fontSize: "14px", color: "#676767", fontWeight: 500 }}>
|
|
{text || "—"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function BadgeLabel({ text }: { text: string }) {
|
|
return (
|
|
<span className="px-3 py-1 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold">
|
|
{text}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
export default function PolicyEngineList() {
|
|
const navigate = useNavigate();
|
|
const [policies, setPolicies] = useState<PolicyEngineResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
|
const location = useLocation();
|
|
|
|
useEffect(() => {
|
|
if (location.state?.successMsg) {
|
|
setSuccessMsg(location.state.successMsg);
|
|
window.history.replaceState({}, document.title);
|
|
}
|
|
}, [location]);
|
|
|
|
// Pagination
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
// Filters
|
|
const [search, setSearch] = useState("");
|
|
|
|
// Modal state
|
|
const [deleteTarget, setDeleteTarget] = useState<PolicyEngineResponse | null>(
|
|
null,
|
|
);
|
|
const [deactivateTarget, setDeactivateTarget] =
|
|
useState<PolicyEngineResponse | null>(null);
|
|
|
|
// ─── Fetch data ─────────────────────────────────────────────
|
|
|
|
const fetchPolicies = useCallback((page: number) => {
|
|
setLoading(true);
|
|
getPolicies(page, PAGE_SIZE)
|
|
.then((res) => {
|
|
setPolicies(res.data || []);
|
|
setTotalItems(res.total || 0);
|
|
setTotalPages(res.totalPages || 1);
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to fetch policies:", err);
|
|
setPolicies([]);
|
|
setTotalItems(0);
|
|
setTotalPages(1);
|
|
})
|
|
.finally(() => {
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchPolicies(currentPage);
|
|
}, [currentPage, fetchPolicies]);
|
|
|
|
// ─── Handlers ──────────────────────────────────────────────────────────────
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page);
|
|
};
|
|
|
|
const handleSearchChange = (val: string) => {
|
|
setSearch(val);
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
setError(null);
|
|
setSuccessMsg(null);
|
|
try {
|
|
await deletePolicy(deleteTarget.id);
|
|
setSuccessMsg(
|
|
`Policy "${deleteTarget.policyName}" deleted successfully.`,
|
|
);
|
|
setDeleteTarget(null);
|
|
fetchPolicies(currentPage);
|
|
} catch (err) {
|
|
console.error("Failed to delete policy:", err);
|
|
setError("Failed to delete policy. Please try again.");
|
|
}
|
|
};
|
|
|
|
const handleToggleStatus = async () => {
|
|
if (!deactivateTarget) return;
|
|
setError(null);
|
|
setSuccessMsg(null);
|
|
try {
|
|
const nextStatus =
|
|
deactivateTarget.status === "Active" ? "inactive" : "active";
|
|
await updatePolicyStatus(deactivateTarget.id, nextStatus);
|
|
setSuccessMsg(
|
|
`Policy "${deactivateTarget.policyName}" is now ${nextStatus === "active" ? "Active" : "Inactive"}.`,
|
|
);
|
|
setDeactivateTarget(null);
|
|
fetchPolicies(currentPage);
|
|
} catch (err) {
|
|
console.error("Failed to toggle policy status:", err);
|
|
setError("Failed to update policy status. Please try again.");
|
|
}
|
|
};
|
|
|
|
const displayedPolicies = search
|
|
? policies.filter(
|
|
(p) =>
|
|
p.policyName?.toLowerCase().includes(search.toLowerCase()) ||
|
|
p.jurisdiction?.toLowerCase().includes(search.toLowerCase()),
|
|
)
|
|
: policies;
|
|
|
|
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
|
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
|
|
|
// ─── Table columns ─────────────────────────────────────────────────────────
|
|
|
|
const columns: Column<PolicyEngineResponse>[] = [
|
|
{
|
|
header: <HeaderLabel text="Policy Name" />,
|
|
accessor: (row) => (
|
|
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
|
|
{row.policyName}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Jurisdiction" />,
|
|
accessor: (row) => <BadgeLabel text={row.jurisdiction} />,
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Status" />,
|
|
accessor: (row) => <CustomStatus status={row.status} />,
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Last Modified" />,
|
|
accessor: (row) => <CellText text={row.lastModified} />,
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Action" />,
|
|
className: "text-right",
|
|
accessor: (row) => (
|
|
<CustomActionMenu>
|
|
{row.status?.toLowerCase() === "inactive" && (
|
|
<Can permission="policy_engine:publish">
|
|
<CustomActionItem
|
|
icon={<ChecksIcon size={15} />}
|
|
variant="success"
|
|
onClick={() => setDeactivateTarget(row)}
|
|
>
|
|
Activate
|
|
</CustomActionItem>
|
|
</Can>
|
|
)}
|
|
{row.status?.toLowerCase() === "active" && (
|
|
<Can permission="policy_engine:publish">
|
|
<CustomActionItem
|
|
icon={<XIcon size={15} />}
|
|
onClick={() => setDeactivateTarget(row)}
|
|
>
|
|
Deactivate
|
|
</CustomActionItem>
|
|
</Can>
|
|
)}
|
|
<Can permission="policy_engine:edit">
|
|
<CustomActionItem
|
|
icon={<PencilSimpleIcon size={15} />}
|
|
onClick={() => navigate(`/policy-engine/add?id=${row.id}`)}
|
|
>
|
|
Edit
|
|
</CustomActionItem>
|
|
</Can>
|
|
<Can permission="policy_engine:delete">
|
|
<CustomActionItem
|
|
icon={<TrashIcon size={15} />}
|
|
variant="danger"
|
|
onClick={() => setDeleteTarget(row)}
|
|
>
|
|
Delete
|
|
</CustomActionItem>
|
|
</Can>
|
|
</CustomActionMenu>
|
|
),
|
|
},
|
|
];
|
|
|
|
// ─── Loading skeleton ──────────────────────────────────────────────────────
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
|
<Skeleton width={320} height={36} />
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton width={148} height={36} />
|
|
</div>
|
|
</div>
|
|
<div className="p-6 flex flex-col gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Skeleton key={i} height={52} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Render ────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<>
|
|
{error && (
|
|
<CustomAlertBanner
|
|
message={error}
|
|
type="error"
|
|
onClose={() => setError(null)}
|
|
/>
|
|
)}
|
|
{successMsg && (
|
|
<CustomAlertBanner
|
|
message={successMsg}
|
|
type="success"
|
|
onClose={() => setSuccessMsg(null)}
|
|
/>
|
|
)}
|
|
<CustomTable<PolicyEngineResponse>
|
|
columns={columns}
|
|
data={displayedPolicies}
|
|
leftHeaderActions={
|
|
<div className="w-[380px]">
|
|
<CustomInput
|
|
placeholder="Search framework registry..."
|
|
value={search}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
leftIcon={<MagnifyingGlassIcon size={16} />}
|
|
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
|
|
containerClassName="!gap-0"
|
|
/>
|
|
</div>
|
|
}
|
|
rightHeaderActions={
|
|
<>
|
|
<Can permission="policy_engine:create">
|
|
<CustomButton
|
|
variant="primary"
|
|
size="md"
|
|
leftIcon={<PlusIcon size={16} />}
|
|
className="!rounded-[10px] !gap-[10px] !h-[40px] !bg-[#1E7D5C] hover:!bg-[#17664B]"
|
|
onClick={() => navigate("/policy-engine/add")}
|
|
>
|
|
Deploy New Policy
|
|
</CustomButton>
|
|
</Can>
|
|
</>
|
|
}
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
startIndex={startIndex}
|
|
endIndex={endIndex}
|
|
onPageChange={handlePageChange}
|
|
itemName="Policies"
|
|
/>
|
|
|
|
{/* Delete Confirmation */}
|
|
<CustomConfirmationModal
|
|
isOpen={!!deleteTarget}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={handleDelete}
|
|
title="Delete Policy"
|
|
description={`"${deleteTarget?.policyName}" will be permanently removed.`}
|
|
confirmText="Delete"
|
|
cancelText="Cancel"
|
|
variant="danger"
|
|
/>
|
|
|
|
{/* Activate / Deactivate Confirmation */}
|
|
<CustomConfirmationModal
|
|
isOpen={!!deactivateTarget}
|
|
onClose={() => setDeactivateTarget(null)}
|
|
onConfirm={handleToggleStatus}
|
|
title={
|
|
deactivateTarget?.status === "Active"
|
|
? "Deactivate Policy"
|
|
: "Activate Policy"
|
|
}
|
|
description={
|
|
deactivateTarget?.status === "Active"
|
|
? `"${deactivateTarget?.policyName}" will be deactivated.`
|
|
: `"${deactivateTarget?.policyName}" will be reactivated.`
|
|
}
|
|
confirmText={
|
|
deactivateTarget?.status === "Active" ? "Deactivate" : "Activate"
|
|
}
|
|
cancelText="Cancel"
|
|
variant="warning"
|
|
/>
|
|
</>
|
|
);
|
|
}
|