Merge pull request 'development' (#51) from development into test

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/51
This commit is contained in:
Syed Waseem khadri Rafai
2026-08-21 09:57:59 +00:00
20 changed files with 1173 additions and 536 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}
@@ -1,4 +1,4 @@
import { XCircleIcon } from '@phosphor-icons/react';
import { useEffect } from 'react';
import {
CustomModal,
@@ -7,6 +7,7 @@ import {
CustomTextArea,
CustomSwitch,
CustomButton,
CustomAlertBanner,
} from '../../../../components/custom';
import type { ActionCategory, ActionType, ActionTypeFormData } from '../ActionBuilderTypes';
@@ -17,6 +18,7 @@ interface ActionTypeFormModalProps {
formData: ActionTypeFormData;
setFormData: React.Dispatch<React.SetStateAction<ActionTypeFormData>>;
error: string | null;
setError?: (err: string | null) => void;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
@@ -28,9 +30,21 @@ export function ActionTypeFormModal({
formData,
setFormData,
error,
setError,
onClose,
onSubmit,
}: ActionTypeFormModalProps) {
useEffect(() => {
if (!error || !setError) return;
if (error === 'Please select a Category.' && formData.categoryId) {
setError(null);
} else if (error === 'Action Type Name is required.' && formData.name.trim()) {
setError(null);
} else if (error === 'Action Type Code is required.' && formData.code.trim()) {
setError(null);
}
}, [formData.categoryId, formData.name, formData.code, error, setError]);
return (
<CustomModal
isOpen={isOpen}
@@ -40,13 +54,15 @@ export function ActionTypeFormModal({
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError?.(null)}
autoClose={false}
/>
)}
<form onSubmit={onSubmit} className="space-y-5 pt-1">
<form onSubmit={onSubmit} noValidate className="space-y-5 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
@@ -55,7 +71,12 @@ export function ActionTypeFormModal({
<CustomDropdown
options={categories.map((c) => ({ label: `${c.name} (${c.code})`, value: c.id }))}
value={formData.categoryId}
onChange={(val) => setFormData((prev) => ({ ...prev, categoryId: val }))}
onChange={(val) => {
setFormData((prev) => ({ ...prev, categoryId: val }));
if (setError && val) {
setError(null);
}
}}
placeholder="Select Category"
/>
</div>
@@ -73,6 +94,9 @@ export function ActionTypeFormModal({
name: nameVal,
code: editingType ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '_'),
}));
if (setError && nameVal.trim()) {
setError(null);
}
}}
placeholder="e.g. Award Miles"
/>
@@ -84,7 +108,13 @@ export function ActionTypeFormModal({
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
onChange={(e) => {
const codeVal = e.target.value;
setFormData((prev) => ({ ...prev, code: codeVal }));
if (setError && codeVal.trim()) {
setError(null);
}
}}
placeholder="e.g. award_miles"
/>
</div>
@@ -88,9 +88,23 @@ export function ActionTypesColumn({
: 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800'
}`}
>
<div className="pr-3 space-y-0.5 min-w-0">
<div className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{t.name}
<div className="pr-3 space-y-0.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{t.name}
</span>
<span
title={t.isActive !== false ? 'Active' : 'Inactive'}
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isSelected
? t.isActive !== false
? 'bg-emerald-300 ring-2 ring-white/30'
: 'bg-rose-300 ring-2 ring-rose-400/40'
: t.isActive !== false
? 'bg-[#1E7D5C]'
: 'bg-rose-500'
}`}
/>
</div>
<div className={`font-mono text-[11px] uppercase tracking-wider ${isSelected ? 'text-white/80' : 'text-slate-400'}`}>
{t.code}
@@ -75,13 +75,26 @@ export function CategoriesColumn({
key={cat.id}
onClick={() => onSelectCategory(cat.id)}
className={`group relative p-3.5 rounded-[14px] flex items-center justify-between cursor-pointer transition-all duration-150 ${isSelected
? 'bg-[#1E7D5C] text-white shadow-sm'
: 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800'
? 'bg-[#1E7D5C] text-white shadow-sm'
: 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800'
}`}
>
<div className="pr-3 space-y-0.5 min-w-0">
<div className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{cat.name}
<div className="pr-3 space-y-1 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{cat.name}
</span>
<span
title={cat.isActive !== false ? 'Active' : 'Inactive'}
className={`w-2 h-2 rounded-full flex-shrink-0 ${isSelected
? cat.isActive !== false
? 'bg-emerald-300 ring-2 ring-white/30'
: 'bg-rose-300 ring-2 ring-rose-400/40'
: cat.isActive !== false
? 'bg-[#1E7D5C]'
: 'bg-rose-500'
}`}
/>
</div>
<div className={`font-mono text-[11px] uppercase tracking-wider ${isSelected ? 'text-white/80' : 'text-slate-400'}`}>
{cat.code}
@@ -114,8 +127,8 @@ export function CategoriesColumn({
{/* Count Badge */}
<div
className={`w-7 h-7 rounded-full font-bold text-[12px] flex items-center justify-center ${isSelected
? 'bg-white/20 text-white'
: 'bg-[#E8F3EF] text-[#1E7D5C]'
? 'bg-white/20 text-white'
: 'bg-[#E8F3EF] text-[#1E7D5C]'
}`}
>
{childCount}
@@ -1,4 +1,4 @@
import { XCircleIcon } from '@phosphor-icons/react';
import { useEffect } from 'react';
import {
CustomModal,
@@ -6,6 +6,7 @@ import {
CustomTextArea,
CustomSwitch,
CustomButton,
CustomAlertBanner,
} from '../../../../components/custom';
import type { ActionCategory, ActionCategoryFormData } from '../ActionBuilderTypes';
@@ -15,6 +16,7 @@ interface CategoryFormModalProps {
formData: ActionCategoryFormData;
setFormData: React.Dispatch<React.SetStateAction<ActionCategoryFormData>>;
error: string | null;
setError?: (err: string | null) => void;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
@@ -25,9 +27,19 @@ export function CategoryFormModal({
formData,
setFormData,
error,
setError,
onClose,
onSubmit,
}: CategoryFormModalProps) {
useEffect(() => {
if (!error || !setError) return;
if (error === 'Category Name is required.' && formData.name.trim()) {
setError(null);
} else if (error === 'Category Code is required.' && formData.code.trim()) {
setError(null);
}
}, [formData.name, formData.code, error, setError]);
return (
<CustomModal
isOpen={isOpen}
@@ -37,13 +49,15 @@ export function CategoryFormModal({
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError?.(null)}
autoClose={false}
/>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<form onSubmit={onSubmit} noValidate className="space-y-4 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
@@ -58,6 +72,9 @@ export function CategoryFormModal({
name: nameVal,
code: editingCategory ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'),
}));
if (setError && nameVal.trim()) {
setError(null);
}
}}
placeholder="e.g. Refunds"
/>
@@ -69,7 +86,13 @@ export function CategoryFormModal({
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
onChange={(e) => {
const codeVal = e.target.value;
setFormData((prev) => ({ ...prev, code: codeVal }));
if (setError && codeVal.trim()) {
setError(null);
}
}}
placeholder="e.g. refunds"
/>
</div>
@@ -1,4 +1,4 @@
import { XCircleIcon } from '@phosphor-icons/react';
import { useEffect } from 'react';
import {
CustomModal,
@@ -7,6 +7,7 @@ import {
CustomTextArea,
CustomSwitch,
CustomButton,
CustomAlertBanner,
} from '../../../../components/custom';
import type {
FieldDefinition,
@@ -52,6 +53,7 @@ interface FieldDefinitionFormModalProps {
formData: FieldDefinitionFormData;
setFormData: React.Dispatch<React.SetStateAction<FieldDefinitionFormData>>;
error: string | null;
setError?: (err: string | null) => void;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
@@ -64,9 +66,24 @@ export function FieldDefinitionFormModal({
formData,
setFormData,
error,
setError,
onClose,
onSubmit,
}: FieldDefinitionFormModalProps) {
useEffect(() => {
if (!error || !setError) return;
if (error === 'Field Name is required.' && formData.fieldName.trim()) {
setError(null);
} else if (error === 'Field Code is required.' && formData.fieldCode.trim()) {
setError(null);
} else if (
error === 'Master Data Lookup Source is required for Dropdown or Multi-Select control types.' &&
formData.lookupSource?.trim()
) {
setError(null);
}
}, [formData.fieldName, formData.fieldCode, formData.lookupSource, error, setError]);
const dependentOptions = [
{ label: 'None (No Dependency)', value: '' },
...fields
@@ -86,187 +103,187 @@ export function FieldDefinitionFormModal({
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError?.(null)}
autoClose={false}
/>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<form onSubmit={onSubmit} noValidate className="space-y-4 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Label / Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldName}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
fieldName: val,
fieldCode: editingField ? prev.fieldCode : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Field Name"
/>
</div>
<CustomInput
label="Field Label / Name"
required
value={formData.fieldName}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
fieldName: val,
fieldCode: editingField ? prev.fieldCode : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
}));
if (setError && val.trim()) {
setError(null);
}
}}
placeholder="e.g. Field Name"
/>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Code (JSON Key) <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldCode}
onChange={(e) => setFormData((prev) => ({ ...prev, fieldCode: e.target.value }))}
placeholder="e.g. field_code"
/>
</div>
<CustomInput
label="Field Code (JSON Key)"
required
value={formData.fieldCode}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({ ...prev, fieldCode: val }));
if (setError && val.trim()) {
setError(null);
}
}}
placeholder="e.g. field_code"
/>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Control Type <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={FIELD_TYPE_OPTIONS}
value={formData.fieldType}
onChange={(val) => setFormData((prev) => ({ ...prev, fieldType: val as FieldType }))}
placeholder="Select Control Type..."
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Master Data Lookup Source
</label>
<CustomDropdown
options={masterLookupOptions}
value={formData.lookupSource || ''}
onChange={(val) => setFormData((prev) => ({ ...prev, lookupSource: val }))}
placeholder="Select Lookup Source..."
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Form Width Layout
</label>
<CustomDropdown
options={WIDTH_OPTIONS}
value={formData.width}
onChange={(val) => setFormData((prev) => ({ ...prev, width: val as FieldWidth }))}
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Section Group Name
</label>
<CustomInput
value={formData.section || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, section: e.target.value }))}
placeholder="e.g. General Information"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Placeholder</label>
<CustomInput
value={formData.placeholder || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, placeholder: e.target.value }))}
placeholder="e.g. Enter value..."
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Default Value</label>
<CustomInput
value={formData.defaultValue || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))}
placeholder="e.g. Default"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Help Text / Instructions</label>
<CustomTextArea
value={formData.helpText || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, helpText: e.target.value }))}
placeholder="e.g. Instructions for end users filling this field"
rows={2}
<CustomDropdown
label="Control Type"
required
options={FIELD_TYPE_OPTIONS}
value={formData.fieldType}
onChange={(val) => {
const newType = val as FieldType;
const isLookupType = newType === 'dropdown' || newType === 'multi_select';
setFormData((prev) => ({
...prev,
fieldType: newType,
lookupSource: isLookupType ? prev.lookupSource : undefined,
}));
if (setError && !isLookupType) {
setError(null);
}
}}
placeholder="Select Control Type..."
/>
</div>
<div
className={`grid grid-cols-1 ${formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select'
? 'md:grid-cols-3'
: 'md:grid-cols-2'
} gap-4`}
>
{(formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select') && (
<CustomDropdown
label="Master Data Lookup Source"
required
options={masterLookupOptions}
value={formData.lookupSource || ''}
onChange={(val) => {
setFormData((prev) => ({ ...prev, lookupSource: val }));
if (setError && val?.trim()) {
setError(null);
}
}}
placeholder="Select Lookup Source..."
/>
)}
<CustomDropdown
label="Form Width Layout"
options={WIDTH_OPTIONS}
value={formData.width}
onChange={(val) => setFormData((prev) => ({ ...prev, width: val as FieldWidth }))}
/>
<CustomInput
label="Section Group Name"
value={formData.section || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, section: e.target.value }))}
placeholder="e.g. General Information"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<CustomInput
label="Placeholder"
value={formData.placeholder || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, placeholder: e.target.value }))}
placeholder="e.g. Enter value..."
/>
<CustomInput
label="Default Value"
value={formData.defaultValue || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))}
placeholder="e.g. Default"
/>
<CustomInput
label="Display Order"
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))}
/>
</div>
<CustomTextArea
label="Help Text / Instructions"
value={formData.helpText || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, helpText: e.target.value }))}
placeholder="e.g. Instructions for end users filling this field"
rows={2}
/>
{/* Validation JSON Rules */}
<div className="p-4 bg-slate-50 border border-slate-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-slate-800">Validation Rules (validation_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Min Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
min: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 312"
/>
</div>
<CustomInput
label="Min Value / Length"
type="number"
value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
min: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 312"
/>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Max Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
max: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 245"
/>
</div>
<CustomInput
label="Max Value / Length"
type="number"
value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
max: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 245"
/>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Regex Pattern</label>
<CustomInput
value={formData.validationJson?.regex || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
regex: e.target.value,
},
}))
}
placeholder="e.g. ^[A-Z0-9]+$"
/>
</div>
<CustomInput
label="Regex Pattern"
value={formData.validationJson?.regex || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
regex: e.target.value,
},
}))
}
placeholder="e.g. ^[A-Z0-9]+$"
/>
</div>
</div>
@@ -274,61 +291,55 @@ export function FieldDefinitionFormModal({
<div className="p-4 bg-amber-50/60 border border-amber-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-amber-900">Conditional Visibility (visibility_condition_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Depends on Field Code</label>
<CustomDropdown
options={dependentOptions}
value={formData.visibilityConditionJson?.field || ''}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { operator: 'equals', value: '' }),
field: val,
},
}))
}
placeholder="Select dependent field..."
/>
</div>
<CustomDropdown
label="Depends on Field Code"
options={dependentOptions}
value={formData.visibilityConditionJson?.field || ''}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { operator: 'equals', value: '' }),
field: val,
},
}))
}
placeholder="Select dependent field..."
/>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Operator</label>
<CustomDropdown
options={[
{ label: 'Equals', value: 'equals' },
{ label: 'Not Equals', value: 'not_equals' },
{ label: 'Contains', value: 'contains' },
]}
value={formData.visibilityConditionJson?.operator || 'equals'}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', value: '' }),
operator: val as any,
},
}))
}
/>
</div>
<CustomDropdown
label="Operator"
options={[
{ label: 'Equals', value: 'equals' },
{ label: 'Not Equals', value: 'not_equals' },
{ label: 'Contains', value: 'contains' },
]}
value={formData.visibilityConditionJson?.operator || 'equals'}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', value: '' }),
operator: val as any,
},
}))
}
/>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Target Value</label>
<CustomInput
value={formData.visibilityConditionJson?.value || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', operator: 'equals' }),
value: e.target.value,
},
}))
}
placeholder="e.g. DGYRHTFUJKYHUSDERTYUIK"
/>
</div>
<CustomInput
label="Target Value"
value={formData.visibilityConditionJson?.value || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', operator: 'equals' }),
value: e.target.value,
},
}))
}
placeholder="e.g. DGYRHTFUJKYHUSDERTYUIK"
/>
</div>
</div>
@@ -353,6 +364,7 @@ export function FieldDefinitionFormModal({
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
+22 -3
View File
@@ -431,6 +431,13 @@ export default function ActionBuilderPage() {
setFieldError('Field Code is required.');
return;
}
if (
(fieldFormData.fieldType === 'dropdown' || fieldFormData.fieldType === 'multi_select') &&
!fieldFormData.lookupSource?.trim()
) {
setFieldError('Master Data Lookup Source is required for Dropdown or Multi-Select control types.');
return;
}
const payloadData: FieldDefinitionFormData = {
...fieldFormData,
@@ -587,7 +594,11 @@ export default function ActionBuilderPage() {
formData={categoryFormData}
setFormData={setCategoryFormData}
error={categoryError}
onClose={() => setIsCategoryModalOpen(false)}
setError={setCategoryError}
onClose={() => {
setCategoryError(null);
setIsCategoryModalOpen(false);
}}
onSubmit={handleSaveCategory}
/>
@@ -598,7 +609,11 @@ export default function ActionBuilderPage() {
formData={typeFormData}
setFormData={setTypeFormData}
error={typeError}
onClose={() => setIsTypeModalOpen(false)}
setError={setTypeError}
onClose={() => {
setTypeError(null);
setIsTypeModalOpen(false);
}}
onSubmit={handleSaveType}
/>
@@ -610,7 +625,11 @@ export default function ActionBuilderPage() {
formData={fieldFormData}
setFormData={setFieldFormData}
error={fieldError}
onClose={() => setIsFieldModalOpen(false)}
setError={setFieldError}
onClose={() => {
setFieldError(null);
setIsFieldModalOpen(false);
}}
onSubmit={handleSaveField}
/>
@@ -1,10 +1,10 @@
import React from 'react';
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomSwitch,
CustomButton,
CustomAlertBanner,
} from '../../../../components/custom';
import type {
MasterDataCategoryItem,
@@ -21,6 +21,7 @@ interface MasterItemFormModalProps {
setFormData: React.Dispatch<React.SetStateAction<MasterDataValueFormData>>;
onSubmit: (e: React.FormEvent) => void;
errorMsg: string | null;
setErrorMsg?: (err: string | null) => void;
}
export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
@@ -32,7 +33,15 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
setFormData,
onSubmit,
errorMsg,
setErrorMsg,
}) => {
React.useEffect(() => {
if (!errorMsg || !setErrorMsg) return;
if (errorMsg === 'Value / Name is required.' && formData.value.trim()) {
setErrorMsg(null);
}
}, [formData.value, errorMsg, setErrorMsg]);
return (
<CustomModal
isOpen={isOpen}
@@ -46,13 +55,15 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
size="md"
>
{errorMsg && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{errorMsg}
</div>
<CustomAlertBanner
message={errorMsg}
type="error"
onClose={() => setErrorMsg?.(null)}
autoClose={false}
/>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<form onSubmit={onSubmit} noValidate className="space-y-4 pt-1">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Item Value / Name <span className="text-red-500">*</span>
@@ -66,6 +77,9 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
value: val,
code: editingItem ? prev.code : val.toLowerCase().trim().replace(/[^a-z0-9_]+/g, '_'),
}));
if (setErrorMsg && val.trim()) {
setErrorMsg(null);
}
}}
placeholder="e.g. Platinum Tier / USD"
/>
@@ -78,7 +92,13 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
</label>
<CustomInput
value={formData.code || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
onChange={(e) => {
const codeVal = e.target.value;
setFormData((prev) => ({ ...prev, code: codeVal }));
if (setErrorMsg && codeVal.trim()) {
setErrorMsg(null);
}
}}
placeholder="e.g. platinum_tier"
/>
</div>
@@ -247,6 +247,7 @@ export default function MasterDataManagement() {
setFormData={setFormData}
onSubmit={handleSubmit}
errorMsg={errorMsg}
setErrorMsg={setErrorMsg}
/>
{/* Delete Confirmation Modal */}
+6
View File
@@ -26,6 +26,12 @@ export interface ActionTypeField {
section?: string;
displayOrder?: number;
isActive: boolean;
validationJson?: any;
visibilityConditionJson?: {
field: string;
operator: 'equals' | 'not_equals' | 'contains';
value: any;
};
}
// ─── Local Direct Api Helpers for Self-Containment ───────────────────────────
@@ -15,8 +15,8 @@ import {
CustomSwitch,
CustomCheckBox,
CustomLoader,
Skeleton,
} from '../../../components/custom';
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
import {
getJurisdictionOptions,
getCohortOptions,
@@ -44,8 +44,113 @@ const WIDTH_GRID_MAP: Record<string, string> = {
two_thirds: 'col-span-12 md:col-span-8',
};
function parseVisibilityCondition(visCond: any): { field?: string; operator?: string; value?: any } | null {
if (!visCond) return null;
if (typeof visCond === 'string') {
try {
return JSON.parse(visCond);
} catch {
return null;
}
}
if (typeof visCond === 'object') {
return visCond;
}
return null;
}
function isActionFieldVisible(
field: ActionTypeField,
fieldValues?: Record<string, any>,
allFields?: ActionTypeField[],
fieldLookupOptionsMap?: Record<string, OptionItem[]>
): boolean {
const visCond = parseVisibilityCondition(
field.visibilityConditionJson || (field as any).visibility_condition_json
);
if (!visCond || !visCond.field) return true;
const targetFieldCode = visCond.field;
const operator = visCond.operator || 'equals';
const expectedValue = visCond.value;
const targetField = allFields?.find((f) => f.fieldCode === targetFieldCode);
const rawActual =
fieldValues?.[targetFieldCode] !== undefined && fieldValues?.[targetFieldCode] !== null
? fieldValues[targetFieldCode]
: targetField?.defaultValue;
const actualValue =
rawActual && typeof rawActual === 'object' && 'amount' in rawActual
? rawActual.amount
: rawActual;
const candidateValues: string[] = [];
if (actualValue !== undefined && actualValue !== null) {
if (Array.isArray(actualValue)) {
actualValue.forEach((v) => candidateValues.push(String(v)));
} else {
candidateValues.push(String(actualValue));
}
if (fieldLookupOptionsMap) {
Object.values(fieldLookupOptionsMap).forEach((opts) => {
opts.forEach((opt) => {
if (
candidateValues.some(
(c) =>
c.toLowerCase() === String(opt.value || '').toLowerCase() ||
c.toLowerCase() === String(opt.id || '').toLowerCase() ||
c.toLowerCase() === String(opt.code || '').toLowerCase() ||
c.toLowerCase() === String(opt.label || '').toLowerCase()
)
) {
if (opt.value) candidateValues.push(String(opt.value));
if (opt.code) candidateValues.push(String(opt.code));
if (opt.label) candidateValues.push(String(opt.label));
if (opt.id) candidateValues.push(String(opt.id));
}
});
});
}
}
const normalizeStr = (s: any) =>
String(s ?? '')
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '');
const normExpected = normalizeStr(expectedValue);
if (operator === 'equals') {
if (typeof expectedValue === 'boolean') {
return Boolean(actualValue) === expectedValue;
}
if (expectedValue === '' || expectedValue === undefined || expectedValue === null) {
return actualValue === '' || actualValue === undefined || actualValue === null;
}
return candidateValues.some((c) => normalizeStr(c) === normExpected);
}
if (operator === 'not_equals') {
if (typeof expectedValue === 'boolean') {
return Boolean(actualValue) !== expectedValue;
}
return !candidateValues.some((c) => normalizeStr(c) === normExpected);
}
if (operator === 'contains') {
if (typeof expectedValue === 'boolean') {
return Boolean(actualValue) === expectedValue;
}
return candidateValues.some(
(c) => normalizeStr(c).includes(normExpected) || normExpected.includes(normalizeStr(c))
);
}
return true;
}
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -152,7 +257,6 @@ export default function AddPolicyEngine() {
const [loadingPolicy, setLoadingPolicy] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
// Dynamic Options State (loaded directly from PolicyEngineApi)
const [jurisdictionOptions, setJurisdictionOptions] = useState<OptionItem[]>([]);
@@ -337,7 +441,7 @@ export default function AddPolicyEngine() {
// Policy Info State
const [policyName, setPolicyName] = useState('');
const [jurisdiction, setJurisdiction] = useState<(string | number)[]>([]);
const [status, setStatus] = useState<'Active' | 'Inactive'>('Active');
const [status, setStatus] = useState<'Active' | 'Inactive' | 'Draft'>('Active');
const [description, setDescription] = useState('');
// Target Audience State
@@ -380,7 +484,8 @@ export default function AddPolicyEngine() {
setJurisdiction(singleJur ? [singleJur] : []);
}
setStatus(
data.status?.toLowerCase() === 'active' ? 'Active' : 'Inactive'
data.status?.toLowerCase() === 'active' ? 'Active' :
data.status?.toLowerCase() === 'draft' ? 'Draft' : 'Inactive'
);
setDescription(data.description || '');
@@ -531,9 +636,49 @@ export default function AddPolicyEngine() {
.finally(() => setLoadingPolicy(false));
}, [policyId]);
const isFormValid = (() => {
if (!policyName.trim()) return false;
if (audienceType === 'Selected Cohorts' && (!selectedCohorts || selectedCohorts.length === 0)) return false;
if (!rules || rules.length === 0) return false;
for (const rule of rules) {
if (!rule.category) return false;
if (!rule.conditions || rule.conditions.length === 0) return false;
for (const cond of rule.conditions) {
if (!cond.condition || !cond.operator || cond.value === '' || cond.value === undefined || cond.value === null) return false;
}
if (!rule.actions || rule.actions.length === 0) return false;
for (const act of rule.actions) {
if (!act.actionCategoryId || !act.actionTypeId) return false;
const fields = actionTypeFieldsMap[act.actionTypeId] || [];
for (const field of fields) {
if (field.isRequired) {
const val = act.fieldValues?.[field.fieldCode];
if (val === undefined || val === null || val === '') return false;
if (Array.isArray(val) && val.length === 0) return false;
if (typeof val === 'object' && !Array.isArray(val)) {
if (val.amount === undefined || val.amount === null || val.amount === '') return false;
}
}
}
}
}
return true;
})();
const isDraftValid = policyName.trim().length > 0;
const handleSavePolicy = async (isDeploy: boolean) => {
if (!policyName.trim()) {
alert('Please enter a Policy Name.');
if (isDeploy && !isFormValid) {
return;
}
if (!isDeploy && !isDraftValid) {
return;
}
@@ -544,7 +689,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 === 'Draft' ? 'active' : status.toLowerCase()) : 'draft',
audienceType: audienceType === 'Selected Cohorts' ? 'COHORT' : 'ALL',
targetAudiences:
audienceType === 'Selected Cohorts'
@@ -591,19 +736,30 @@ export default function AddPolicyEngine() {
const curr = typeof val === 'object' ? val.currency : undefined;
valObj.numberValue = parseFloat(amt) || 0;
if (curr) {
valObj.currencyCodeId = curr;
const lookupOpts =
fieldLookupOptionsMap[fieldDef?.lookupSource || 'currency'] ||
fieldLookupOptionsMap['currency'] ||
[];
const matchedCurr = lookupOpts.find(
(o) => o.value === curr || o.id === curr || o.code === curr
);
valObj.currencyCodeId =
matchedCurr?.id ||
(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(curr))
? String(curr)
: undefined);
}
} else if (fieldType === 'checkbox' || fieldType === 'switch' || typeof val === 'boolean') {
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 ?? '');
}
@@ -620,7 +776,14 @@ export default function AddPolicyEngine() {
await createPolicy(payload);
}
setShowSuccessModal(true);
let actionWord = isDeploy ? 'created' : 'saved as draft';
if (isEditMode) {
actionWord = isDeploy ? 'updated' : 'saved as draft';
}
const successMsg = `Policy "${payload.policyName}" ${actionWord} successfully!`;
navigate('/policy-engine', { state: { successMsg } });
} catch (err) {
console.error('Failed to save policy', err);
alert('Failed to save policy. Please check input values and try again.');
@@ -759,8 +922,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>
);
}
@@ -808,6 +1045,7 @@ export default function AddPolicyEngine() {
<div className="flex flex-col gap-2">
<CustomMultiSelect
label='Jurisdiction'
required
options={jurisdictionOptions}
value={jurisdiction}
onChange={setJurisdiction}
@@ -815,7 +1053,7 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-[13px] font-semibold text-gray-700">Status</label>
<label className="text-[13px] font-semibold text-gray-700">Status<span className="text-red-500 ml-1">*</span></label>
<div className="flex items-center gap-6 h-11">
<CustomRadio
name="status"
@@ -836,6 +1074,7 @@ export default function AddPolicyEngine() {
<div className="flex flex-col gap-2">
<CustomTextArea
label='Description'
required
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description..."
@@ -868,6 +1107,7 @@ export default function AddPolicyEngine() {
<div className="w-1/3 flex flex-col gap-2">
<CustomMultiSelect
label='Cohort'
required
options={cohortOptions}
value={selectedCohorts}
onChange={setSelectedCohorts}
@@ -914,6 +1154,7 @@ export default function AddPolicyEngine() {
<div className="w-1/3 flex flex-col gap-2">
<CustomDropdown
label='Rule Category'
required
options={ruleCategoryOptions}
value={rule.category}
onChange={(val) => handleCategoryChange(rule.id, val)}
@@ -921,7 +1162,7 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-[14px] font-medium leading-none text-[#001811]">Priority</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Priority<span className="text-red-500 ml-1">*</span></label>
{(() => {
const prevPriority = getPrevPriority(rule);
const nextPriority = getNextPriority(rule);
@@ -969,6 +1210,7 @@ export default function AddPolicyEngine() {
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label='Condition'
required
options={categoryConditionMap[rule.category] || []}
value={condition.condition}
onChange={(val) => handleConditionFieldSelect(rule.id, condition.id, val)}
@@ -979,6 +1221,7 @@ export default function AddPolicyEngine() {
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label='Operator'
required
options={operatorOptions}
value={condition.operator}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { operator: val })}
@@ -989,6 +1232,7 @@ export default function AddPolicyEngine() {
{isLookup ? (
<CustomDropdown
label='Value'
required
options={lookupOpts}
value={condition.value}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
@@ -997,6 +1241,7 @@ export default function AddPolicyEngine() {
) : isBoolean ? (
<CustomDropdown
label='Value'
required
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
@@ -1008,6 +1253,7 @@ export default function AddPolicyEngine() {
) : (
<CustomInput
label='Value'
required
type={isNumber ? 'number' : 'text'}
value={condition.value}
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
@@ -1017,7 +1263,7 @@ export default function AddPolicyEngine() {
</div>
{cIdx < rule.conditions.length - 1 ? (
<div className="w-[163px] flex flex-col gap-2">
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic<span className="text-red-500 ml-1">*</span></label>
<LogicDropdown
value={condition.logic}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
@@ -1065,6 +1311,7 @@ export default function AddPolicyEngine() {
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label="Action Category"
required
options={actionCategoryOptions}
value={action.actionCategoryId}
onChange={(val) => handleActionCategoryChange(rule.id, action.id, val)}
@@ -1075,6 +1322,7 @@ export default function AddPolicyEngine() {
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label="Action Type"
required
options={actionTypesByCategoryMap[action.actionCategoryId] || []}
value={action.actionTypeId}
onChange={(val) => handleActionTypeChange(rule.id, action.id, val)}
@@ -1085,7 +1333,7 @@ export default function AddPolicyEngine() {
{aIdx < rule.actions.length - 1 ? (
<div className="w-[163px] flex flex-col gap-2">
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic<span className="text-red-500 ml-1">*</span></label>
<LogicDropdown value={action.logic} readOnly />
</div>
) : (
@@ -1121,109 +1369,136 @@ export default function AddPolicyEngine() {
<div className="py-4 flex justify-center">
<CustomLoader label="Loading Configured Dynamic Fields..." />
</div>
) : (actionTypeFieldsMap[action.actionTypeId] || []).length === 0 ? (
<p className="text-xs text-gray-400 italic">No dynamic fields configured for this Action Type.</p>
) : (
<div className="grid grid-cols-12 gap-4">
{(actionTypeFieldsMap[action.actionTypeId] || []).map((field) => {
const widthClass = WIDTH_GRID_MAP[field.width || 'full'] || 'col-span-12';
const fieldVal = action.fieldValues?.[field.fieldCode];
const lookupOpts = fieldLookupOptionsMap[field.lookupSource || ''] || [];
) : (() => {
const allFields = actionTypeFieldsMap[action.actionTypeId] || [];
const visibleFields = allFields.filter((f) =>
isActionFieldVisible(f, action.fieldValues, allFields, fieldLookupOptionsMap)
);
return (
<div key={field.id} className={widthClass}>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
if (allFields.length === 0) {
return <p className="text-xs text-gray-400 italic">No dynamic fields configured for this Action Type.</p>;
}
{field.fieldType === 'textarea' ? (
<CustomTextArea
value={fieldVal || ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter details...'}
/>
) : field.fieldType === 'currency' ? (
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<CustomInput
type="number"
value={fieldVal?.amount || ''}
onChange={(e) =>
if (visibleFields.length === 0) {
return null;
}
return (
<div className="grid grid-cols-12 gap-4">
{visibleFields.map((field) => {
const widthClass = WIDTH_GRID_MAP[field.width || 'full'] || 'col-span-12';
const fieldVal = action.fieldValues?.[field.fieldCode];
const lookupOpts = fieldLookupOptionsMap[field.lookupSource || ''] || [];
const helpText = field.helpText || (field as any).help_text;
return (
<div key={field.id} className={widthClass}>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{field.fieldType === 'textarea' ? (
<CustomTextArea
value={fieldVal || ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter details...'}
/>
) : field.fieldType === 'currency' ? (
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<CustomInput
type="number"
value={fieldVal?.amount || ''}
onChange={(e) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}),
amount: e.target.value,
})
}
placeholder={field.placeholder || 'Amount'}
/>
</div>
<CustomDropdown
options={lookupOpts}
value={fieldVal?.currency}
onChange={(val) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}),
amount: e.target.value,
currency: val,
})
}
placeholder={field.placeholder || 'Amount'}
/>
</div>
) : field.fieldType === 'dropdown' ? (
<CustomDropdown
options={lookupOpts}
value={fieldVal?.currency}
onChange={(val) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}),
currency: val,
})
}
value={fieldVal || ''}
onChange={(val) => handleFieldValueChange(rule.id, action.id, field.fieldCode, val)}
placeholder={field.placeholder || 'Select option...'}
/>
</div>
) : field.fieldType === 'dropdown' ? (
<CustomDropdown
options={lookupOpts}
value={fieldVal || ''}
onChange={(val) => handleFieldValueChange(rule.id, action.id, field.fieldCode, val)}
placeholder={field.placeholder || 'Select option...'}
/>
) : field.fieldType === 'multi_select' ? (
<CustomMultiSelect
options={lookupOpts}
value={Array.isArray(fieldVal) ? fieldVal : []}
onChange={(vals) => handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)}
placeholder={field.placeholder || 'Select multiple options...'}
/>
) : field.fieldType === 'checkbox' ? (
<CustomCheckBox
checked={!!fieldVal}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
label={field.helpText || field.fieldName}
/>
) : field.fieldType === 'switch' ? (
<div className="flex items-center gap-3 pt-1">
<CustomSwitch
) : field.fieldType === 'multi_select' ? (
<CustomMultiSelect
options={lookupOpts}
value={Array.isArray(fieldVal) ? fieldVal : []}
onChange={(vals) => handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)}
placeholder={field.placeholder || 'Select multiple options...'}
/>
) : field.fieldType === 'checkbox' ? (
<CustomCheckBox
checked={!!fieldVal}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
label={field.fieldName}
/>
<span className="text-[13px] font-medium text-slate-700">
{fieldVal ? 'Enabled' : 'Disabled'}
</span>
</div>
) : (
<CustomInput
type={
field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage'
? 'number'
: field.fieldType === 'email'
? 'email'
: field.fieldType === 'date'
? 'date'
: field.fieldType === 'time'
? 'time'
: field.fieldType === 'datetime'
? 'datetime-local'
: 'text'
}
value={fieldVal ?? ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter value...'}
/>
)}
</div>
);
})}
</div>
)}
) : field.fieldType === 'switch' ? (
<div className="flex items-center gap-3 pt-1">
<CustomSwitch
checked={!!fieldVal}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
/>
<span className="text-[13px] font-medium text-slate-700">
{fieldVal ? 'Enabled' : 'Disabled'}
</span>
</div>
) : (
<CustomInput
type={
field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage'
? 'number'
: field.fieldType === 'email'
? 'email'
: field.fieldType === 'date'
? 'date'
: field.fieldType === 'time'
? 'time'
: field.fieldType === 'datetime'
? 'datetime-local'
: 'text'
}
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
}
/>
)}
{helpText && (
<p className="text-[11.5px] text-gray-500 mt-1 leading-snug">
{helpText}
</p>
)}
</div>
);
})}
</div>
);
})()}
</div>
)}
</div>
@@ -1247,16 +1522,14 @@ export default function AddPolicyEngine() {
<CustomStatus status={status} />
</div>
<div className="flex items-center gap-3">
{!isEditMode && (
<CustomButton
variant="secondary"
className="!text-[#1E7D5C] !bg-[#E8F3EF] hover:!bg-[#d9ece4] !border-none font-semibold px-6"
onClick={() => handleSavePolicy(false)}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save Draft'}
</CustomButton>
)}
<CustomButton
variant="secondary"
className="!text-[#1E7D5C] !bg-[#E8F3EF] hover:!bg-[#d9ece4] !border-none font-semibold px-6 disabled:opacity-50"
onClick={() => handleSavePolicy(false)}
disabled={isSaving || !isDraftValid}
>
{isSaving ? 'Saving...' : 'Save Draft'}
</CustomButton>
<CustomButton
variant="outlined"
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
@@ -1267,29 +1540,15 @@ export default function AddPolicyEngine() {
</CustomButton>
<CustomButton
variant="primary"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
onClick={() => handleSavePolicy(true)}
disabled={isSaving}
disabled={isSaving || !isFormValid}
>
{isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'}
</CustomButton>
</div>
</div>
{/* ─── Success Modal ──────────────────────────────────────────────── */}
<CustomSuccessModal
isOpen={showSuccessModal}
onClose={() => {
setShowSuccessModal(false);
navigate('/policy-engine');
}}
title={isEditMode ? 'Policy Updated Successfully.' : 'Policy Created Successfully.'}
label="POLICY NAME"
cohortName={policyName}
cohortStatus={status}
cohortDescription={description}
/>
</div>
);
}
@@ -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, useLocation } 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,16 @@ 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);
@@ -56,36 +80,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 +130,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 +175,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 +183,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 +207,7 @@ export default function PolicyEngineList() {
Activate
</CustomActionItem>
)}
{row.status === 'Active' && (
{row.status === "Active" && (
<CustomActionItem
icon={<XIcon size={15} />}
onClick={() => setDeactivateTarget(row)}
@@ -211,15 +257,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 +293,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 +325,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,26 +92,44 @@ export default function RecoveryIncidentsList() {
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getRecoveryMetrics()
.then((data) => setMetrics(data))
.catch((err) => console.error("Failed to fetch recovery metrics:", err));
}, []);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
// Filters
const [search, setSearch] = useState("");
const [isGrouped, setIsGrouped] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [isGrouped, setIsGrouped] = useState(() => {
return sessionStorage.getItem("recovery_isGrouped") === "true";
});
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => {
const saved = sessionStorage.getItem("recovery_collapsedGroups");
return saved ? new Set(JSON.parse(saved)) : new Set();
});
useEffect(() => {
sessionStorage.setItem("recovery_isGrouped", String(isGrouped));
}, [isGrouped]);
useEffect(() => {
sessionStorage.setItem("recovery_collapsedGroups", JSON.stringify(Array.from(collapsedGroups)));
}, [collapsedGroups]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingIncident, setEditingIncident] = useState<RecoveryIncident | null>(null);
// 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 () => {
@@ -117,6 +137,9 @@ export default function RecoveryIncidentsList() {
try {
const data = await getRecoveryIncidents();
setIncidents(data);
getRecoveryMetrics()
.then((m) => setMetrics(m))
.catch((err) => console.error("Failed to fetch recovery metrics:", err));
} catch (error) {
console.error("Failed to fetch recovery incidents", error);
setIncidents([]);
@@ -131,8 +154,13 @@ export default function RecoveryIncidentsList() {
useEffect(() => {
if (incidents.length > 0) {
const keys = new Set(incidents.map((i) => i.flightNumber || "Other"));
setCollapsedGroups(keys);
setCollapsedGroups((prev) => {
const hasSaved = sessionStorage.getItem("recovery_hasSavedGroups");
if (hasSaved) return prev;
sessionStorage.setItem("recovery_hasSavedGroups", "true");
return new Set(incidents.map((i) => i.flightNumber || "Other"));
});
}
}, [incidents]);
@@ -180,12 +208,15 @@ export default function RecoveryIncidentsList() {
const handleStatusChange = async (incident: RecoveryIncident, text: string) => {
try {
const { updateIncidentStatus, getRecoveryMetrics } = await import('../RecoveryIncidentsApi');
setError(null);
setSuccessMsg(null);
const { updateIncidentStatus } = 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 +526,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 +620,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>
);
@@ -75,7 +75,6 @@ const SIMULATION_PASSENGER_POOL: Array<Omit<SimulatedPassenger, 'refund' | 'comp
];
const SCENARIO_OPTIONS: Option[] = [
{ label: 'e.g. Passenger Compensation', value: 'e.g. Passenger Compensation' },
{ label: 'Flight Delay Disruption', value: 'Flight Delay Disruption' },
{ label: 'Flight Cancellation', value: 'Flight Cancellation' },
{ label: 'Denied Boarding / Involuntary Bumping', value: 'Denied Boarding' },
@@ -83,7 +82,6 @@ const SCENARIO_OPTIONS: Option[] = [
];
const SCENARIO_SUBTYPE_OPTIONS: Option[] = [
{ label: 'e.g. Passenger Compensation', value: 'e.g. Passenger Compensation' },
{ label: 'Delay > 3 Hours (Long Haul)', value: 'Delay > 3 Hours' },
{ label: 'Delay > 4 Hours (Standard)', value: 'Delay > 4 Hours' },
{ label: 'Short Notice Cancellation (< 14 Days)', value: 'Short Notice Cancellation' },
+1 -1
View File
@@ -22,7 +22,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
type = "text",
leftIcon,
rightIcon,
maxLength,
maxLength = 100,
phonePrefix,
disabled,
className = "",
+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
+1 -1
View File
@@ -16,7 +16,7 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
label,
leftIcon,
rightIcon,
maxLength,
maxLength = 500,
disabled,
className = "",
rows = 4,
+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
};