diff --git a/src/app/auditLogs/components/AuditLogsList.tsx b/src/app/auditLogs/components/AuditLogsList.tsx index 2022615..e83e7fe 100644 --- a/src/app/auditLogs/components/AuditLogsList.tsx +++ b/src/app/auditLogs/components/AuditLogsList.tsx @@ -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 ( -
+
{text}
); @@ -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: , 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 (
- {time} - {date} + + {time} + + + {date} +
); }, @@ -210,7 +222,8 @@ export default function AuditLogsList() { { header: , accessor: (row) => { - const displayStatus = row.action === 'STATUS_CHANGE' ? 'Status Change' : row.action; + const displayStatus = + row.action === "STATUS_CHANGE" ? "Status Change" : row.action; return ( , accessor: (row) => { - const hash = row.id.split('-')[0].substring(0, 4).toUpperCase(); + const hash = row.id.split("-")[0].substring(0, 4).toUpperCase(); return (
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() {
columns={columns} - data={logs} + data={displayedLogs} leftHeaderActions={
setSearch(e.target.value)} - leftIcon={} + leftIcon={ + + } className="!bg-[#F8F9FA] !rounded-[8px] !h-[40px] !border-none !text-[13px]" containerClassName="!gap-0 border-none" /> @@ -292,7 +316,13 @@ export default function AuditLogsList() { } + leftIcon={ + + } 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]" />
- - setSelectedLog(null)} - /> + setSelectedLog(null)} />
); } diff --git a/src/app/cohartManage/components/cohartList.tsx b/src/app/cohartManage/components/cohartList.tsx index 4ac232a..6acb2a5 100644 --- a/src/app/cohartManage/components/cohartList.tsx +++ b/src/app/cohartManage/components/cohartList.tsx @@ -54,6 +54,7 @@ export default function CohortList() { const [cohorts, setCohorts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(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 && ( + setSuccessMsg(null)} + /> + )} columns={columns} diff --git a/src/app/configuration/actionBuilder/components/ActionTypeFormModal.tsx b/src/app/configuration/actionBuilder/components/ActionTypeFormModal.tsx index 2541f4a..a62285f 100644 --- a/src/app/configuration/actionBuilder/components/ActionTypeFormModal.tsx +++ b/src/app/configuration/actionBuilder/components/ActionTypeFormModal.tsx @@ -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>; 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 ( {error && ( -
- - {error} -
+ setError?.(null)} + autoClose={false} + /> )} -
+
@@ -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({ 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" />
diff --git a/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx b/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx index 88b06d0..6ee8066 100644 --- a/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx +++ b/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx @@ -88,9 +88,23 @@ export function ActionTypesColumn({ : 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800' }`} > -
-
- {t.name} +
+
+ + {t.name} + +
{t.code} diff --git a/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx b/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx index 0ea9b72..b850684 100644 --- a/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx +++ b/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx @@ -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' }`} > -
-
- {cat.name} +
+
+ + {cat.name} + +
{cat.code} @@ -114,8 +127,8 @@ export function CategoriesColumn({ {/* Count Badge */}
{childCount} diff --git a/src/app/configuration/actionBuilder/components/CategoryFormModal.tsx b/src/app/configuration/actionBuilder/components/CategoryFormModal.tsx index 5bebd02..fc8c900 100644 --- a/src/app/configuration/actionBuilder/components/CategoryFormModal.tsx +++ b/src/app/configuration/actionBuilder/components/CategoryFormModal.tsx @@ -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>; 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 ( {error && ( -
- - {error} -
+ setError?.(null)} + autoClose={false} + /> )} - +
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" />
diff --git a/src/app/configuration/actionBuilder/components/FieldDefinitionFormModal.tsx b/src/app/configuration/actionBuilder/components/FieldDefinitionFormModal.tsx index e888c53..2b57fd7 100644 --- a/src/app/configuration/actionBuilder/components/FieldDefinitionFormModal.tsx +++ b/src/app/configuration/actionBuilder/components/FieldDefinitionFormModal.tsx @@ -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>; 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 && ( -
- - {error} -
+ setError?.(null)} + autoClose={false} + /> )} - +
-
- - { - 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" - /> -
+ { + 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" + /> -
- - setFormData((prev) => ({ ...prev, fieldCode: e.target.value }))} - placeholder="e.g. field_code" - /> -
+ { + const val = e.target.value; + setFormData((prev) => ({ ...prev, fieldCode: val })); + if (setError && val.trim()) { + setError(null); + } + }} + placeholder="e.g. field_code" + /> -
- - setFormData((prev) => ({ ...prev, fieldType: val as FieldType }))} - placeholder="Select Control Type..." - /> -
-
- -
-
- - setFormData((prev) => ({ ...prev, lookupSource: val }))} - placeholder="Select Lookup Source..." - /> -
- -
- - setFormData((prev) => ({ ...prev, width: val as FieldWidth }))} - /> -
- -
- - setFormData((prev) => ({ ...prev, section: e.target.value }))} - placeholder="e.g. General Information" - /> -
-
- -
-
- - setFormData((prev) => ({ ...prev, placeholder: e.target.value }))} - placeholder="e.g. Enter value..." - /> -
- -
- - setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))} - placeholder="e.g. Default" - /> -
- -
- - setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))} - /> -
-
- -
- - setFormData((prev) => ({ ...prev, helpText: e.target.value }))} - placeholder="e.g. Instructions for end users filling this field" - rows={2} + { + 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..." />
+
+ {(formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select') && ( + { + setFormData((prev) => ({ ...prev, lookupSource: val })); + if (setError && val?.trim()) { + setError(null); + } + }} + placeholder="Select Lookup Source..." + /> + )} + + setFormData((prev) => ({ ...prev, width: val as FieldWidth }))} + /> + + setFormData((prev) => ({ ...prev, section: e.target.value }))} + placeholder="e.g. General Information" + /> +
+ +
+ setFormData((prev) => ({ ...prev, placeholder: e.target.value }))} + placeholder="e.g. Enter value..." + /> + + setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))} + placeholder="e.g. Default" + /> + + setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))} + /> +
+ + setFormData((prev) => ({ ...prev, helpText: e.target.value }))} + placeholder="e.g. Instructions for end users filling this field" + rows={2} + /> + {/* Validation JSON Rules */}
Validation Rules (validation_json)
-
- - - setFormData((prev) => ({ - ...prev, - validationJson: { - ...prev.validationJson, - min: e.target.value !== '' ? Number(e.target.value) : undefined, - }, - })) - } - placeholder="e.g. 312" - /> -
+ + setFormData((prev) => ({ + ...prev, + validationJson: { + ...prev.validationJson, + min: e.target.value !== '' ? Number(e.target.value) : undefined, + }, + })) + } + placeholder="e.g. 312" + /> -
- - - setFormData((prev) => ({ - ...prev, - validationJson: { - ...prev.validationJson, - max: e.target.value !== '' ? Number(e.target.value) : undefined, - }, - })) - } - placeholder="e.g. 245" - /> -
+ + setFormData((prev) => ({ + ...prev, + validationJson: { + ...prev.validationJson, + max: e.target.value !== '' ? Number(e.target.value) : undefined, + }, + })) + } + placeholder="e.g. 245" + /> -
- - - setFormData((prev) => ({ - ...prev, - validationJson: { - ...prev.validationJson, - regex: e.target.value, - }, - })) - } - placeholder="e.g. ^[A-Z0-9]+$" - /> -
+ + setFormData((prev) => ({ + ...prev, + validationJson: { + ...prev.validationJson, + regex: e.target.value, + }, + })) + } + placeholder="e.g. ^[A-Z0-9]+$" + />
@@ -274,61 +291,55 @@ export function FieldDefinitionFormModal({
Conditional Visibility (visibility_condition_json)
-
- - - setFormData((prev) => ({ - ...prev, - visibilityConditionJson: { - ...(prev.visibilityConditionJson || { operator: 'equals', value: '' }), - field: val, - }, - })) - } - placeholder="Select dependent field..." - /> -
+ + setFormData((prev) => ({ + ...prev, + visibilityConditionJson: { + ...(prev.visibilityConditionJson || { operator: 'equals', value: '' }), + field: val, + }, + })) + } + placeholder="Select dependent field..." + /> -
- - - setFormData((prev) => ({ - ...prev, - visibilityConditionJson: { - ...(prev.visibilityConditionJson || { field: '', value: '' }), - operator: val as any, - }, - })) - } - /> -
+ + setFormData((prev) => ({ + ...prev, + visibilityConditionJson: { + ...(prev.visibilityConditionJson || { field: '', value: '' }), + operator: val as any, + }, + })) + } + /> -
- - - setFormData((prev) => ({ - ...prev, - visibilityConditionJson: { - ...(prev.visibilityConditionJson || { field: '', operator: 'equals' }), - value: e.target.value, - }, - })) - } - placeholder="e.g. DGYRHTFUJKYHUSDERTYUIK" - /> -
+ + setFormData((prev) => ({ + ...prev, + visibilityConditionJson: { + ...(prev.visibilityConditionJson || { field: '', operator: 'equals' }), + value: e.target.value, + }, + })) + } + placeholder="e.g. DGYRHTFUJKYHUSDERTYUIK" + />
@@ -353,6 +364,7 @@ export function FieldDefinitionFormModal({
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} /> diff --git a/src/app/configuration/masterData/components/MasterItemFormModal.tsx b/src/app/configuration/masterData/components/MasterItemFormModal.tsx index 3d7c5ec..c77104c 100644 --- a/src/app/configuration/masterData/components/MasterItemFormModal.tsx +++ b/src/app/configuration/masterData/components/MasterItemFormModal.tsx @@ -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>; onSubmit: (e: React.FormEvent) => void; errorMsg: string | null; + setErrorMsg?: (err: string | null) => void; } export const MasterItemFormModal: React.FC = ({ @@ -32,7 +33,15 @@ export const MasterItemFormModal: React.FC = ({ 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 ( = ({ size="md" > {errorMsg && ( -
- - {errorMsg} -
+ setErrorMsg?.(null)} + autoClose={false} + /> )} - +
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" />
diff --git a/src/app/configuration/masterData/index.tsx b/src/app/configuration/masterData/index.tsx index f7f70db..965cb9a 100644 --- a/src/app/configuration/masterData/index.tsx +++ b/src/app/configuration/masterData/index.tsx @@ -247,6 +247,7 @@ export default function MasterDataManagement() { setFormData={setFormData} onSubmit={handleSubmit} errorMsg={errorMsg} + setErrorMsg={setErrorMsg} /> {/* Delete Confirmation Modal */} diff --git a/src/app/policyEngine/PolicyEngineApi.ts b/src/app/policyEngine/PolicyEngineApi.ts index 340fffb..1101d9c 100644 --- a/src/app/policyEngine/PolicyEngineApi.ts +++ b/src/app/policyEngine/PolicyEngineApi.ts @@ -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 ─────────────────────────── diff --git a/src/app/policyEngine/components/AddPolicyEngine.tsx b/src/app/policyEngine/components/AddPolicyEngine.tsx index 7d47a38..9a33a02 100644 --- a/src/app/policyEngine/components/AddPolicyEngine.tsx +++ b/src/app/policyEngine/components/AddPolicyEngine.tsx @@ -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 = { 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, + allFields?: ActionTypeField[], + fieldLookupOptionsMap?: Record +): 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([]); @@ -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 ( -
- +
+ {/* ─── Header Skeleton ────────────────────────────────────────────── */} +
+
+
+ +
+
+ + +
+
+
+ + {/* ─── Body Content Skeleton ──────────────────────────────────────── */} +
+
+ + {/* Policy Information Card */} +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {/* Target Audience Card */} +
+
+ + +
+
+
+ + +
+
+
+ + {/* Rule Engine Card */} +
+
+
+ + +
+ +
+
+ +
+
+ +
+
); } @@ -808,6 +1045,7 @@ export default function AddPolicyEngine() {
- +
setDescription(e.target.value)} placeholder="Enter description..." @@ -868,6 +1107,7 @@ export default function AddPolicyEngine() {
handleCategoryChange(rule.id, val)} @@ -921,7 +1162,7 @@ export default function AddPolicyEngine() { />
- + {(() => { const prevPriority = getPrevPriority(rule); const nextPriority = getNextPriority(rule); @@ -969,6 +1210,7 @@ export default function AddPolicyEngine() {
handleConditionFieldSelect(rule.id, condition.id, val)} @@ -979,6 +1221,7 @@ export default function AddPolicyEngine() {
handleUpdateCondition(rule.id, condition.id, { operator: val })} @@ -989,6 +1232,7 @@ export default function AddPolicyEngine() { {isLookup ? ( handleUpdateCondition(rule.id, condition.id, { value: val })} @@ -997,6 +1241,7 @@ export default function AddPolicyEngine() { ) : isBoolean ? ( handleUpdateCondition(rule.id, condition.id, { value: e.target.value })} @@ -1017,7 +1263,7 @@ export default function AddPolicyEngine() {
{cIdx < rule.conditions.length - 1 ? (
- + handleUpdateCondition(rule.id, condition.id, { logic: val })} @@ -1065,6 +1311,7 @@ export default function AddPolicyEngine() {
handleActionCategoryChange(rule.id, action.id, val)} @@ -1075,6 +1322,7 @@ export default function AddPolicyEngine() {
handleActionTypeChange(rule.id, action.id, val)} @@ -1085,7 +1333,7 @@ export default function AddPolicyEngine() { {aIdx < rule.actions.length - 1 ? (
- +
) : ( @@ -1121,109 +1369,136 @@ export default function AddPolicyEngine() {
- ) : (actionTypeFieldsMap[action.actionTypeId] || []).length === 0 ? ( -

No dynamic fields configured for this Action Type.

- ) : ( -
- {(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 ( -
- + if (allFields.length === 0) { + return

No dynamic fields configured for this Action Type.

; + } - {field.fieldType === 'textarea' ? ( - handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)} - placeholder={field.placeholder || 'Enter details...'} - /> - ) : field.fieldType === 'currency' ? ( -
-
- + if (visibleFields.length === 0) { + return null; + } + + return ( +
+ {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 ( +
+ + + {field.fieldType === 'textarea' ? ( + handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)} + placeholder={field.placeholder || 'Enter details...'} + /> + ) : field.fieldType === 'currency' ? ( +
+
+ + handleFieldValueChange(rule.id, action.id, field.fieldCode, { + ...(fieldVal || {}), + amount: e.target.value, + }) + } + placeholder={field.placeholder || 'Amount'} + /> +
+ handleFieldValueChange(rule.id, action.id, field.fieldCode, { ...(fieldVal || {}), - amount: e.target.value, + currency: val, }) } - placeholder={field.placeholder || 'Amount'} />
+ ) : field.fieldType === 'dropdown' ? ( - 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...'} /> -
- ) : field.fieldType === 'dropdown' ? ( - handleFieldValueChange(rule.id, action.id, field.fieldCode, val)} - placeholder={field.placeholder || 'Select option...'} - /> - ) : field.fieldType === 'multi_select' ? ( - handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)} - placeholder={field.placeholder || 'Select multiple options...'} - /> - ) : field.fieldType === 'checkbox' ? ( - handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)} - label={field.helpText || field.fieldName} - /> - ) : field.fieldType === 'switch' ? ( -
- handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)} + placeholder={field.placeholder || 'Select multiple options...'} + /> + ) : field.fieldType === 'checkbox' ? ( + handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)} + label={field.fieldName} /> - - {fieldVal ? 'Enabled' : 'Disabled'} - -
- ) : ( - handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)} - placeholder={field.placeholder || 'Enter value...'} - /> - )} -
- ); - })} -
- )} + ) : field.fieldType === 'switch' ? ( +
+ handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)} + /> + + {fieldVal ? 'Enabled' : 'Disabled'} + +
+ ) : ( + 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 && ( +

+ {helpText} +

+ )} +
+ ); + })} +
+ ); + })()}
)}
@@ -1247,16 +1522,14 @@ export default function AddPolicyEngine() {
- {!isEditMode && ( - handleSavePolicy(false)} - disabled={isSaving} - > - {isSaving ? 'Saving...' : 'Save Draft'} - - )} + handleSavePolicy(false)} + disabled={isSaving || !isDraftValid} + > + {isSaving ? 'Saving...' : 'Save Draft'} + handleSavePolicy(true)} - disabled={isSaving} + disabled={isSaving || !isFormValid} > {isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'}
- {/* ─── Success Modal ──────────────────────────────────────────────── */} - { - setShowSuccessModal(false); - navigate('/policy-engine'); - }} - title={isEditMode ? 'Policy Updated Successfully.' : 'Policy Created Successfully.'} - label="POLICY NAME" - cohortName={policyName} - cohortStatus={status} - cohortDescription={description} - /> -
); } diff --git a/src/app/policyEngine/components/PolicyEngineList.tsx b/src/app/policyEngine/components/PolicyEngineList.tsx index 76f6b0e..6878e56 100644 --- a/src/app/policyEngine/components/PolicyEngineList.tsx +++ b/src/app/policyEngine/components/PolicyEngineList.tsx @@ -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 ( - {text} + + {text} + ); } function CellText({ text }: { text: string | null | undefined }) { return ( - - {text || '—'} + + {text || "—"} ); } @@ -49,6 +63,16 @@ export default function PolicyEngineList() { const navigate = useNavigate(); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(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(null); - const [deactivateTarget, setDeactivateTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [deactivateTarget, setDeactivateTarget] = + useState(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[] = [ { header: , - accessor: row => ( + accessor: (row) => ( {row.policyName} @@ -137,22 +183,22 @@ export default function PolicyEngineList() { }, { header: , - accessor: row => , + accessor: (row) => , }, { header: , - accessor: row => , + accessor: (row) => , }, { header: , - accessor: row => , + accessor: (row) => , }, { header: , - className: 'text-right', - accessor: row => ( + className: "text-right", + accessor: (row) => ( - {row.status !== 'Active' && ( + {row.status !== "Active" && ( } variant="success" @@ -161,7 +207,7 @@ export default function PolicyEngineList() { Activate )} - {row.status === 'Active' && ( + {row.status === "Active" && ( } onClick={() => setDeactivateTarget(row)} @@ -211,15 +257,29 @@ export default function PolicyEngineList() { return ( <> + {error && ( + setError(null)} + /> + )} + {successMsg && ( + setSuccessMsg(null)} + /> + )} columns={columns} - data={policies} + data={displayedPolicies} leftHeaderActions={
handleSearchChange(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} leftIcon={} className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]" containerClassName="!gap-0" @@ -233,7 +293,7 @@ export default function PolicyEngineList() { size="md" leftIcon={} 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 @@ -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" /> diff --git a/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx b/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx index 55f1a47..a54c218 100644 --- a/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx +++ b/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx @@ -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) { diff --git a/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx b/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx index 4607cf6..43ca927 100644 --- a/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx +++ b/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx @@ -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([]); const [metrics, setMetrics] = useState([]); 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(null); + const [successMsg, setSuccessMsg] = useState(null); // Pagination const [currentPage, setCurrentPage] = useState(1); // Filters const [search, setSearch] = useState(""); - const [isGrouped, setIsGrouped] = useState(false); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [isGrouped, setIsGrouped] = useState(() => { + return sessionStorage.getItem("recovery_isGrouped") === "true"; + }); + const [collapsedGroups, setCollapsedGroups] = useState>(() => { + 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(null); // Selection const [selectedIds, setSelectedIds] = useState>(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 (
+ {error && ( + setError(null)} + /> + )} + {successMsg && ( + setSuccessMsg(null)} + /> + )} {/* Metrics Row */}
{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 */} + 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}.` + } />
); diff --git a/src/app/simulation/components/SimulationTerminal.tsx b/src/app/simulation/components/SimulationTerminal.tsx index dc3f0d6..0c50520 100644 --- a/src/app/simulation/components/SimulationTerminal.tsx +++ b/src/app/simulation/components/SimulationTerminal.tsx @@ -75,7 +75,6 @@ const SIMULATION_PASSENGER_POOL: Array 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' }, diff --git a/src/components/custom/CustomInput.tsx b/src/components/custom/CustomInput.tsx index 80c0ed6..4861bfe 100644 --- a/src/components/custom/CustomInput.tsx +++ b/src/components/custom/CustomInput.tsx @@ -22,7 +22,7 @@ const CustomInput = forwardRef( type = "text", leftIcon, rightIcon, - maxLength, + maxLength = 100, phonePrefix, disabled, className = "", diff --git a/src/components/custom/CustomTable.tsx b/src/components/custom/CustomTable.tsx index f759ee9..12b0002 100644 --- a/src/components/custom/CustomTable.tsx +++ b/src/components/custom/CustomTable.tsx @@ -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 { @@ -53,7 +59,6 @@ export function CustomTable({ onRowClick, rowClassName, }: CustomTableProps) { - const handlePageChange = (newPage: number) => { if (newPage >= 1 && newPage <= totalPages && onPageChange) { onPageChange(newPage); @@ -61,16 +66,43 @@ export function CustomTable({ }; 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 (
- {/* Top Header Section */}
@@ -89,9 +121,7 @@ export function CustomTable({ {leftHeaderActions}
-
- {rightHeaderActions} -
+
{rightHeaderActions}
{/* Table Section */} @@ -106,8 +136,18 @@ export function CustomTable({ >
{col.header} - {col.sortable && } - {col.filterable && } + {col.sortable && ( + + )} + {col.filterable && ( + + )}
))} @@ -122,7 +162,10 @@ export function CustomTable({ className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""} ${rowClassName ? rowClassName(row) : ""}`} > {columns.map((col, colIndex) => ( - + {typeof col.accessor === "function" ? col.accessor(row) : (row[col.accessor] as React.ReactNode)} @@ -132,7 +175,10 @@ export function CustomTable({ )) ) : ( - + No {itemName} found. @@ -144,7 +190,8 @@ export function CustomTable({ {/* Pagination Footer */}
- Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of {totalItems} {itemName} + Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of{" "} + {totalItems} {itemName}
@@ -157,20 +204,27 @@ export function CustomTable({
- {getPageNumbers().map(page => ( - - ))} + {getPageNumbers().map((page, index) => + page === "..." ? ( + + ... + + ) : ( + + ), + )}