This commit is contained in:
Syed Waseem
2026-08-20 16:35:33 +05:30
8 changed files with 438 additions and 137 deletions
+79 -52
View File
@@ -3,7 +3,7 @@ import {
MagnifyingGlassIcon,
CalendarBlank,
Export,
CaretRight
CaretRight,
} from "@phosphor-icons/react";
import {
CustomTable,
@@ -11,14 +11,11 @@ import {
CustomStatus,
Skeleton,
CustomButton,
CustomDropdown
CustomDropdown,
} from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
import type { AuditLog, AuditLogFilters } from "../AuditLogsTypes";
import {
AUDIT_MODULE_LABELS,
AUDIT_ACTION_VARIANTS,
} from "../AuditLogsTypes";
import { AUDIT_MODULE_LABELS, AUDIT_ACTION_VARIANTS } from "../AuditLogsTypes";
import { getAuditLogs } from "../AuditLogsApi";
import AuditLogDetail from "./AuditLogDetail";
@@ -32,9 +29,17 @@ function HeaderLabel({ text }: { text: string }) {
);
}
function PrimaryText({ text, className = "" }: { text: string, className?: string }) {
function PrimaryText({
text,
className = "",
}: {
text: string;
className?: string;
}) {
return (
<div className={`text-[13px] font-semibold text-[#0F172B] leading-[18px] ${className}`}>
<div
className={`text-[13px] font-semibold text-[#0F172B] leading-[18px] ${className}`}
>
{text}
</div>
);
@@ -53,9 +58,11 @@ function ModuleBadge({ module }: { module: string }) {
function getLogDescription(row: AuditLog) {
const entity = row.entityLabel || row.entityId || "record";
const mod = AUDIT_MODULE_LABELS[row.module] || row.module;
if (row.action === "CREATE") return `New record created in ${mod}: ${entity}.`;
if (row.action === "CREATE")
return `New record created in ${mod}: ${entity}.`;
if (row.action === "UPDATE") return `${mod} record updated: ${entity}.`;
if (row.action === "STATUS_CHANGE") return `Status changed for ${entity} in ${mod}.`;
if (row.action === "STATUS_CHANGE")
return `Status changed for ${entity} in ${mod}.`;
if (row.action === "DELETE") return `${mod} record deleted: ${entity}.`;
return `Action ${row.action} performed on ${entity}.`;
}
@@ -88,33 +95,22 @@ export default function AuditLogsList() {
if (daysFilter !== "all") {
const now = new Date();
const past = new Date();
if (daysFilter === "today") {
past.setHours(0, 0, 0, 0);
} else {
past.setDate(now.getDate() - parseInt(daysFilter, 10));
}
filters.dateFrom = past.toISOString();
filters.dateTo = now.toISOString();
}
const result = await getAuditLogs(filters);
// Client-side search on entityLabel / entityId
const filtered = search
? result.data.filter(
(l) =>
l.entityLabel?.toLowerCase().includes(search.toLowerCase()) ||
l.entityId?.toLowerCase().includes(search.toLowerCase()) ||
l.module?.toLowerCase().includes(search.toLowerCase()) ||
l.performedBy?.toLowerCase().includes(search.toLowerCase())
)
: result.data;
setLogs(filtered);
setTotalItems(result.total);
setTotalPages(result.totalPages);
setLogs(result.data || []);
setTotalItems(result.total || 0);
setTotalPages(result.totalPages || 1);
} catch (err) {
console.error("Failed to fetch audit logs", err);
setLogs([]);
@@ -122,7 +118,7 @@ export default function AuditLogsList() {
setLoading(false);
}
},
[search, daysFilter]
[daysFilter],
);
useEffect(() => {
@@ -137,20 +133,29 @@ export default function AuditLogsList() {
const handleExportCSV = () => {
if (logs.length === 0) return;
const headers = ["Timestamp", "User", "Action", "Module", "Description", "Audit ID"];
const rows = logs.map(row => {
const headers = [
"Timestamp",
"User",
"Action",
"Module",
"Description",
"Audit ID",
];
const rows = logs.map((row) => {
const date = new Date(row.createdAt).toLocaleString();
const user = row.performedBy ?? "System";
const action = row.action;
const module = AUDIT_MODULE_LABELS[row.module] ?? row.module;
const entity = row.entityLabel || row.entityId || "record";
let desc = `Action ${action} performed on ${entity}.`;
if (action === "CREATE") desc = `New record created in ${module}: ${entity}.`;
if (action === "CREATE")
desc = `New record created in ${module}: ${entity}.`;
if (action === "UPDATE") desc = `${module} record updated: ${entity}.`;
if (action === "STATUS_CHANGE") desc = `Status changed for ${entity} in ${module}.`;
if (action === "STATUS_CHANGE")
desc = `Status changed for ${entity} in ${module}.`;
if (action === "DELETE") desc = `${module} record deleted: ${entity}.`;
const auditId = row.id;
// Escape quotes and wrap in quotes to handle commas in values
@@ -160,14 +165,14 @@ export default function AuditLogsList() {
`"${action.replace(/"/g, '""')}"`,
`"${module.replace(/"/g, '""')}"`,
`"${desc.replace(/"/g, '""')}"`,
`"${auditId.replace(/"/g, '""')}"`
`"${auditId.replace(/"/g, '""')}"`,
].join(",");
});
const csvContent = [headers.join(","), ...rows].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `audit_logs_${new Date().getTime()}.csv`);
@@ -183,11 +188,14 @@ export default function AuditLogsList() {
header: <HeaderLabel text="Timestamp" />,
accessor: (row) => {
const d = new Date(row.createdAt);
const time = d.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).toLowerCase().replace(' ', '');
const time = d
.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.toLowerCase()
.replace(" ", "");
const date = d.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
@@ -195,8 +203,12 @@ export default function AuditLogsList() {
});
return (
<div className="flex flex-col">
<span className="text-[13px] font-medium text-[#6C766D]">{time}</span>
<span className="text-[13px] font-medium text-[#6C766D]">{date}</span>
<span className="text-[13px] font-medium text-[#6C766D]">
{time}
</span>
<span className="text-[13px] font-medium text-[#6C766D]">
{date}
</span>
</div>
);
},
@@ -210,7 +222,8 @@ export default function AuditLogsList() {
{
header: <HeaderLabel text="Action" />,
accessor: (row) => {
const displayStatus = row.action === 'STATUS_CHANGE' ? 'Status Change' : row.action;
const displayStatus =
row.action === "STATUS_CHANGE" ? "Status Change" : row.action;
return (
<CustomStatus
status={displayStatus}
@@ -232,7 +245,7 @@ export default function AuditLogsList() {
{
header: <HeaderLabel text="Audit ID" />,
accessor: (row) => {
const hash = row.id.split('-')[0].substring(0, 4).toUpperCase();
const hash = row.id.split("-")[0].substring(0, 4).toUpperCase();
return (
<div className="flex items-center gap-2 text-[13px] font-medium text-[#6C766D]">
LOG-{hash}
@@ -266,6 +279,15 @@ export default function AuditLogsList() {
// ─── Render ─────────────────────────────────────────────────────────────────
const displayedLogs = search
? logs.filter((l) =>
l.entityLabel?.toLowerCase().includes(search.toLowerCase()) ||
l.entityId?.toLowerCase().includes(search.toLowerCase()) ||
l.module?.toLowerCase().includes(search.toLowerCase()) ||
l.performedBy?.toLowerCase().includes(search.toLowerCase())
)
: logs;
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
@@ -273,14 +295,16 @@ export default function AuditLogsList() {
<div className="w-full flex flex-col gap-6">
<CustomTable<AuditLog>
columns={columns}
data={logs}
data={displayedLogs}
leftHeaderActions={
<div className="w-[360px]">
<CustomInput
placeholder="Search logs by keyword, user, or ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<MagnifyingGlassIcon size={16} className="text-[#6C766D]" />}
leftIcon={
<MagnifyingGlassIcon size={16} className="text-[#6C766D]" />
}
className="!bg-[#F8F9FA] !rounded-[8px] !h-[40px] !border-none !text-[13px]"
containerClassName="!gap-0 border-none"
/>
@@ -292,7 +316,13 @@ export default function AuditLogsList() {
<CustomDropdown
value={daysFilter}
onChange={setDaysFilter}
leftIcon={<CalendarBlank size={16} weight="bold" className="text-[#1E8E3E]" />}
leftIcon={
<CalendarBlank
size={16}
weight="bold"
className="text-[#1E8E3E]"
/>
}
searchable={false}
options={[
{ label: "Today", value: "today" },
@@ -304,7 +334,7 @@ export default function AuditLogsList() {
className="!border-[#1E8E3E] !bg-[#E6F4EA] !h-[40px] hover:!bg-[#E6F4EA]"
/>
</div>
<CustomButton
<CustomButton
variant="outlined"
onClick={handleExportCSV}
className="flex items-center gap-2 h-[40px] px-4 rounded-[8px] border border-[#1E8E3E] text-[#1E8E3E] text-[13px] font-bold hover:bg-[#E6F4EA] transition-colors cursor-pointer"
@@ -327,10 +357,7 @@ export default function AuditLogsList() {
}
/>
<AuditLogDetail
log={selectedLog}
onClose={() => setSelectedLog(null)}
/>
<AuditLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />
</div>
);
}
@@ -54,6 +54,7 @@ export default function CohortList() {
const [cohorts, setCohorts] = useState<CohartResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Server-side pagination
const [currentPage, setCurrentPage] = useState(1);
@@ -113,8 +114,11 @@ export default function CohortList() {
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleteLoading(true);
setError(null);
setSuccessMsg(null);
try {
await deleteCohart(deleteTarget.id);
setSuccessMsg(`Cohort "${deleteTarget.name}" deleted successfully.`);
setDeleteTarget(null);
fetchCohorts(currentPage);
} catch {
@@ -127,9 +131,12 @@ export default function CohortList() {
const handleToggleStatus = async () => {
if (!deactivateTarget) return;
setStatusLoading(true);
setError(null);
setSuccessMsg(null);
try {
const newStatus = deactivateTarget.status === 'Active' ? 'Inactive' : 'Active';
await updateCohartStatus(deactivateTarget.id, { status: newStatus });
setSuccessMsg(`Cohort "${deactivateTarget.name}" is now ${newStatus}.`);
setDeactivateTarget(null);
fetchCohorts(currentPage);
} catch {
@@ -239,6 +246,13 @@ export default function CohortList() {
onClose={() => setError(null)}
/>
)}
{successMsg && (
<CustomAlertBanner
message={successMsg}
type="success"
onClose={() => setSuccessMsg(null)}
/>
)}
<CustomTable<CohartResponse>
columns={columns}
@@ -15,6 +15,7 @@ import {
CustomSwitch,
CustomCheckBox,
CustomLoader,
Skeleton,
} from '../../../components/custom';
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
import {
@@ -649,7 +650,7 @@ export default function AddPolicyEngine() {
jurisdictionId: Array.isArray(jurisdiction) && jurisdiction.length > 0 ? String(jurisdiction[0]) : undefined,
jurisdictionIds: Array.isArray(jurisdiction) ? jurisdiction.map(String) : [],
description: description || undefined,
status: isDeploy ? 'active' : 'draft',
status: isDeploy ? status.toLowerCase() : 'draft',
audienceType: audienceType === 'Selected Cohorts' ? 'COHORT' : 'ALL',
targetAudiences:
audienceType === 'Selected Cohorts'
@@ -713,13 +714,13 @@ export default function AddPolicyEngine() {
valObj.booleanValue = !!val;
} else if (fieldType === 'number' || fieldType === 'decimal' || fieldType === 'percentage') {
valObj.numberValue = parseFloat(val) || 0;
} else if (Array.isArray(val)) {
valObj.textValue = JSON.stringify(val);
} else if (fieldType === 'dropdown' || fieldType === 'select' || fieldDef?.lookupSource) {
valObj.selectedValueId = String(val ?? '');
const lookupOpts = fieldLookupOptionsMap[fieldDef?.lookupSource || ''] || [];
const matchedOpt = lookupOpts.find((o) => o.value === val || o.id === val || o.code === val);
valObj.textValue = matchedOpt ? matchedOpt.label : String(val ?? '');
} else if (Array.isArray(val)) {
valObj.textValue = JSON.stringify(val);
} else {
valObj.textValue = String(val ?? '');
}
@@ -875,8 +876,82 @@ export default function AddPolicyEngine() {
if (loadingPolicy) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-white">
<CustomLoader label="Loading Policy Details..." />
<div className="flex flex-col h-full bg-white min-h-screen">
{/* ─── Header Skeleton ────────────────────────────────────────────── */}
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
<div className="flex items-start gap-4">
<div className="mt-1 p-1">
<Skeleton variant="circular" className="w-5 h-5" />
</div>
<div className="flex flex-col gap-1">
<Skeleton className="w-48 h-7" />
<Skeleton className="w-32 h-4" />
</div>
</div>
</div>
{/* ─── Body Content Skeleton ──────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
<div className="w-full flex flex-col gap-6">
{/* Policy Information Card */}
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
<div className="flex items-center gap-2 mb-5">
<Skeleton className="w-8 h-8 rounded-lg" />
<Skeleton className="w-40 h-5" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="flex flex-col gap-2">
<Skeleton className="w-24 h-4" />
<Skeleton className="w-full h-11" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="w-24 h-4" />
<Skeleton className="w-full h-11" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="w-24 h-4" />
<Skeleton className="w-full h-11" />
</div>
</div>
<div className="flex flex-col gap-2">
<Skeleton className="w-24 h-4" />
<Skeleton className="w-full h-24" />
</div>
</div>
{/* Target Audience Card */}
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
<div className="flex items-center gap-2 mb-5">
<Skeleton className="w-8 h-8 rounded-lg" />
<Skeleton className="w-40 h-5" />
</div>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-8">
<Skeleton className="w-32 h-6" />
<Skeleton className="w-32 h-6" />
</div>
</div>
</div>
{/* Rule Engine Card */}
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Skeleton className="w-8 h-8 rounded-lg" />
<Skeleton className="w-32 h-5" />
</div>
<Skeleton className="w-40 h-10 rounded-lg" />
</div>
<div className="border border-gray-100 rounded-[12px] p-6 bg-white">
<Skeleton className="w-full h-32" />
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1346,6 +1421,13 @@ export default function AddPolicyEngine() {
value={fieldVal ?? ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter value...'}
min={
field.fieldType === 'date'
? new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]
: field.fieldType === 'datetime'
? new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)
: undefined
}
/>
)}
@@ -1,6 +1,13 @@
import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, TrashIcon, MagnifyingGlassIcon, XIcon, PencilSimpleIcon, ChecksIcon } from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useCallback } from "react";
import {
PlusIcon,
TrashIcon,
MagnifyingGlassIcon,
XIcon,
PencilSimpleIcon,
ChecksIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom";
import {
CustomTable,
CustomInput,
@@ -9,11 +16,16 @@ import {
CustomActionMenu,
CustomActionItem,
CustomConfirmationModal,
CustomAlertBanner,
Skeleton,
} from '../../../components/custom';
import type { Column } from '../../../components/custom/CustomTable';
import type { PolicyEngineResponse } from '../PolicyEngineTypes';
import { getPolicies, deletePolicy, updatePolicyStatus } from '../PolicyEngineApi';
} from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
import type { PolicyEngineResponse } from "../PolicyEngineTypes";
import {
getPolicies,
deletePolicy,
updatePolicyStatus,
} from "../PolicyEngineApi";
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -23,14 +35,16 @@ const PAGE_SIZE = 10;
function HeaderLabel({ text }: { text: string }) {
return (
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">{text}</span>
<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 style={{ fontSize: "14px", color: "#676767", fontWeight: 500 }}>
{text || "—"}
</span>
);
}
@@ -49,6 +63,8 @@ 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);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
@@ -56,36 +72,42 @@ export default function PolicyEngineList() {
const [totalPages, setTotalPages] = useState(1);
// Filters
const [search, setSearch] = useState('');
const [search, setSearch] = useState("");
// Modal state
const [deleteTarget, setDeleteTarget] = useState<PolicyEngineResponse | null>(null);
const [deactivateTarget, setDeactivateTarget] = useState<PolicyEngineResponse | null>(null);
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, search)
.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);
});
}, [search]);
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, search, fetchPolicies]);
}, [currentPage, fetchPolicies]);
// ─── Handlers ──────────────────────────────────────────────────────────────
@@ -100,27 +122,43 @@ export default function PolicyEngineList() {
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);
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';
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);
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);
@@ -129,7 +167,7 @@ export default function PolicyEngineList() {
const columns: Column<PolicyEngineResponse>[] = [
{
header: <HeaderLabel text="Policy Name" />,
accessor: row => (
accessor: (row) => (
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
{row.policyName}
</span>
@@ -137,22 +175,22 @@ export default function PolicyEngineList() {
},
{
header: <HeaderLabel text="Jurisdiction" />,
accessor: row => <BadgeLabel text={row.jurisdiction} />,
accessor: (row) => <BadgeLabel text={row.jurisdiction} />,
},
{
header: <HeaderLabel text="Status" />,
accessor: row => <CustomStatus status={row.status} />,
accessor: (row) => <CustomStatus status={row.status} />,
},
{
header: <HeaderLabel text="Last Modified" />,
accessor: row => <CellText text={row.lastModified} />,
accessor: (row) => <CellText text={row.lastModified} />,
},
{
header: <HeaderLabel text="Action" />,
className: 'text-right',
accessor: row => (
className: "text-right",
accessor: (row) => (
<CustomActionMenu>
{row.status !== 'Active' && (
{row.status !== "Active" && (
<CustomActionItem
icon={<ChecksIcon size={15} />}
variant="success"
@@ -161,7 +199,7 @@ export default function PolicyEngineList() {
Activate
</CustomActionItem>
)}
{row.status === 'Active' && (
{row.status === "Active" && (
<CustomActionItem
icon={<XIcon size={15} />}
onClick={() => setDeactivateTarget(row)}
@@ -211,15 +249,29 @@ export default function PolicyEngineList() {
return (
<>
{error && (
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError(null)}
/>
)}
{successMsg && (
<CustomAlertBanner
message={successMsg}
type="success"
onClose={() => setSuccessMsg(null)}
/>
)}
<CustomTable<PolicyEngineResponse>
columns={columns}
data={policies}
data={displayedPolicies}
leftHeaderActions={
<div className="w-[380px]">
<CustomInput
placeholder="Search framework registry..."
value={search}
onChange={e => handleSearchChange(e.target.value)}
onChange={(e) => handleSearchChange(e.target.value)}
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
@@ -233,7 +285,7 @@ export default function PolicyEngineList() {
size="md"
leftIcon={<PlusIcon size={16} />}
className="!rounded-[10px] !gap-[10px] !h-[40px] !bg-[#1E7D5C] hover:!bg-[#17664B]"
onClick={() => navigate('/policy-engine/add')}
onClick={() => navigate("/policy-engine/add")}
>
Deploy New Policy
</CustomButton>
@@ -265,13 +317,19 @@ export default function PolicyEngineList() {
isOpen={!!deactivateTarget}
onClose={() => setDeactivateTarget(null)}
onConfirm={handleToggleStatus}
title={deactivateTarget?.status === 'Active' ? 'Deactivate Policy' : 'Activate Policy'}
title={
deactivateTarget?.status === "Active"
? "Deactivate Policy"
: "Activate Policy"
}
description={
deactivateTarget?.status === 'Active'
deactivateTarget?.status === "Active"
? `"${deactivateTarget?.policyName}" will be deactivated.`
: `"${deactivateTarget?.policyName}" will be reactivated.`
}
confirmText={deactivateTarget?.status === 'Active' ? 'Deactivate' : 'Activate'}
confirmText={
deactivateTarget?.status === "Active" ? "Deactivate" : "Activate"
}
cancelText="Cancel"
variant="warning"
/>
@@ -18,6 +18,7 @@ import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface AddRecoveryIncidentsProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: (createdCount: number, flightNumber: string, passengerName: string) => void;
incident?: RecoveryIncident | null;
}
@@ -44,7 +45,7 @@ const DEFAULT_JURISDICTION_OPTIONS = [
{ label: "CAA SG (Singapore)", value: "CAA_SG" },
];
export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddRecoveryIncidentsProps) {
export default function AddRecoveryIncidents({ isOpen, onClose, onSuccess, incident }: AddRecoveryIncidentsProps) {
const [, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]);
const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>(DEFAULT_JURISDICTION_OPTIONS);
const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>(DEFAULT_SCENARIO_OPTIONS);
@@ -347,6 +348,14 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
});
await Promise.all(promises);
if (onSuccess) {
onSuccess(
selectedPassengers.length,
formData.flightNumber || "TBD",
selectedPassengers[0]?.passengerName || "Unknown"
);
}
}
onClose();
} catch (error: any) {
@@ -22,6 +22,8 @@ import {
Skeleton,
CustomActionMenu,
CustomActionItem,
CustomAlertBanner,
CustomSuccessModal,
} from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
import type { RecoveryIncident, MetricCardData } from "../RecoveryIncidentsTypes";
@@ -90,6 +92,8 @@ export default function RecoveryIncidentsList() {
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
useEffect(() => {
getRecoveryMetrics()
@@ -110,6 +114,14 @@ export default function RecoveryIncidentsList() {
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Success Modal
const [successModalData, setSuccessModalData] = useState<{
isOpen: boolean;
count: number;
flightNumber: string;
passengerName: string;
}>({ isOpen: false, count: 0, flightNumber: '', passengerName: '' });
// ─── Fetch data ─────────────────────────────────────────────
const fetchIncidents = useCallback(async () => {
@@ -180,12 +192,16 @@ export default function RecoveryIncidentsList() {
const handleStatusChange = async (incident: RecoveryIncident, text: string) => {
try {
setError(null);
setSuccessMsg(null);
const { updateIncidentStatus, getRecoveryMetrics } = await import('../RecoveryIncidentsApi');
await updateIncidentStatus(incident.id, text);
fetchIncidents();
getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => { });
setSuccessMsg(`Incident status updated to ${text} successfully.`);
} catch (error) {
console.error("Failed to update status", error);
setError("Failed to update status. Please try again.");
}
};
@@ -495,6 +511,20 @@ export default function RecoveryIncidentsList() {
return (
<div className="w-full flex flex-col gap-6">
{error && (
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError(null)}
/>
)}
{successMsg && (
<CustomAlertBanner
message={successMsg}
type="success"
onClose={() => setSuccessMsg(null)}
/>
)}
{/* Metrics Row */}
<div className="flex gap-4 w-full">
{metrics.map((metric) => (
@@ -575,6 +605,32 @@ export default function RecoveryIncidentsList() {
setEditingIncident(null);
fetchIncidents(); // Refresh list
}}
onSuccess={(createdCount, flightNumber, passengerName) => {
setSuccessModalData({
isOpen: true,
count: createdCount,
flightNumber,
passengerName,
});
}}
/>
{/* Success Modal */}
<CustomSuccessModal
isOpen={successModalData.isOpen}
onClose={() => setSuccessModalData((prev) => ({ ...prev, isOpen: false }))}
title={
successModalData.count > 1
? "Recovery Incidents Created."
: "Recovery Incident Created."
}
label="FLIGHT & PASSENGER DETAILS"
cohortName={`Flight ${successModalData.flightNumber}`}
cohortDescription={
successModalData.count > 1
? `Successfully logged incidents for ${successModalData.count} passengers, including ${successModalData.passengerName}.`
: `Successfully logged incident for ${successModalData.passengerName}.`
}
/>
</div>
);
+82 -28
View File
@@ -1,5 +1,11 @@
import React from "react";
import { MagnifyingGlassIcon, CaretLeftIcon, CaretRightIcon, FunnelSimpleIcon, ArrowsDownUpIcon } from "@phosphor-icons/react";
import {
MagnifyingGlassIcon,
CaretLeftIcon,
CaretRightIcon,
FunnelSimpleIcon,
ArrowsDownUpIcon,
} from "@phosphor-icons/react";
import CustomInput from "./CustomInput";
export interface Column<T> {
@@ -53,7 +59,6 @@ export function CustomTable<T>({
onRowClick,
rowClassName,
}: CustomTableProps<T>) {
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages && onPageChange) {
onPageChange(newPage);
@@ -61,16 +66,43 @@ export function CustomTable<T>({
};
const getPageNumbers = () => {
const pages = [];
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
const pages: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 4) {
pages.push(1, 2, 3, 4, 5, "...", totalPages);
} else if (currentPage >= totalPages - 3) {
pages.push(
1,
"...",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
);
} else {
pages.push(
1,
"...",
currentPage - 1,
currentPage,
currentPage + 1,
"...",
totalPages,
);
}
}
return pages;
};
return (
<div className="w-full flex flex-col bg-white rounded-[14px] shadow-sm border border-gray-100 overflow-hidden">
{/* Top Header Section */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<div className="flex items-center gap-4 flex-1">
@@ -89,9 +121,7 @@ export function CustomTable<T>({
{leftHeaderActions}
</div>
<div className="flex items-center gap-3">
{rightHeaderActions}
</div>
<div className="flex items-center gap-3">{rightHeaderActions}</div>
</div>
{/* Table Section */}
@@ -106,8 +136,18 @@ export function CustomTable<T>({
>
<div className="flex items-center gap-2">
{col.header}
{col.sortable && <ArrowsDownUpIcon size={14} className="cursor-pointer hover:text-gray-900" />}
{col.filterable && <FunnelSimpleIcon size={14} className="cursor-pointer hover:text-gray-900" />}
{col.sortable && (
<ArrowsDownUpIcon
size={14}
className="cursor-pointer hover:text-gray-900"
/>
)}
{col.filterable && (
<FunnelSimpleIcon
size={14}
className="cursor-pointer hover:text-gray-900"
/>
)}
</div>
</th>
))}
@@ -122,7 +162,10 @@ export function CustomTable<T>({
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""} ${rowClassName ? rowClassName(row) : ""}`}
>
{columns.map((col, colIndex) => (
<td key={colIndex} className={`py-5 px-6 ${col.className || ''}`}>
<td
key={colIndex}
className={`py-5 px-6 ${col.className || ""}`}
>
{typeof col.accessor === "function"
? col.accessor(row)
: (row[col.accessor] as React.ReactNode)}
@@ -132,7 +175,10 @@ export function CustomTable<T>({
))
) : (
<tr>
<td colSpan={columns.length} className="py-12 text-center text-gray-500 text-sm">
<td
colSpan={columns.length}
className="py-12 text-center text-gray-500 text-sm"
>
No {itemName} found.
</td>
</tr>
@@ -144,7 +190,8 @@ export function CustomTable<T>({
{/* Pagination Footer */}
<div className="flex items-center justify-between py-4 px-6 bg-[#F3F6F5] border-t border-[#E4E9F2]">
<div className="text-[13px] font-medium text-gray-500">
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of {totalItems} {itemName}
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of{" "}
{totalItems} {itemName}
</div>
<div className="flex items-center gap-2">
@@ -157,20 +204,27 @@ export function CustomTable<T>({
</button>
<div className="flex items-center gap-1">
{getPageNumbers().map(page => (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`w-8 h-8 flex items-center justify-center rounded-lg text-sm font-semibold transition-colors
${currentPage === page
? "bg-gradient-to-b from-primary to-primary-dark text-white"
: "bg-white text-[#9FACA1] border border-[#9FACA1] hover:bg-gray-50 hover:text-gray-900"
}
`}
>
{page}
</button>
))}
{getPageNumbers().map((page, index) =>
page === "..." ? (
<span key={`ellipsis-${index}`} className="px-2 text-gray-500">
...
</span>
) : (
<button
key={index}
onClick={() => handlePageChange(page as number)}
className={`w-8 h-8 flex items-center justify-center rounded-lg text-sm font-semibold transition-colors
${
currentPage === page
? "bg-[#1E7D5C] text-white border-none"
: "bg-white text-[#9FACA1] border border-[#9FACA1] hover:bg-gray-50 hover:text-gray-900"
}
`}
>
{page}
</button>
),
)}
</div>
<button
+3 -2
View File
@@ -24,7 +24,7 @@ import Skeleton from "./CustomSkeleton";
import CustomTimePicker from "./CustomTimePicker";
import CustomAccordionSection from "./CustomAccordionSection";
import CustomFullModal from "./CustomFullModal";
import CustomSuccessModal from "./CustomSuccessModal";
export {
CustomInput,
CustomTextArea,
@@ -51,5 +51,6 @@ export {
CustomAlertBanner,
Skeleton,
CustomTimePicker,
CustomAccordionSection
CustomAccordionSection,
CustomSuccessModal
};