Merge pull request 'waseem' (#30) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/30
This commit is contained in:
+4
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 608 B |
@@ -99,7 +99,7 @@ export function submitActionPayload(payload: ActionSubmissionPayload): Promise<a
|
||||
// ─── Master Data Options Loader ──────────────────────────────────────────────
|
||||
|
||||
export function getMasterDataOptions(categoryCode: string): Promise<{ label: string; value: string; id?: string }[]> {
|
||||
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCode}`)
|
||||
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
|
||||
.then((res) => {
|
||||
if (Array.isArray(res)) {
|
||||
return res.map((item) => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function ActionBuilderPage() {
|
||||
|
||||
const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([
|
||||
{ label: 'None (Manual / Free-text)', value: '' },
|
||||
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
|
||||
]);
|
||||
|
||||
// Loading & Notification States
|
||||
@@ -162,7 +161,6 @@ export default function ActionBuilderPage() {
|
||||
}));
|
||||
setMasterLookupOptions([
|
||||
{ label: 'None (Manual / Free-text)', value: '' },
|
||||
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
|
||||
...opts,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,22 @@ function normalizeItems(items: any[]): MasterDataItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Categories List Endpoint ───────────────────────────────────────────────
|
||||
// ─── Lookup Tables & Categories List Endpoint ─────────────────────────────────
|
||||
|
||||
export function getLookupTables(): Promise<MasterDataCategoryItem[]> {
|
||||
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/lookup-tables')
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export function getMasterCategories(): Promise<MasterDataCategoryItem[]> {
|
||||
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/categories')
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
return getLookupTables()
|
||||
.then((tables) => {
|
||||
if (Array.isArray(tables) && tables.length > 0) return tables;
|
||||
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/categories')
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
})
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
@@ -33,13 +44,9 @@ export function getMasterCategories(): Promise<MasterDataCategoryItem[]> {
|
||||
|
||||
export function getCategoryValues(categoryCode: string): Promise<MasterDataItem[]> {
|
||||
if (!categoryCode) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCode}`)
|
||||
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() =>
|
||||
ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() => []),
|
||||
);
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
// ─── Generic Master Data Item CRUD ──────────────────────────────────────────
|
||||
|
||||
@@ -121,11 +121,11 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 font-bold text-[#0F172B]">
|
||||
{item.value || item.label || item.name}
|
||||
{item.label || item.value}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 font-mono text-slate-500 text-[12px]">
|
||||
{item.code || '—'}
|
||||
{item.value || '—'}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4">
|
||||
@@ -182,11 +182,10 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`min-w-[32px] h-8 px-2 rounded-lg text-[13px] font-semibold transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-[#1E7D5C] text-white shadow-sm font-bold'
|
||||
: 'bg-white text-slate-600 border border-gray-200 hover:bg-slate-50'
|
||||
}`}
|
||||
className={`min-w-[32px] h-8 px-2 rounded-lg text-[13px] font-semibold transition-colors ${currentPage === page
|
||||
? 'bg-[#1E7D5C] text-white shadow-sm font-bold'
|
||||
: 'bg-white text-slate-600 border border-gray-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
|
||||
@@ -45,13 +45,9 @@ function normalizeItems(items: any[]): any[] {
|
||||
|
||||
function fetchCategoryValues(categoryCodeOrId: string): Promise<any[]> {
|
||||
if (!categoryCodeOrId) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCodeOrId}`)
|
||||
return ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() =>
|
||||
ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() => []),
|
||||
);
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
function fetchMastersCategories(): Promise<any[]> {
|
||||
@@ -187,17 +183,18 @@ export function getConditionOptions(categoryCodeOrId?: string): Promise<OptionIt
|
||||
}
|
||||
|
||||
export function getOperatorOptions(): Promise<OptionItem[]> {
|
||||
return fetchCategoryValues('operator')
|
||||
.then((items) =>
|
||||
(items || [])
|
||||
return ApiClient.get<any, any[]>('/master-data/operators')
|
||||
.then((res) => {
|
||||
const items = Array.isArray(res) ? res : [];
|
||||
return items
|
||||
.filter((i) => i.isActive !== false)
|
||||
.map((i) => ({
|
||||
label: i.label || i.name || i.value || '',
|
||||
label: i.name || i.symbol || i.label || i.code || '',
|
||||
value: i.id || i.code || i.value || '',
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
})),
|
||||
)
|
||||
}));
|
||||
})
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
@@ -294,3 +291,34 @@ export function updatePolicyStatus(id: string, status: string): Promise<PolicyEn
|
||||
export function deletePolicy(id: string): Promise<void> {
|
||||
return ApiClient.delete<any, void>(`/policy-engine/${id}`);
|
||||
}
|
||||
|
||||
// ─── 3-Level Metadata API Functions ──────────────────────────────────────────
|
||||
|
||||
export function getConditionGroupsForCategory(categoryCodeOrId: string): Promise<any[]> {
|
||||
if (!categoryCodeOrId) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/rule-categories/${categoryCodeOrId}/condition-groups`)
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export function getConditionFieldsForGroup(groupIdOrCode: string): Promise<any[]> {
|
||||
if (!groupIdOrCode) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/condition-groups/${groupIdOrCode}/fields`)
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export function getFieldLookupOptions(fieldIdOrCode: string): Promise<OptionItem[]> {
|
||||
if (!fieldIdOrCode) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/condition-fields/${fieldIdOrCode}/lookup-values`)
|
||||
.then((res) =>
|
||||
(res || []).map((i) => ({
|
||||
label: i.label || i.name || i.value || '',
|
||||
value: i.id || i.code || i.value || '',
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
})),
|
||||
)
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,3 +12,31 @@ export interface PaginatedPolicyEngineResponse {
|
||||
totalPages: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ConditionGroupItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ConditionFieldItem {
|
||||
id: string;
|
||||
groupId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
lookupTable?: string;
|
||||
dataType: 'STRING' | 'NUMBER' | 'BOOLEAN' | 'ENUM' | 'DATE';
|
||||
operatorType: 'COMPARISON' | 'TEXT' | 'SET' | 'BOOLEAN';
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface LookupOptionItem {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
value: string;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
getPolicy,
|
||||
createPolicy,
|
||||
updatePolicy,
|
||||
getConditionGroupsForCategory,
|
||||
getConditionFieldsForGroup,
|
||||
getFieldLookupOptions,
|
||||
type OptionItem,
|
||||
type ActionTypeField,
|
||||
} from '../PolicyEngineApi';
|
||||
@@ -75,7 +78,6 @@ type Rule = {
|
||||
const LOGIC_OPTIONS = [
|
||||
{ label: 'AND', value: 'AND' },
|
||||
{ label: 'OR', value: 'OR' },
|
||||
{ label: 'NOT', value: 'NOT' },
|
||||
];
|
||||
|
||||
// Per-gate color scheme (exact Figma tokens): AND=blue, OR=yellow, NOT=red.
|
||||
@@ -175,15 +177,76 @@ export default function AddPolicyEngine() {
|
||||
getActionCategoryOptions().then(setActionCategoryOptions);
|
||||
}, []);
|
||||
|
||||
const handleCategoryChange = (ruleId: string, category: string) => {
|
||||
handleUpdateRule(ruleId, { category });
|
||||
if (category && !categoryConditionMap[category]) {
|
||||
getConditionOptions(category).then((opts) => {
|
||||
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);
|
||||
fields.forEach((f: any) => {
|
||||
const val = f.id || f.code;
|
||||
allFields.push({
|
||||
label: `${g.name} › ${f.name}`,
|
||||
value: val,
|
||||
id: f.id,
|
||||
code: f.code,
|
||||
});
|
||||
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]: opts,
|
||||
[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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,22 +387,33 @@ export default function AddPolicyEngine() {
|
||||
const cat = matchedRuleCat ? matchedRuleCat.value : rawCat;
|
||||
|
||||
if (cat) {
|
||||
getConditionOptions(cat).then((opts) => {
|
||||
setCategoryConditionMap((prev) => ({ ...prev, [cat]: opts }));
|
||||
});
|
||||
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: 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) {
|
||||
@@ -454,9 +528,9 @@ export default function AddPolicyEngine() {
|
||||
targetAudiences:
|
||||
audienceType === 'Selected Cohorts'
|
||||
? selectedCohorts.map((cohortId) => ({
|
||||
targetType: 'COHORT',
|
||||
targetId: String(cohortId),
|
||||
}))
|
||||
targetType: 'COHORT',
|
||||
targetId: String(cohortId),
|
||||
}))
|
||||
: [],
|
||||
rules: rules.map((r, rIdx) => ({
|
||||
ruleCategoryId:
|
||||
@@ -862,69 +936,99 @@ export default function AddPolicyEngine() {
|
||||
<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) => (
|
||||
<div key={condition.id} className="flex items-end gap-3">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<CustomDropdown
|
||||
label='Condition'
|
||||
options={categoryConditionMap[rule.category] || []}
|
||||
value={condition.condition}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { condition: 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'
|
||||
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">
|
||||
<CustomInput
|
||||
label='Value'
|
||||
value={condition.value}
|
||||
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
{cIdx < rule.conditions.length - 1 ? (
|
||||
<div className="w-[163px] flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
|
||||
<LogicDropdown
|
||||
value={condition.logic}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
|
||||
{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'
|
||||
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="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 className="flex-1 flex flex-col gap-2">
|
||||
<CustomDropdown
|
||||
label='Operator'
|
||||
options={operatorOptions}
|
||||
value={condition.operator}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { operator: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</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 className="flex-1 flex flex-col gap-2">
|
||||
{isLookup ? (
|
||||
<CustomDropdown
|
||||
label='Value'
|
||||
options={lookupOpts}
|
||||
value={condition.value}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
|
||||
placeholder="Select Value..."
|
||||
/>
|
||||
) : isBoolean ? (
|
||||
<CustomDropdown
|
||||
label='Value'
|
||||
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'
|
||||
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</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ export function updateRecoveryIncident(id: string, data: Partial<RecoveryInciden
|
||||
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}`, data);
|
||||
}
|
||||
|
||||
export function updateIncidentStatus(id: string, status: string): Promise<RecoveryIncident> {
|
||||
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}/status`, { status });
|
||||
}
|
||||
|
||||
export function deleteRecoveryIncident(id: string): Promise<void> {
|
||||
return ApiClient.delete<any, void>(`/recovery-incidents/${id}`);
|
||||
}
|
||||
|
||||
@@ -180,11 +180,10 @@ export default function RecoveryIncidentsList() {
|
||||
|
||||
const handleStatusChange = async (incident: RecoveryIncident, text: string) => {
|
||||
try {
|
||||
const { updateRecoveryIncident } = await import('../RecoveryIncidentsApi');
|
||||
await updateRecoveryIncident(incident.id, {
|
||||
status: text
|
||||
});
|
||||
const { updateIncidentStatus, getRecoveryMetrics } = await import('../RecoveryIncidentsApi');
|
||||
await updateIncidentStatus(incident.id, text);
|
||||
fetchIncidents();
|
||||
getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => {});
|
||||
} catch (error) {
|
||||
console.error("Failed to update status", error);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function RecoveryPlanTab() {
|
||||
<CreditCardIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">FINANCIAL REFUND</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">REFUND AMOUNT</span>
|
||||
@@ -32,7 +32,7 @@ export default function RecoveryPlanTab() {
|
||||
<GiftIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">COMPENSATION & PERKS</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">CASH COMPENSATION</span>
|
||||
@@ -55,7 +55,7 @@ export default function RecoveryPlanTab() {
|
||||
<HandHeartIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">PASSENGER CARE</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">MEAL VOUCHERS</span>
|
||||
|
||||
@@ -1,37 +1,62 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon } from '@phosphor-icons/react';
|
||||
import { User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon } from '@phosphor-icons/react';
|
||||
import { CustomButton, CustomTabs, CustomBackButton, CustomStatus } from '../../../components/custom';
|
||||
import SummaryTab from './SummaryTab';
|
||||
import CaseDetailsTab from './CaseDetailsTab';
|
||||
import RecoveryPlanTab from './RecoveryPlanTab';
|
||||
import AuditTrailTab from './AuditTrailTab';
|
||||
import { SparkleIcon } from 'lucide-react';
|
||||
import { getRecoveryIncident } from '../RecoveryIncidentsApi';
|
||||
import { getRecoveryIncident, updateIncidentStatus } from '../RecoveryIncidentsApi';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" {
|
||||
if (!status) return "neutral";
|
||||
const s = status.toLowerCase();
|
||||
if (s.includes("appr") || s.includes("active") || s.includes("success")) return "success";
|
||||
if (s.includes("reject") || s.includes("denied") || s.includes("error")) return "error";
|
||||
if (s.includes("pend") || s.includes("review") || s.includes("warn")) return "warning";
|
||||
if (s.includes("new") || s.includes("info")) return "info";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export default function RecoveryIncidentTabs() {
|
||||
const { id } = useParams();
|
||||
const [activeTab, setActiveTab] = useState('Summary');
|
||||
const [incident, setIncident] = useState<RecoveryIncident | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const fetchIncident = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getRecoveryIncident(id);
|
||||
setIncident(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load incident:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
getRecoveryIncident(id)
|
||||
.then(data => {
|
||||
setIncident(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load incident:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
fetchIncident();
|
||||
}, [id]);
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
if (!id || updating) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const updated = await updateIncidentStatus(id, newStatus);
|
||||
setIncident(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to update incident status:", err);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
id: 'Summary',
|
||||
@@ -59,6 +84,8 @@ export default function RecoveryIncidentTabs() {
|
||||
return <div className="p-8 text-center text-gray-500">Loading incident...</div>;
|
||||
}
|
||||
|
||||
const currentStatus = incident?.status || "Pending";
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col h-full relative">
|
||||
{/* Header */}
|
||||
@@ -67,8 +94,11 @@ export default function RecoveryIncidentTabs() {
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomBackButton />
|
||||
<h1 className="text-xl font-bold text-gray-900">{incident?.recoveryCode || id}</h1>
|
||||
<CustomStatus status="UNDER REVIEW" variant="warning" className="uppercase !text-[11px] !px-2.5 !py-1 !tracking-wide" />
|
||||
<CustomStatus status="HIGH PRIORITY" variant="error" className="uppercase !text-[11px] !px-2.5 !py-1 !bg-red-100 !text-red-800 !tracking-wide" />
|
||||
<CustomStatus
|
||||
status={currentStatus}
|
||||
variant={getStatusVariant(currentStatus)}
|
||||
className="uppercase !text-[11px] !px-2.5 !py-1 !tracking-wide"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 ml-8">
|
||||
<div className="flex items-center gap-1.5"><User size={16} /> {incident?.passengerName || 'Unknown'}</div>
|
||||
@@ -87,14 +117,14 @@ export default function RecoveryIncidentTabs() {
|
||||
{/* Tabs Row */}
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<div className="flex-1">
|
||||
<CustomTabs
|
||||
tabs={tabItems}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
contentClassName="!hidden"
|
||||
/>
|
||||
<CustomTabs
|
||||
tabs={tabItems}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
contentClassName="!hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomButton variant="outlined" className="!border-[#1B9869] !text-[#1B9869] hover:!bg-green-50 !font-semibold !rounded-lg">
|
||||
Share with Finance
|
||||
@@ -109,94 +139,100 @@ export default function RecoveryIncidentTabs() {
|
||||
<div className="flex gap-6 items-start pb-24">
|
||||
{/* Left Column - Tab Content */}
|
||||
<div className="flex-1 bg-[#F8F9FA] rounded-[24px] p-3 border border-gray-100">
|
||||
<CustomTabs
|
||||
tabs={tabItems}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
tabListClassName="!hidden"
|
||||
contentClassName="!mt-0"
|
||||
/>
|
||||
<CustomTabs
|
||||
tabs={tabItems}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
tabListClassName="!hidden"
|
||||
contentClassName="!mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Sidebar */}
|
||||
<div className="w-[360px] flex-shrink-0 bg-[#F8F9FA] rounded-[16px] border border-gray-100 relative">
|
||||
|
||||
{/* Blur Overlay */}
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/20 backdrop-blur-[3px] rounded-[16px]">
|
||||
<h4 className="text-[18px] font-bold text-[#143d30] italic">"Coming soon"</h4>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="p-6 select-none pointer-events-none">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-[#1B9869]"><SparkleIcon size={20} height="fill" /></span>
|
||||
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider">AI RECOMMENDATION</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 tracking-wider">SATISFACTION PREDICT</span>
|
||||
<span className="text-sm font-bold text-[#1B9869]">84%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
|
||||
<div className="h-full bg-[#1B9869] w-[84%] rounded-full opacity-60"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Blur Overlay */}
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/20 backdrop-blur-[3px] rounded-[16px]">
|
||||
<h4 className="text-[18px] font-bold text-[#143d30] italic">"Coming soon"</h4>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 tracking-wider">ESCALATION RISK</span>
|
||||
<span className="text-sm font-bold text-blue-500">12%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
|
||||
<div className="h-full bg-blue-500 w-[12%] rounded-full opacity-60"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sidebar Content */}
|
||||
<div className="p-6 select-none pointer-events-none">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-[#1B9869]"><SparkleIcon size={20} height="fill" /></span>
|
||||
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider">AI RECOMMENDATION</h3>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider mb-4">NEXT RECOMMENDED ACTION</h3>
|
||||
<div className="bg-white rounded-xl p-5 mb-4 border border-gray-100 shadow-sm">
|
||||
<p className="text-sm text-gray-500 text-center">Approve the automated recovery payout of [250 EUR]. This will prevent a regulatory complaint and retain this high-value Platinum member.</p>
|
||||
</div>
|
||||
|
||||
<CustomButton
|
||||
disabled
|
||||
rightIcon={<ArrowLeftIcon size={16} weight="bold" className="rotate-180" />}
|
||||
className="w-full !py-3 !bg-[#1B9869]/50 !text-white !font-semibold !rounded-lg"
|
||||
>
|
||||
EXECUTE RECOMMENDATION
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 tracking-wider">SATISFACTION PREDICT</span>
|
||||
<span className="text-sm font-bold text-[#1B9869]">84%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
|
||||
<div className="h-full bg-[#1B9869] w-[84%] rounded-full opacity-60"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 tracking-wider">ESCALATION RISK</span>
|
||||
<span className="text-sm font-bold text-blue-500">12%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
|
||||
<div className="h-full bg-blue-500 w-[12%] rounded-full opacity-60"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider mb-4">NEXT RECOMMENDED ACTION</h3>
|
||||
<div className="bg-white rounded-xl p-5 mb-4 border border-gray-100 shadow-sm">
|
||||
<p className="text-sm text-gray-500 text-center">Approve the automated recovery payout of [250 EUR]. This will prevent a regulatory complaint and retain this high-value Platinum member.</p>
|
||||
</div>
|
||||
|
||||
<CustomButton
|
||||
disabled
|
||||
rightIcon={<ArrowLeftIcon size={16} weight="bold" className="rotate-180" />}
|
||||
className="w-full !py-3 !bg-[#1B9869]/50 !text-white !font-semibold !rounded-lg"
|
||||
>
|
||||
EXECUTE RECOMMENDATION
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Sticky Action Bar */}
|
||||
<div className="sticky bottom-[-24px] -mx-8 px-8 py-4 bg-white/90 backdrop-blur-md border-t border-gray-100 flex justify-between items-center z-10 mt-auto shadow-[0_-10px_20px_-10px_rgba(0,0,0,0.05)]">
|
||||
<div></div> {/* Spacer */}
|
||||
<div className="flex gap-4">
|
||||
<CustomButton
|
||||
variant="text"
|
||||
leftIcon={<X size={18} weight="bold" />}
|
||||
className="!bg-[#FDE8E8] !text-[#E02424] !font-bold !rounded-lg !border !border-[#E02424] hover:!bg-red-100"
|
||||
>
|
||||
REJECT RECOVERY
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="text"
|
||||
leftIcon={<ClockCounterClockwiseIcon size={18} weight="bold" />}
|
||||
className="!bg-[#FEF3C7] !text-[#B45309] !font-bold !rounded-lg !border !border-[#B45309] hover:!bg-[#FDE68A]"
|
||||
>
|
||||
MARK FOR REVIEW
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
leftIcon={<Checks size={18} weight="bold" />}
|
||||
className="!bg-[#1B9869] !bg-none !text-white !font-bold !rounded-lg !border !border-[#1B9869] hover:!bg-[#14704E]"
|
||||
>
|
||||
APPROVE RECOVERY
|
||||
</CustomButton>
|
||||
</div>
|
||||
<div></div> {/* Spacer */}
|
||||
<div className="flex gap-4">
|
||||
<CustomButton
|
||||
variant="text"
|
||||
disabled={updating}
|
||||
onClick={() => handleStatusChange("Rejected")}
|
||||
leftIcon={<X size={18} weight="bold" />}
|
||||
className="!bg-[#FDE8E8] !text-[#E02424] !font-bold !rounded-lg !border !border-[#E02424] hover:!bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
REJECT RECOVERY
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="text"
|
||||
disabled={updating}
|
||||
onClick={() => handleStatusChange("Under Review")}
|
||||
leftIcon={<ClockCounterClockwiseIcon size={18} weight="bold" />}
|
||||
className="!bg-[#FEF3C7] !text-[#B45309] !font-bold !rounded-lg !border !border-[#B45309] hover:!bg-[#FDE68A] disabled:opacity-50"
|
||||
>
|
||||
MARK FOR REVIEW
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
disabled={updating}
|
||||
onClick={() => handleStatusChange("Approved")}
|
||||
leftIcon={<Checks size={18} weight="bold" />}
|
||||
className="!bg-[#1B9869] !bg-none !text-white !font-bold !rounded-lg !border !border-[#1B9869] hover:!bg-[#14704E] disabled:opacity-50"
|
||||
>
|
||||
APPROVE RECOVERY
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user