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

1693 lines
77 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import {
ArrowLeftIcon, PlusIcon, TrashIcon, MinusIcon, BookIcon, UsersIcon, GitBranchIcon, CaretDownIcon
} from '@phosphor-icons/react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ApiClient } from '../../api/ApiClient';
import {
CustomInput,
CustomDropdown,
CustomMultiSelect,
CustomButton,
CustomTextArea,
CustomStatus,
CustomRadio,
CustomSwitch,
CustomCheckBox,
CustomLoader,
Skeleton,
} from '../../../components/custom';
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
import {
getJurisdictionOptions,
getCohortOptions,
getRuleCategoryOptions,
getConditionOptions,
getOperatorOptions,
getActionCategoryOptions,
getActionTypesByCategoryOptions,
getActionTypeFields,
getLookupOptions,
getPolicy,
createPolicy,
updatePolicy,
getConditionGroupsForCategory,
getConditionFieldsForGroup,
getFieldLookupOptions,
type OptionItem,
type ActionTypeField,
} from '../PolicyEngineApi';
const WIDTH_GRID_MAP: Record<string, string> = {
full: 'col-span-12',
half: 'col-span-12 md:col-span-6',
third: 'col-span-12 md:col-span-4',
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 ───────────────────────────────────────────────────────────────────
type Condition = {
id: string;
condition: string;
operator: string;
value: string;
logic: string;
};
type Action = {
id: string;
actionCategoryId: string;
actionTypeId: string;
logic: string;
fieldValues?: Record<string, any>;
};
type Rule = {
id: string;
category: string;
priority: number;
conditions: Condition[];
actions: Action[];
};
// ─── Constants ───────────────────────────────────────────────────────────────
const LOGIC_OPTIONS = [
{ label: 'AND', value: 'AND' },
{ label: 'OR', value: 'OR' },
];
// Per-gate color scheme (exact Figma tokens): AND=blue, OR=yellow, NOT=red.
// Fill = background per gate; border per gate; label/chevron text = #032D20 for all.
const LOGIC_STYLES: Record<string, { bg: string; text: string; border: string }> = {
AND: { bg: 'bg-[#F2FAFF]', text: 'text-[#032D20]', border: 'border-[#1A6597]' },
OR: { bg: 'bg-[#FFFDF2]', text: 'text-[#032D20]', border: 'border-[#977E1A]' },
NOT: { bg: 'bg-[#FFF2F2]', text: 'text-[#032D20]', border: 'border-[#971A1C]' },
};
// ─── Logic Gate Dropdown ─────────────────────────────────────────────────────
// Colored variant of the logic selector — the shared CustomDropdown hardcodes a
// white background and text color, so we render a dedicated colored control here.
function LogicDropdown({ value, onChange, readOnly = false }: { value: string; onChange?: (v: string) => void; readOnly?: boolean }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (readOnly) return;
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [readOnly]);
const style = LOGIC_STYLES[value] ?? LOGIC_STYLES.AND;
const boxClass = `w-full h-[50px] px-4 rounded-[10px] border flex items-center justify-between gap-2 text-[14px] font-semibold transition-colors shadow-[0_1px_2px_0_rgba(0,0,0,0.05)] ${style.bg} ${style.text} ${style.border}`;
// Fixed / non-editable gate (used by the Strategic Action Builder) — no menu.
if (readOnly) {
return <div className={boxClass}>{value}</div>;
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(o => !o)}
className={boxClass}
>
{value}
<CaretDownIcon size={16} className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden">
{LOGIC_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => { onChange?.(opt.value); setOpen(false); }}
className="w-full text-left px-4 h-[42px] text-[14px] font-medium flex items-center text-gray-700 hover:bg-gray-50"
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function AddPolicyEngine() {
const navigate = useNavigate();
const { id: paramId } = useParams();
const [searchParams] = useSearchParams();
const policyId = paramId || searchParams.get('id') || '';
const isEditMode = !!policyId;
const [loadingPolicy, setLoadingPolicy] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successTitle, setSuccessTitle] = useState('');
// Dynamic Options State (loaded directly from PolicyEngineApi)
const [jurisdictionOptions, setJurisdictionOptions] = useState<OptionItem[]>([]);
const [cohortOptions, setCohortOptions] = useState<OptionItem[]>([]);
const [ruleCategoryOptions, setRuleCategoryOptions] = useState<OptionItem[]>([]);
const [operatorOptions, setOperatorOptions] = useState<OptionItem[]>([]);
const [categoryConditionMap, setCategoryConditionMap] = useState<Record<string, OptionItem[]>>({});
// Dynamic Action Builder State
const [actionCategoryOptions, setActionCategoryOptions] = useState<OptionItem[]>([]);
const [actionTypesByCategoryMap, setActionTypesByCategoryMap] = useState<Record<string, OptionItem[]>>({});
const [loadingActionTypesMap, setLoadingActionTypesMap] = useState<Record<string, boolean>>({});
const [actionTypeFieldsMap, setActionTypeFieldsMap] = useState<Record<string, ActionTypeField[]>>({});
const [fieldLookupOptionsMap, setFieldLookupOptionsMap] = useState<Record<string, OptionItem[]>>({});
const [loadingActionFields, setLoadingActionFields] = useState<Record<string, boolean>>({});
useEffect(() => {
getJurisdictionOptions().then(setJurisdictionOptions);
getCohortOptions().then(setCohortOptions);
getRuleCategoryOptions().then(setRuleCategoryOptions);
getOperatorOptions().then(setOperatorOptions);
getActionCategoryOptions().then(setActionCategoryOptions);
}, []);
const [conditionFieldsMetaMap, setConditionFieldsMetaMap] = useState<Record<string, any>>({});
const [conditionValueLookupMap, setConditionValueLookupMap] = useState<Record<string, OptionItem[]>>({});
const loadCategoryConditions = async (category: string) => {
if (!category) return [];
try {
const groups = await getConditionGroupsForCategory(category);
const allFields: OptionItem[] = [];
const metaMap: Record<string, any> = {};
for (const g of groups) {
const fields = await getConditionFieldsForGroup(g.id || g.code);
if (fields && fields.length > 0) {
allFields.push({
label: g.name,
value: `header_${g.id || g.code}`,
groupHeader: true,
disabled: true,
});
fields.forEach((f: any) => {
const val = f.id || f.code;
allFields.push({
label: f.name,
value: val,
id: f.id,
code: f.code,
groupName: g.name,
});
metaMap[val] = f;
});
}
}
setCategoryConditionMap((prev) => ({
...prev,
[category]: allFields,
}));
setConditionFieldsMetaMap((prev) => ({
...prev,
...metaMap,
}));
return allFields;
} catch (err) {
console.error('Failed to load condition groups/fields for category', err);
try {
const fallbackOpts = await getConditionOptions(category);
setCategoryConditionMap((prev) => ({
...prev,
[category]: fallbackOpts,
}));
return fallbackOpts;
} catch {
return [];
}
}
};
const handleCategoryChange = async (ruleId: string, category: string) => {
handleUpdateRule(ruleId, { category });
if (category) {
await loadCategoryConditions(category);
}
};
const handleConditionFieldSelect = async (ruleId: string, conditionId: string, selectedFieldIdOrCode: string) => {
handleUpdateCondition(ruleId, conditionId, { condition: selectedFieldIdOrCode, value: '' });
if (selectedFieldIdOrCode) {
try {
const opts = await getFieldLookupOptions(selectedFieldIdOrCode);
if (opts && opts.length > 0) {
setConditionValueLookupMap((prev) => ({
...prev,
[selectedFieldIdOrCode]: opts,
}));
}
} catch (err) {
console.error('Failed to load lookup options for condition field', err);
}
}
};
const handleActionCategoryChange = async (ruleId: string, actionId: string, selectedCategoryId: string) => {
handleUpdateAction(ruleId, actionId, { actionCategoryId: selectedCategoryId, actionTypeId: '', fieldValues: {} });
if (!selectedCategoryId) return;
if (!actionTypesByCategoryMap[selectedCategoryId]) {
setLoadingActionTypesMap((prev) => ({ ...prev, [selectedCategoryId]: true }));
try {
const types = await getActionTypesByCategoryOptions(selectedCategoryId);
setActionTypesByCategoryMap((prev) => ({ ...prev, [selectedCategoryId]: types }));
} catch (err) {
console.error('Failed to load action types', err);
} finally {
setLoadingActionTypesMap((prev) => ({ ...prev, [selectedCategoryId]: false }));
}
}
};
const handleActionTypeChange = async (ruleId: string, actionId: string, selectedTypeId: string) => {
handleUpdateAction(ruleId, actionId, { actionTypeId: selectedTypeId, fieldValues: {} });
if (!selectedTypeId) return;
if (!actionTypeFieldsMap[selectedTypeId]) {
setLoadingActionFields((prev) => ({ ...prev, [selectedTypeId]: true }));
try {
const fields = await getActionTypeFields(selectedTypeId);
setActionTypeFieldsMap((prev) => ({ ...prev, [selectedTypeId]: fields }));
// Pre-populate default values for the new action
const initialFieldValues: Record<string, any> = {};
fields.forEach((f) => {
const defaultVal = f.defaultValue ?? (f as any).default_value ?? (f as any).defaultValueJson ?? (f as any).default_value_json;
if (defaultVal !== undefined && defaultVal !== null) {
initialFieldValues[f.fieldCode] = defaultVal;
}
});
handleUpdateAction(ruleId, actionId, { fieldValues: initialFieldValues });
// Pre-fetch lookup options for fields requiring lookupSource
const sourcesToFetch = new Set<string>();
fields.forEach((f) => {
if (f.lookupSource) sourcesToFetch.add(f.lookupSource);
});
for (const src of Array.from(sourcesToFetch)) {
if (!fieldLookupOptionsMap[src]) {
const opts = await getLookupOptions(src);
setFieldLookupOptionsMap((prev) => ({ ...prev, [src]: opts }));
}
}
} catch (err) {
console.error('Failed to load action fields', err);
} finally {
setLoadingActionFields((prev) => ({ ...prev, [selectedTypeId]: false }));
}
} else {
// Fields already loaded in map, just pre-populate default values
const fields = actionTypeFieldsMap[selectedTypeId] || [];
const initialFieldValues: Record<string, any> = {};
fields.forEach((f) => {
const defaultVal = f.defaultValue ?? (f as any).default_value ?? (f as any).defaultValueJson ?? (f as any).default_value_json;
if (defaultVal !== undefined && defaultVal !== null && defaultVal !== '') {
initialFieldValues[f.fieldCode] = defaultVal;
}
});
handleUpdateAction(ruleId, actionId, { fieldValues: initialFieldValues });
}
};
const handleFieldValueChange = (ruleId: string, actionId: string, fieldCode: string, value: any) => {
setRules((prevRules) =>
prevRules.map((r) => {
if (r.id === ruleId) {
return {
...r,
actions: r.actions.map((a) => {
if (a.id === actionId) {
return {
...a,
fieldValues: {
...(a.fieldValues || {}),
[fieldCode]: value,
},
};
}
return a;
}),
};
}
return r;
}),
);
};
// Policy Info State
const [policyName, setPolicyName] = useState('');
const [jurisdiction, setJurisdiction] = useState<(string | number)[]>([]);
const [status, setStatus] = useState<'Active' | 'Inactive' | 'Draft'>('Active');
const [description, setDescription] = useState('');
// Target Audience State
const [audienceType, setAudienceType] = useState<'All Passengers' | 'Selected Cohorts'>('All Passengers');
const [selectedCohorts, setSelectedCohorts] = useState<(string | number)[]>([]);
// Rule Engine State
const [rules, setRules] = useState<Rule[]>([
{
id: crypto.randomUUID(),
category: '',
priority: 2,
conditions: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }],
actions: [{ id: crypto.randomUUID(), actionCategoryId: '', actionTypeId: '', logic: 'AND', fieldValues: {} }]
}
]);
// Load existing policy data if editing
useEffect(() => {
if (!policyId) return;
setLoadingPolicy(true);
getPolicy(policyId)
.then(async (data: any) => {
if (!data) return;
setPolicyName(data.policyName || data.name || '');
if (Array.isArray(data.jurisdictions)) {
setJurisdiction(
data.jurisdictions
.map((j: any) => (typeof j === 'object' ? j.jurisdictionId || j.jurisdiction?.id || j.jurisdiction?.code || j.id : j))
.filter(Boolean),
);
} else if (Array.isArray(data.jurisdictionIds)) {
setJurisdiction(data.jurisdictionIds);
} else {
const singleJur =
typeof data.jurisdiction === 'object' && data.jurisdiction !== null
? data.jurisdiction.code || data.jurisdiction.id
: data.jurisdictionId || data.jurisdiction || '';
setJurisdiction(singleJur ? [singleJur] : []);
}
setStatus(
data.status?.toLowerCase() === 'active' ? 'Active' :
data.status?.toLowerCase() === 'draft' ? 'Draft' : 'Inactive'
);
setDescription(data.description || '');
const isCohort =
data.audienceType === 'COHORT' ||
data.audienceType === 'Selected Cohorts' ||
(Array.isArray(data.targetAudiences) && data.targetAudiences.length > 0);
setAudienceType(isCohort ? 'Selected Cohorts' : 'All Passengers');
if (Array.isArray(data.targetAudiences)) {
const cohortIds = data.targetAudiences
.map((ta: any) => ta.targetId)
.filter(Boolean);
setSelectedCohorts(cohortIds);
}
if (Array.isArray(data.rules) && data.rules.length > 0) {
const loadedRules: Rule[] = [];
const ruleCatOpts = ruleCategoryOptions.length > 0 ? ruleCategoryOptions : await getRuleCategoryOptions();
for (const r of data.rules) {
const rawCat = r.ruleCategory?.code || r.ruleCategory?.id || r.ruleCategoryId || r.category || '';
const matchedRuleCat = ruleCatOpts.find((c) => c.id === rawCat || c.code === rawCat || c.value === rawCat);
const cat = matchedRuleCat ? matchedRuleCat.value : rawCat;
if (cat) {
await loadCategoryConditions(cat);
}
const loadedConditions: Condition[] =
Array.isArray(r.conditions) && r.conditions.length > 0
? r.conditions.map((c: any) => ({
id: c.id || crypto.randomUUID(),
condition: c.fieldId || c.condition || '',
operator: c.operatorId || c.operator || '',
value: c.valueText || c.value || '',
logic: c.logicalOperator || c.logic || 'AND',
}))
: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }];
for (const c of loadedConditions) {
if (c.condition) {
try {
const opts = await getFieldLookupOptions(c.condition);
if (opts && opts.length > 0) {
setConditionValueLookupMap((prev) => ({ ...prev, [c.condition]: opts }));
}
} catch (err) {
console.error('Failed to load lookup options for condition', err);
}
}
}
const loadedActions: Action[] = [];
if (Array.isArray(r.actions) && r.actions.length > 0) {
for (const a of r.actions) {
const actionTypeId = a.actionTypeId || '';
let actionCatId = a.actionCategoryId || a.actionType?.categoryId || a.actionType?.actionCategoryId || '';
if (!actionCatId && actionTypeId) {
try {
const typeInfo = await ApiClient.get<any, any>(`/master-data/action-types/${actionTypeId}`);
if (typeInfo?.categoryId) {
actionCatId = typeInfo.categoryId;
}
} catch (err) {
console.error('Failed to fetch action type category for edit mode', err);
}
}
if (actionCatId) {
const types = await getActionTypesByCategoryOptions(actionCatId);
setActionTypesByCategoryMap((prev) => ({ ...prev, [actionCatId]: types }));
}
const fieldValues: Record<string, any> = {};
if (Array.isArray(a.values)) {
a.values.forEach((v: any) => {
const curr = v.currencyCodeId || v.currencyCode;
if (curr || (v.numberValue !== null && v.numberValue !== undefined && curr)) {
fieldValues[v.fieldCode] = { amount: v.numberValue, currency: curr };
} else if (v.booleanValue !== null && v.booleanValue !== undefined) {
fieldValues[v.fieldCode] = v.booleanValue;
} else if (v.numberValue !== null && v.numberValue !== undefined) {
fieldValues[v.fieldCode] = v.numberValue;
} else if (v.selectedValueId) {
fieldValues[v.fieldCode] = v.selectedValueId;
} else if (v.textValue) {
try {
fieldValues[v.fieldCode] = JSON.parse(v.textValue);
} catch {
fieldValues[v.fieldCode] = v.textValue;
}
}
});
} else if (a.fieldValues) {
Object.assign(fieldValues, a.fieldValues);
}
if (actionTypeId) {
const fields = await getActionTypeFields(actionTypeId);
setActionTypeFieldsMap((prev) => ({ ...prev, [actionTypeId]: fields }));
const sourcesToFetch = new Set<string>();
fields.forEach((f) => {
if (f.lookupSource) sourcesToFetch.add(f.lookupSource);
});
for (const src of Array.from(sourcesToFetch)) {
getLookupOptions(src).then((opts) => {
setFieldLookupOptionsMap((prev) => ({ ...prev, [src]: opts }));
});
}
}
loadedActions.push({
id: a.id || crypto.randomUUID(),
actionCategoryId: actionCatId,
actionTypeId,
logic: a.logic || 'AND',
fieldValues,
});
}
} else {
loadedActions.push({
id: crypto.randomUUID(),
actionCategoryId: '',
actionTypeId: '',
logic: 'AND',
fieldValues: {},
});
}
loadedRules.push({
id: r.id || crypto.randomUUID(),
category: cat,
priority: r.priority || 1,
conditions: loadedConditions,
actions: loadedActions,
});
}
setRules(loadedRules);
}
})
.catch((err) => {
console.error('Failed to load policy for edit', err);
})
.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 allFields = actionTypeFieldsMap[act.actionTypeId] || [];
const visibleFields = allFields.filter((f) =>
isActionFieldVisible(f, act.fieldValues, allFields, fieldLookupOptionsMap)
);
for (const field of visibleFields) {
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 (isDeploy && !isFormValid) {
return;
}
if (!isDeploy && !isDraftValid) {
return;
}
setIsSaving(true);
try {
const payload = {
policyName: policyName.trim(),
jurisdictionId: Array.isArray(jurisdiction) && jurisdiction.length > 0 ? String(jurisdiction[0]) : undefined,
jurisdictionIds: Array.isArray(jurisdiction) ? jurisdiction.map(String) : [],
description: description || undefined,
status: isDeploy ? (status === 'Draft' ? 'active' : status.toLowerCase()) : 'draft',
audienceType: audienceType === 'Selected Cohorts' ? 'COHORT' : 'ALL',
targetAudiences:
audienceType === 'Selected Cohorts'
? selectedCohorts.map((cohortId) => ({
targetType: 'COHORT',
targetId: String(cohortId),
}))
: [],
rules: rules.map((r, rIdx) => ({
ruleCategoryId:
ruleCategoryOptions.find((cat) => cat.code === r.category || cat.value === r.category || cat.id === r.category)?.id ||
(r.category && r.category.includes('-') ? r.category : undefined),
priority: r.priority || rIdx + 1,
conditions: r.conditions
.filter((c) => c.condition && c.operator)
.map((c, cIdx) => ({
fieldId: c.condition,
operatorId: c.operator,
valueText:
typeof c.value === 'object'
? JSON.stringify(c.value)
: String(c.value ?? ''),
logicalOperator: c.logic || 'AND',
sequence: cIdx + 1,
})),
actions: r.actions
.filter((a) => a.actionTypeId)
.map((a, aIdx) => ({
actionTypeId: a.actionTypeId,
sequence: aIdx + 1,
values: Object.entries(a.fieldValues || {}).map(([fCode, val]) => {
const fields = actionTypeFieldsMap[a.actionTypeId] || [];
const fieldDef = fields.find((f) => f.fieldCode === fCode);
const valObj: any = { fieldCode: fCode };
if (fieldDef?.id) {
valObj.fieldDefinitionId = fieldDef.id;
}
const fieldType = fieldDef?.fieldType;
if (fieldType === 'currency' || (val && typeof val === 'object' && ('amount' in val || 'currency' in val))) {
const amt = typeof val === 'object' ? val.amount : val;
const curr = typeof val === 'object' ? val.currency : undefined;
valObj.numberValue = parseFloat(amt) || 0;
if (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 {
valObj.textValue = String(val ?? '');
}
return valObj;
}),
})),
})),
};
if (isEditMode) {
await updatePolicy(policyId, payload);
} else {
await createPolicy(payload);
}
if (isEditMode) {
}
setSuccessTitle(isDeploy
? (isEditMode ? "Policy Updated Successfully." : "Policy Created Successfully.")
: "Policy Saved as Draft.");
setShowSuccessModal(true);
} catch (err) {
console.error('Failed to save policy', err);
alert('Failed to save policy. Please check input values and try again.');
} finally {
setIsSaving(false);
}
};
// ─── Handlers ──────────────────────────────────────────────────────────────
// Returns the lowest priority (1-10) not already used by an existing rule.
const findFreePriority = () => {
const taken = rules.map(r => r.priority);
for (let p = 1; p <= 10; p++) {
if (!taken.includes(p)) return p;
}
return 1; // all 10 slots taken (>10 rules) — warning handles this edge case
};
const handleAddRule = () => {
setRules([...rules, {
id: crypto.randomUUID(),
category: '',
priority: findFreePriority(),
conditions: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }],
actions: [{ id: crypto.randomUUID(), actionCategoryId: '', actionTypeId: '', logic: 'AND', fieldValues: {} }]
}]);
};
const handleDeleteRule = (ruleId: string) => {
setRules(rules.filter(r => r.id !== ruleId));
};
const handleUpdateRule = (ruleId: string, updates: Partial<Rule>) => {
setRules(rules.map(r => r.id === ruleId ? { ...r, ...updates } : r));
};
// Priorities used by every OTHER rule — a rule may never land on one of these.
const priorityTakenByOthers = (rule: Rule) =>
rules.filter(r => r.id !== rule.id).map(r => r.priority);
// Next free priority below the current value (down to 1), or null if none.
const getPrevPriority = (rule: Rule): number | null => {
const taken = priorityTakenByOthers(rule);
for (let p = rule.priority - 1; p >= 1; p--) {
if (!taken.includes(p)) return p;
}
return null;
};
// Next free priority above the current value (up to 10), or null if none.
const getNextPriority = (rule: Rule): number | null => {
const taken = priorityTakenByOthers(rule);
for (let p = rule.priority + 1; p <= 10; p++) {
if (!taken.includes(p)) return p;
}
return null;
};
const handleAddCondition = (ruleId: string) => {
setRules(rules.map(r => {
if (r.id === ruleId) {
return {
...r,
conditions: [...r.conditions, { id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }]
};
}
return r;
}));
};
const handleUpdateCondition = (ruleId: string, conditionId: string, updates: Partial<Condition>) => {
setRules(rules.map(r => {
if (r.id === ruleId) {
return {
...r,
conditions: r.conditions.map(c => c.id === conditionId ? { ...c, ...updates } : c)
};
}
return r;
}));
};
const handleDeleteCondition = (ruleId: string, conditionId: string) => {
setRules(rules.map(r => {
if (r.id === ruleId) {
return { ...r, conditions: r.conditions.filter(c => c.id !== conditionId) };
}
return r;
}));
};
const handleAddAction = (ruleId: string) => {
setRules(rules.map(r => {
if (r.id === ruleId) {
return {
...r,
actions: [...r.actions, { id: crypto.randomUUID(), actionCategoryId: '', actionTypeId: '', logic: 'AND', fieldValues: {} }]
};
}
return r;
}));
};
const handleUpdateAction = (ruleId: string, actionId: string, updates: Partial<Action>) => {
setRules(rules.map(r => {
if (r.id === ruleId) {
return {
...r,
actions: r.actions.map(a => a.id === actionId ? { ...a, ...updates } : a)
};
}
return r;
}));
};
const handleDeleteAction = (ruleId: string, actionId: string) => {
setRules(rules.map(r => {
if (r.id === ruleId) {
return { ...r, actions: r.actions.filter(a => a.id !== actionId) };
}
return r;
}));
};
// ─── Render Helpers ────────────────────────────────────────────────────────
const CardHeader = ({ icon: Icon, title }: { icon: any, title: string }) => (
<div className="flex items-center gap-2 mb-5">
<div className="w-8 h-8 rounded-lg bg-[#E8F3EF] flex items-center justify-center text-[#1E7D5C]">
<Icon size={18} />
</div>
<h2 className="text-base font-bold text-[#0F172B]">{title}</h2>
</div>
);
if (loadingPolicy) {
return (
<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>
);
}
return (
<div className="flex flex-col h-full bg-white min-h-screen">
{/* ─── Header ────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
<div className="flex items-start gap-4">
<button
onClick={() => navigate('/policy-engine')}
className="mt-1 p-1 hover:bg-gray-100 rounded-full transition-colors text-gray-500"
>
<ArrowLeftIcon size={20} />
</button>
<div className="flex flex-col">
<h1 className="text-xl font-bold text-[#0F172B]">
{isEditMode ? 'Edit Policy Engine' : 'Deploy New Policy'}
</h1>
<span className="text-[13px] text-gray-500">Global Framework Registry</span>
</div>
</div>
</div>
{/* ─── Body Content ──────────────────────────────────────────────── */}
<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">
<CardHeader icon={BookIcon} title="Policy Information" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="flex flex-col gap-2">
<CustomInput
label='Policy Name'
required
value={policyName}
onChange={(e) => setPolicyName(e.target.value)}
placeholder="Selected Option"
className="!h-11"
/>
</div>
<div className="flex flex-col gap-2">
<CustomMultiSelect
label='Jurisdiction'
required
options={jurisdictionOptions}
value={jurisdiction}
onChange={setJurisdiction}
placeholder="Select Jurisdictions"
/>
</div>
<div className="flex flex-col gap-2">
<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"
label="Active"
checked={status === 'Active'}
onChange={() => setStatus('Active')}
/>
<CustomRadio
name="status"
label="Inactive"
checked={status === 'Inactive'}
onChange={() => setStatus('Inactive')}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<CustomTextArea
label='Description'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description..."
className="!h-24 resize-none"
/>
</div>
</div>
{/* Target Audience Card */}
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
<CardHeader icon={UsersIcon} title="Target Audience" />
<div className="flex flex-col gap-6">
<div className="flex items-center gap-8">
<CustomRadio
name="audience"
label="All Passengers"
checked={audienceType === 'All Passengers'}
onChange={() => setAudienceType('All Passengers')}
/>
<CustomRadio
name="audience"
label="Selected Cohorts"
checked={audienceType === 'Selected Cohorts'}
onChange={() => setAudienceType('Selected Cohorts')}
/>
</div>
{audienceType === 'Selected Cohorts' && (
<div className="w-1/3 flex flex-col gap-2">
<CustomMultiSelect
label='Cohort'
required
options={cohortOptions}
value={selectedCohorts}
onChange={setSelectedCohorts}
placeholder="Selected Option"
/>
</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">
<CardHeader icon={GitBranchIcon} title="Rule Engine" />
<CustomButton
variant="outlined"
onClick={handleAddRule}
leftIcon={<PlusIcon size={16} />}
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-emerald-50/50 font-semibold"
>
Add Strategic Rule
</CustomButton>
</div>
<div className="flex flex-col gap-6">
{rules.map((rule, ruleIndex) => (
<div key={rule.id} className="border border-gray-100 rounded-[12px] p-6 bg-white relative">
{/* Rule Header */}
<div className="flex items-center justify-between mb-5">
<h3 className="text-base font-bold text-[#0F172B]">Rule {ruleIndex + 1}</h3>
{rules.length > 1 && (
<button
onClick={() => handleDeleteRule(rule.id)}
className="p-1.5 text-red-500 hover:bg-red-50 rounded-md transition-colors"
>
<TrashIcon size={18} />
</button>
)}
</div>
{/* Rule Settings */}
<div className="flex items-end gap-6 mb-8">
<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)}
placeholder="Selected Option"
/>
</div>
<div className="flex flex-col gap-2">
<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);
return (
<div className="flex items-center border border-gray-200 rounded-[10px] h-11 bg-white overflow-hidden w-[120px]">
<button
onClick={() => prevPriority !== null && handleUpdateRule(rule.id, { priority: prevPriority })}
disabled={prevPriority === null}
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<MinusIcon size={16} />
</button>
<span className="flex-1 text-center text-[14px] font-semibold text-gray-800">
{rule.priority}
</span>
<button
onClick={() => nextPriority !== null && handleUpdateRule(rule.id, { priority: nextPriority })}
disabled={nextPriority === null}
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<PlusIcon size={16} />
</button>
</div>
);
})()}
{rules.some(r => r.id !== rule.id && r.priority === rule.priority) && (
<span className="text-red-500 text-xs mt-1">Priority already exists</span>
)}
</div>
</div>
{/* Visual Condition Builder */}
<div className="mb-8">
<h4 className="text-[14px] font-bold text-[#0F172B] mb-4">Visual Condition Builder</h4>
<div className="flex flex-col gap-3">
{rule.conditions.map((condition, cIdx) => {
const fieldMeta = conditionFieldsMetaMap[condition.condition];
const lookupOpts = conditionValueLookupMap[condition.condition] || [];
const isLookup = (fieldMeta && (fieldMeta.lookupTable || fieldMeta.dataType === 'ENUM')) || lookupOpts.length > 0;
const isBoolean = fieldMeta?.dataType === 'BOOLEAN';
const isNumber = fieldMeta?.dataType === 'NUMBER';
return (
<div key={condition.id} className="flex items-end gap-3">
<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)}
placeholder={rule.category ? "Select Condition..." : "Select Rule Category first"}
disabled={!rule.category}
/>
</div>
<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 })}
placeholder="Selected Option"
/>
</div>
<div className="flex-1 flex flex-col gap-2">
{isLookup ? (
<CustomDropdown
label='Value'
required
options={lookupOpts}
value={condition.value}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
placeholder="Select Value..."
/>
) : isBoolean ? (
<CustomDropdown
label='Value'
required
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
]}
value={condition.value}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
placeholder="Select..."
/>
) : (
<CustomInput
label='Value'
required
type={isNumber ? 'number' : 'text'}
value={condition.value}
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
placeholder="Enter Value..."
/>
)}
</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<span className="text-red-500 ml-1">*</span></label>
<LogicDropdown
value={condition.logic}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
/>
</div>
) : (
<div className="w-[163px] flex items-center">
<CustomButton
variant="secondary"
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
leftIcon={<PlusIcon size={16} color="#14704E" />}
onClick={() => handleAddCondition(rule.id)}
>
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
Add Condition
</span>
</CustomButton>
</div>
)}
<div className="flex items-center h-11">
{rule.conditions.length > 1 && (
<button
onClick={() => handleDeleteCondition(rule.id, condition.id)}
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
>
<TrashIcon size={16} strokeWidth={1.5} color="#D40000" />
</button>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Strategic Action Builder */}
<div>
<h4 className="text-[14px] font-bold text-[#0F172B] mb-4">Strategic Action Builder</h4>
<div className="flex flex-col gap-4">
{rule.actions.map((action, aIdx) => (
<div key={action.id} className="border border-gray-200/80 rounded-[12px] p-4 bg-slate-50/40 space-y-4">
{/* Action Category & Action Type Selectors */}
<div className="flex items-end gap-3">
<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)}
placeholder="Select Category..."
/>
</div>
<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)}
placeholder={action.actionCategoryId ? "Select Action Type..." : "Select Category first"}
disabled={!action.actionCategoryId || loadingActionTypesMap[action.actionCategoryId]}
/>
</div>
{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<span className="text-red-500 ml-1">*</span></label>
<LogicDropdown value={action.logic} readOnly />
</div>
) : (
<div className="w-[163px] flex items-center">
<CustomButton
variant="secondary"
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
leftIcon={<PlusIcon size={16} color="#14704E" />}
onClick={() => handleAddAction(rule.id)}
>
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
Add
</span>
</CustomButton>
</div>
)}
<div className="flex items-center h-11">
{rule.actions.length > 1 && (
<button
onClick={() => handleDeleteAction(rule.id, action.id)}
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
>
<TrashIcon size={16} strokeWidth={1.5} color="#D40000" />
</button>
)}
</div>
</div>
{/* Dynamic Action Fields Container */}
{action.actionTypeId && (
<div className="pt-3 border-t border-gray-200">
{loadingActionFields[action.actionTypeId] ? (
<div className="py-4 flex justify-center">
<CustomLoader label="Loading Configured Dynamic Fields..." />
</div>
) : (() => {
const allFields = actionTypeFieldsMap[action.actionTypeId] || [];
const visibleFields = allFields.filter((f) =>
isActionFieldVisible(f, action.fieldValues, allFields, fieldLookupOptionsMap)
);
if (allFields.length === 0) {
return <p className="text-xs text-gray-400 italic">No dynamic fields configured for this Action Type.</p>;
}
if (visibleFields.length === 0) {
return null;
}
// Group visible fields by section / group name
const groupedSectionsMap = new Map<string, typeof visibleFields>();
visibleFields.forEach((field) => {
const secName = field.section?.trim() || '';
const fields = groupedSectionsMap.get(secName) || [];
fields.push(field);
groupedSectionsMap.set(secName, fields);
});
return (
<div className="space-y-4">
{Array.from(groupedSectionsMap.entries()).map(([secName, secFields]) => (
<div key={secName || 'default-section'} className="space-y-3">
{secName && (
<div className="flex items-center gap-2 pt-1 border-b border-emerald-100 pb-1 mt-1">
<div className="w-2 h-2 rounded-full bg-[#1B9869]"></div>
<h5 className="text-[12px] font-bold text-[#14704E] tracking-wider uppercase">
{secName}
</h5>
</div>
)}
<div className="grid grid-cols-12 gap-4">
{secFields.map((field) => {
const widthClass = WIDTH_GRID_MAP[field.width || 'full'] || 'col-span-12';
const rawVal = action.fieldValues?.[field.fieldCode];
const defaultVal =
field.defaultValue ??
(field as any).default_value ??
(field as any).defaultValueJson ??
(field as any).default_value_json;
const effectiveVal =
rawVal !== undefined && rawVal !== null
? rawVal
: defaultVal;
const lookupOpts = fieldLookupOptionsMap[field.lookupSource || ''] || [];
const helpText = field.helpText || (field as any).help_text;
const valRules = field.validationJson || (field as any).validation_json;
const getBoolVal = (v: any) => {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') {
return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
}
return !!v;
};
const getMultiVal = (v: any): string[] => {
if (Array.isArray(v)) return v;
if (typeof v === 'string') {
if (v.trim().startsWith('[')) {
try {
const p = JSON.parse(v);
if (Array.isArray(p)) return p;
} catch { }
}
if (v.includes(',')) return v.split(',').map((s) => s.trim());
return v.trim() ? [v.trim()] : [];
}
return [];
};
const getCurrencyVal = (v: any) => {
let currObj = v;
if (typeof currObj === 'string' && currObj.trim().startsWith('{')) {
try { currObj = JSON.parse(currObj); } catch { }
}
const amt =
typeof currObj === 'object' && currObj !== null && 'amount' in currObj
? currObj.amount
: typeof currObj === 'object' && currObj !== null
? ''
: currObj ?? '';
const curr =
typeof currObj === 'object' && currObj !== null && 'currency' in currObj
? currObj.currency
: undefined;
return { amount: amt, currency: curr };
};
return (
<div key={field.id} className={widthClass}>
{field.fieldType === 'textarea' ? (
<CustomTextArea
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
value={effectiveVal !== undefined && effectiveVal !== null ? String(effectiveVal) : ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter details...'}
/>
) : field.fieldType === 'currency' ? (() => {
const currParsed = getCurrencyVal(effectiveVal);
return (
<div>
<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>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<CustomInput
validationJson={valRules}
type="number"
value={currParsed.amount || ''}
onChange={(e) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(currParsed || {}),
amount: e.target.value,
})
}
placeholder={field.placeholder || 'Amount'}
/>
</div>
<CustomDropdown
options={lookupOpts}
value={currParsed.currency}
onChange={(val) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(currParsed || {}),
currency: val,
})
}
/>
</div>
</div>
);
})() : field.fieldType === 'dropdown' ? (
<CustomDropdown
label={field.fieldName}
required={field.isRequired}
options={lookupOpts}
value={effectiveVal !== undefined && effectiveVal !== null ? String(effectiveVal) : ''}
onChange={(val) => handleFieldValueChange(rule.id, action.id, field.fieldCode, val)}
placeholder={field.placeholder || 'Select option...'}
/>
) : field.fieldType === 'multi_select' ? (
<CustomMultiSelect
label={field.fieldName}
required={field.isRequired}
options={lookupOpts}
value={getMultiVal(effectiveVal)}
onChange={(vals) => handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)}
placeholder={field.placeholder || 'Select multiple options...'}
/>
) : field.fieldType === 'checkbox' ? (
<CustomCheckBox
checked={getBoolVal(effectiveVal)}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
label={field.fieldName}
/>
) : field.fieldType === 'switch' ? (
<div>
<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>
<div className="flex items-center gap-3 pt-1">
<CustomSwitch
checked={getBoolVal(effectiveVal)}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
/>
<span className="text-[13px] font-medium text-slate-700">
{getBoolVal(effectiveVal) ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
) : (
<CustomInput
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
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={effectiveVal !== undefined && effectiveVal !== null ? String(effectiveVal) : ''}
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>
);
})()}
</div>
)}
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* ─── Sticky Footer ─────────────────────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-4 py-4 flex items-center justify-between z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-3">
<span className="text-[14px] font-semibold text-gray-600">Status:</span>
<CustomStatus status={status} />
</div>
<div className="flex items-center gap-3">
<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"
onClick={() => navigate('/policy-engine')}
disabled={isSaving}
>
Cancel Policy
</CustomButton>
<CustomButton
variant="primary"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
onClick={() => handleSavePolicy(true)}
disabled={isSaving || !isFormValid}
>
{isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'}
</CustomButton>
</div>
</div>
<CustomSuccessModal
isOpen={showSuccessModal}
onClose={() => {
setShowSuccessModal(false);
navigate('/policy-engine');
}}
title={successTitle}
label="POLICY NAME"
cohortName={policyName}
cohortStatus={status}
cohortDescription={description}
/>
</div>
);
}