Files
aeroresolve_frontend/src/app/policyEngine/components/PolicyEngineList.tsx
T

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"
/>
</>
);
}