Merge pull request 'waseem' (#29) from waseem into development

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/29
This commit is contained in:
Syed Waseem khadri Rafai
2026-08-07 10:56:42 +00:00
37 changed files with 2786 additions and 3388 deletions
+4 -5
View File
@@ -1,4 +1,4 @@
import { Route, Routes } from 'react-router-dom'
import { Route, Routes, Navigate } from 'react-router-dom'
import Layout from './layout/AppLayout'
import HomePage from './app/dashboard'
import CohortManage from './app/cohartManage'
@@ -6,8 +6,7 @@ import PolicyEngineList from './app/policyEngine/components/PolicyEngineList'
import AddPolicyEngine from './app/policyEngine/components/AddPolicyEngine'
import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIncidentsList'
import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index'
import ActionBuilderPage from './app/actionBuilder'
import MasterDataManagement from './app/masterData'
import ConfigurationPage from './app/configuration'
function AppRoutes() {
return (
@@ -18,8 +17,8 @@ function AppRoutes() {
<Route path="/policy-engine" element={<PolicyEngineList />} />
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
<Route path="/action-builder" element={<ActionBuilderPage />} />
<Route path="/config" element={<MasterDataManagement />} />
<Route path="/action-builder" element={<Navigate to="/config?tab=action-builder" replace />} />
<Route path="/config" element={<ConfigurationPage />} />
<Route path="/recovery" element={<RecoveryIncidentsList />} />
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
</Routes>
@@ -1,516 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
PlusIcon,
PencilSimpleIcon,
TrashIcon,
MagnifyingGlassIcon,
FolderPlusIcon,
CheckCircleIcon,
XCircleIcon,
HashIcon,
CaretLeftIcon,
CaretRightIcon,
} from '@phosphor-icons/react';
import {
CustomInput,
CustomButton,
CustomTextArea,
CustomSwitch,
CustomStatus,
CustomConfirmationModal,
CustomLoader,
CustomModal,
} from '../../../components/custom';
import {
getActionCategories,
getActionTypes,
createActionCategory,
updateActionCategory,
deleteActionCategory,
} from '../ActionBuilderApi';
import type { ActionCategory, ActionType, ActionCategoryFormData } from '../ActionBuilderTypes';
const PAGE_SIZE = 10;
export function ActionCategoryManager() {
const [categories, setCategories] = useState<ActionCategory[]>([]);
const [actionTypes, setActionTypes] = useState<ActionType[]>([]);
const [loading, setLoading] = useState<boolean>(true);
// Search & Pagination State
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState<number>(1);
// Modal Form State
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<ActionCategory | null>(null);
const [formData, setFormData] = useState<ActionCategoryFormData>({
code: '',
name: '',
description: '',
displayOrder: 1,
isActive: true,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Delete Modal State
const [deleteModalTarget, setDeleteModalTarget] = useState<ActionCategory | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
// Fetch Data
const fetchData = async () => {
setLoading(true);
try {
const [catsRes, typesRes] = await Promise.all([
getActionCategories().catch(() => []),
getActionTypes().catch(() => []),
]);
setCategories(Array.isArray(catsRes) ? catsRes : []);
setActionTypes(Array.isArray(typesRes) ? typesRes : []);
} catch (e) {
console.error('Failed to load categories/action-types API data', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
// Reset page number on search
const handleSearchChange = (value: string) => {
setSearchTerm(value);
setCurrentPage(1);
};
// Reset Form
const resetForm = () => {
setFormData({
code: '',
name: '',
description: '',
displayOrder: categories.length + 1,
isActive: true,
});
setEditingCategory(null);
setErrorMsg(null);
setIsFormOpen(false);
};
// Open Create Mode
const handleOpenCreate = () => {
setEditingCategory(null);
setFormData({
code: '',
name: '',
description: '',
displayOrder: categories.length + 1,
isActive: true,
});
setErrorMsg(null);
setIsFormOpen(true);
};
// Open Edit Mode
const handleOpenEdit = (category: ActionCategory) => {
setEditingCategory(category);
setFormData({
code: category.code,
name: category.name,
description: category.description || '',
displayOrder: category.displayOrder || 1,
isActive: category.isActive !== false,
});
setErrorMsg(null);
setIsFormOpen(true);
};
// Submit Handler (Add / Edit)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
setSuccessMsg(null);
if (!formData.name.trim()) {
setErrorMsg('Category Name is required.');
return;
}
if (!formData.code.trim()) {
setErrorMsg('Category Code is required.');
return;
}
try {
if (editingCategory) {
await updateActionCategory(editingCategory.id, formData);
setSuccessMsg(`Category "${formData.name}" updated successfully!`);
} else {
await createActionCategory(formData);
setSuccessMsg(`Category "${formData.name}" added successfully!`);
}
await fetchData();
resetForm();
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setErrorMsg(err.message || err.response?.data?.message || 'An error occurred while saving.');
}
};
// Confirm Delete
const handleConfirmDelete = async () => {
if (!deleteModalTarget) return;
setDeleteError(null);
const hasChildTypes = actionTypes.some((t) => t.categoryId === deleteModalTarget.id);
if (hasChildTypes) {
setDeleteError('Cannot delete category because it has associated Action Types.');
return;
}
try {
await deleteActionCategory(deleteModalTarget.id);
setDeleteModalTarget(null);
setSuccessMsg(`Category deleted successfully.`);
await fetchData();
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setDeleteError(err.message || err.response?.data?.message || 'Failed to delete category.');
}
};
// Filter & Pagination Calculations
const filteredCategories = categories.filter(
(c) =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
(c.description || '').toLowerCase().includes(searchTerm.toLowerCase())
);
const totalItems = filteredCategories.length;
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
const paginatedCategories = filteredCategories.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
return (
<div className="w-full space-y-6 font-sans">
{/* Top Banner / Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-5 rounded-[20px] border border-gray-100 shadow-sm">
<div>
<h3 className="text-[17px] font-bold text-[#0F172B] flex items-center gap-2">
<FolderPlusIcon size={22} className="text-[#1E7D5C]" />
Action Categories
</h3>
<p className="text-[13px] text-gray-500 mt-1">
Manage high-level action categories (e.g. Accommodation, Compensation, Rebooking)
</p>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="primary"
onClick={handleOpenCreate}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[10px] !gap-[10px] !h-[40px] font-semibold text-[14px]"
>
Add New Category
</CustomButton>
</div>
</div>
{/* Success Notification */}
{successMsg && (
<div className="p-4 bg-[#EAFAF5] border border-[#1E7D5C]/30 text-[#14704E] rounded-[12px] text-[14px] font-medium flex items-center gap-2 animate-fadeIn">
<CheckCircleIcon size={20} weight="fill" />
{successMsg}
</div>
)}
{/* Category CRUD Popup Modal (Add/Edit) */}
<CustomModal
isOpen={isFormOpen}
onClose={resetForm}
title={editingCategory ? `Edit Category: ${editingCategory.name}` : 'Create New Action Category'}
description="Provide category code, name, description, and display configuration."
size="lg"
>
{errorMsg && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{errorMsg}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.name}
onChange={(e) => {
const nameVal = e.target.value;
setFormData((prev) => ({
...prev,
name: nameVal,
code: editingCategory ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'),
}));
}}
placeholder="e.g. Passenger Compensation"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category Code <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. passenger-compensation"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))}
placeholder="1"
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Description
</label>
<CustomTextArea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Briefly describe what actions belong in this category..."
rows={3}
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={resetForm}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingCategory ? 'Save Changes' : 'Create Category'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
{/* Category List Table Card */}
<div className="bg-white rounded-[20px] border border-gray-100 shadow-sm overflow-hidden flex flex-col">
{/* Table Filter Bar */}
<div className="p-4 border-b border-gray-100 flex flex-col sm:flex-row items-center justify-between gap-4 bg-slate-50/50">
<div className="w-full sm:w-80">
<CustomInput
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search category by name or code..."
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
/>
</div>
<div className="flex items-center gap-3 text-[13px] font-semibold text-gray-500">
<span>Total Categories: <strong className="text-slate-900">{categories.length}</strong></span>
<span></span>
<span>Active: <strong className="text-[#1E7D5C]">{categories.filter((c) => c.isActive !== false).length}</strong></span>
</div>
</div>
{/* Table Content */}
{loading ? (
<div className="py-12 flex justify-center">
<CustomLoader label="Loading Categories..." />
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-100/70 border-b border-gray-200 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3.5 px-5 w-16">Order</th>
<th className="py-3.5 px-5">Category Name</th>
<th className="py-3.5 px-5">Code</th>
<th className="py-3.5 px-5">Description</th>
<th className="py-3.5 px-5 text-center">Action Types</th>
<th className="py-3.5 px-5">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{paginatedCategories.length === 0 ? (
<tr>
<td colSpan={7} className="py-10 text-center text-gray-400">
No categories found. Click "Add New Category" to create one.
</td>
</tr>
) : (
paginatedCategories.map((cat) => {
const childTypesCount = actionTypes.filter((t) => t.categoryId === cat.id).length;
return (
<tr key={cat.id} className="hover:bg-slate-50/80 transition-colors">
<td className="py-3.5 px-5 font-mono text-gray-400 font-semibold flex items-center gap-1">
<HashIcon size={14} />
{cat.displayOrder ?? 0}
</td>
<td className="py-3.5 px-5 font-bold text-slate-800">
{cat.name}
</td>
<td className="py-3.5 px-5">
<span className="inline-block px-2.5 py-0.5 rounded-full text-[11px] font-mono font-semibold bg-slate-100 text-slate-700 border border-slate-200">
{cat.code}
</span>
</td>
<td className="py-3.5 px-5 text-gray-600 max-w-xs truncate" title={cat.description}>
{cat.description || '—'}
</td>
<td className="py-3.5 px-5 text-center">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[12px] font-bold bg-[#E8F3EF] text-[#1E7D5C]">
{childTypesCount}
</span>
</td>
<td className="py-3.5 px-5">
<CustomStatus status={cat.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleOpenEdit(cat)}
title="Edit Category"
className="p-2 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
>
<PencilSimpleIcon size={17} weight="bold" />
</button>
<button
onClick={() => setDeleteModalTarget(cat)}
title="Delete Category"
className="p-2 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
<TrashIcon size={17} weight="bold" />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination Footer Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between py-3.5 px-5 bg-[#F8FAFC] border-t border-gray-200 text-[13px] text-gray-500 gap-3 mt-auto">
<div>
Showing <strong className="text-slate-800">{totalItems > 0 ? startIndex : 0}</strong> to{' '}
<strong className="text-slate-800">{endIndex}</strong> of <strong className="text-slate-800">{totalItems}</strong> categories
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-1.5 rounded-lg border border-gray-300 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(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'
}`}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-1.5 rounded-lg border border-gray-300 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretRightIcon size={16} />
</button>
</div>
)}
</div>
</>
)}
</div>
{/* Confirmation Modal for Delete Category */}
{deleteModalTarget && (
<CustomConfirmationModal
isOpen={!!deleteModalTarget}
title={`Delete Category: ${deleteModalTarget.name}`}
description={
deleteError
? deleteError
: `Are you sure you want to delete "${deleteModalTarget.name}"? This action cannot be undone.`
}
onConfirm={handleConfirmDelete}
onClose={() => {
setDeleteModalTarget(null);
setDeleteError(null);
}}
confirmText="Delete Category"
cancelText="Cancel"
variant="danger"
/>
)}
</div>
);
}
@@ -1,551 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
PlusIcon,
PencilSimpleIcon,
TrashIcon,
MagnifyingGlassIcon,
CirclesThreePlusIcon,
CheckCircleIcon,
XCircleIcon,
HashIcon,
CaretLeftIcon,
CaretRightIcon,
} from '@phosphor-icons/react';
import {
CustomInput,
CustomDropdown,
CustomButton,
CustomTextArea,
CustomSwitch,
CustomStatus,
CustomConfirmationModal,
CustomLoader,
CustomModal,
} from '../../../components/custom';
import {
getActionCategories,
getActionTypes,
createActionType,
updateActionType,
deleteActionType,
} from '../ActionBuilderApi';
import type { ActionCategory, ActionType, ActionTypeFormData } from '../ActionBuilderTypes';
const PAGE_SIZE = 10;
export function ActionTypeManager() {
const [categories, setCategories] = useState<ActionCategory[]>([]);
const [actionTypes, setActionTypes] = useState<ActionType[]>([]);
const [loading, setLoading] = useState<boolean>(true);
// Search, Category Filter & Pagination State
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategoryFilter, setSelectedCategoryFilter] = useState<string>('ALL');
const [currentPage, setCurrentPage] = useState<number>(1);
// Modal Form State
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingType, setEditingType] = useState<ActionType | null>(null);
const [formData, setFormData] = useState<ActionTypeFormData>({
categoryId: '',
code: '',
name: '',
description: '',
displayOrder: 1,
isActive: true,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Delete Modal State
const [deleteModalTarget, setDeleteModalTarget] = useState<ActionType | null>(null);
// Fetch Data
const fetchData = async () => {
setLoading(true);
try {
const [catsRes, typesRes] = await Promise.all([
getActionCategories().catch(() => []),
getActionTypes().catch(() => []),
]);
setCategories(Array.isArray(catsRes) ? catsRes : []);
setActionTypes(Array.isArray(typesRes) ? typesRes : []);
} catch (e) {
console.error('Failed loading action categories/types API data', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
// Filter change handler
const handleSearchChange = (val: string) => {
setSearchTerm(val);
setCurrentPage(1);
};
const handleCategoryFilterChange = (val: string) => {
setSelectedCategoryFilter(val);
setCurrentPage(1);
};
// Reset Form
const resetForm = () => {
setFormData({
categoryId: categories[0]?.id || '',
code: '',
name: '',
description: '',
displayOrder: actionTypes.length + 1,
isActive: true,
});
setEditingType(null);
setErrorMsg(null);
setIsFormOpen(false);
};
// Open Create Mode
const handleOpenCreate = () => {
setEditingType(null);
setFormData({
categoryId: categories[0]?.id || '',
code: '',
name: '',
description: '',
displayOrder: actionTypes.length + 1,
isActive: true,
});
setErrorMsg(null);
setIsFormOpen(true);
};
// Open Edit Mode
const handleOpenEdit = (actionType: ActionType) => {
setEditingType(actionType);
setFormData({
categoryId: actionType.categoryId,
code: actionType.code,
name: actionType.name,
description: actionType.description || '',
displayOrder: actionType.displayOrder || 1,
isActive: actionType.isActive !== false,
});
setErrorMsg(null);
setIsFormOpen(true);
};
// Submit Handler (Add / Edit)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
setSuccessMsg(null);
if (!formData.categoryId) {
setErrorMsg('Please select a Category.');
return;
}
if (!formData.name.trim()) {
setErrorMsg('Action Type Name is required.');
return;
}
if (!formData.code.trim()) {
setErrorMsg('Action Type Code is required.');
return;
}
try {
if (editingType) {
await updateActionType(editingType.id, formData);
setSuccessMsg(`Action Type "${formData.name}" updated successfully!`);
} else {
await createActionType(formData);
setSuccessMsg(`Action Type "${formData.name}" added successfully!`);
}
await fetchData();
resetForm();
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setErrorMsg(err.message || err.response?.data?.message || 'An error occurred while saving.');
}
};
// Confirm Delete
const handleConfirmDelete = async () => {
if (!deleteModalTarget) return;
try {
await deleteActionType(deleteModalTarget.id);
setDeleteModalTarget(null);
setSuccessMsg(`Action Type deleted successfully.`);
await fetchData();
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setErrorMsg(err.message || err.response?.data?.message || 'Failed to delete action type.');
}
};
// Options for Dropdowns
const categoryDropdownOptions = categories.map((c) => ({
label: `${c.name} (${c.code})`,
value: c.id,
}));
const filterCategoryDropdownOptions = [
{ label: 'All Categories', value: 'ALL' },
...categories.map((c) => ({ label: c.name, value: c.id })),
];
// Helper map for Category names
const categoryMap = new Map(categories.map((c) => [c.id, c.name]));
// Filter & Pagination Calculations
const filteredActionTypes = actionTypes.filter((t) => {
const matchesCategory = selectedCategoryFilter === 'ALL' || t.categoryId === selectedCategoryFilter;
const catName = t.categoryName || categoryMap.get(t.categoryId) || '';
const matchesSearch =
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
catName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(t.description || '').toLowerCase().includes(searchTerm.toLowerCase());
return matchesCategory && matchesSearch;
});
const totalItems = filteredActionTypes.length;
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
const paginatedActionTypes = filteredActionTypes.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
return (
<div className="w-full space-y-6 font-sans">
{/* Top Banner / Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-5 rounded-[20px] border border-gray-100 shadow-sm">
<div>
<h3 className="text-[17px] font-bold text-[#0F172B] flex items-center gap-2">
<CirclesThreePlusIcon size={22} className="text-[#1E7D5C]" />
Action Types
</h3>
<p className="text-[13px] text-gray-500 mt-1">
Manage action types within categories (e.g. Hotel Booking, Food Voucher, Flight Rebook)
</p>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="primary"
onClick={handleOpenCreate}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[10px] !gap-[10px] !h-[40px] font-semibold text-[14px]"
>
Add Action Type
</CustomButton>
</div>
</div>
{/* Success Notification */}
{successMsg && (
<div className="p-4 bg-[#EAFAF5] border border-[#1E7D5C]/30 text-[#14704E] rounded-[12px] text-[14px] font-medium flex items-center gap-2 animate-fadeIn">
<CheckCircleIcon size={20} weight="fill" />
{successMsg}
</div>
)}
{/* Action Type CRUD Popup Modal (Add/Edit) */}
<CustomModal
isOpen={isFormOpen}
onClose={resetForm}
title={editingType ? `Edit Action Type: ${editingType.name}` : 'Create New Action Type'}
description="Select category, configure type code, name, description, and status."
size="lg"
>
{errorMsg && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{errorMsg}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={categoryDropdownOptions}
value={formData.categoryId}
onChange={(val) => setFormData((prev) => ({ ...prev, categoryId: val }))}
placeholder="Select Category"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Action Type Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.name}
onChange={(e) => {
const nameVal = e.target.value;
setFormData((prev) => ({
...prev,
name: nameVal,
code: editingType ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'),
}));
}}
placeholder="e.g. Voucher Refund"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Action Type Code <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. voucher-refund"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-3">
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Description
</label>
<CustomTextArea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Provide details about how this action type is executed..."
rows={3}
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))}
placeholder="1"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={resetForm}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingType ? 'Save Changes' : 'Create Action Type'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
{/* Action Types Table Card */}
<div className="bg-white rounded-[20px] border border-gray-100 shadow-sm overflow-hidden flex flex-col">
{/* Table Filter Bar */}
<div className="p-4 border-b border-gray-100 flex flex-col sm:flex-row items-center justify-between gap-4 bg-slate-50/50">
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="w-48">
<CustomDropdown
options={filterCategoryDropdownOptions}
value={selectedCategoryFilter}
onChange={handleCategoryFilterChange}
/>
</div>
<div className="flex-1 sm:w-72">
<CustomInput
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search action types..."
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
/>
</div>
</div>
<div className="flex items-center gap-3 text-[13px] font-semibold text-gray-500">
<span>Total Types: <strong className="text-slate-900">{actionTypes.length}</strong></span>
<span></span>
<span>Filtered: <strong className="text-[#1E7D5C]">{filteredActionTypes.length}</strong></span>
</div>
</div>
{/* Table Content */}
{loading ? (
<div className="py-12 flex justify-center">
<CustomLoader label="Loading Action Types..." />
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-100/70 border-b border-gray-200 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3.5 px-5 w-16">Order</th>
<th className="py-3.5 px-5">Action Type Name</th>
<th className="py-3.5 px-5">Category</th>
<th className="py-3.5 px-5">Description</th>
<th className="py-3.5 px-5">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{paginatedActionTypes.length === 0 ? (
<tr>
<td colSpan={6} className="py-10 text-center text-gray-400">
No action types found. Select a category or click "Add Action Type" to create one.
</td>
</tr>
) : (
paginatedActionTypes.map((t) => {
const catName = t.categoryName || categoryMap.get(t.categoryId) || 'Uncategorized';
return (
<tr key={t.id} className="hover:bg-slate-50/80 transition-colors">
<td className="py-3.5 px-5 font-mono text-gray-400 font-semibold flex items-center gap-1">
<HashIcon size={14} />
{t.displayOrder ?? 0}
</td>
<td className="py-3.5 px-5">
<div className="font-bold text-slate-800">{t.name}</div>
<span className="text-[11px] font-mono text-slate-400">{t.code}</span>
</td>
<td className="py-3.5 px-5">
<span className="inline-block px-2.5 py-1 rounded-lg text-[12px] font-semibold bg-[#E8F3EF] text-[#14704E]">
{catName}
</span>
</td>
<td className="py-3.5 px-5 text-gray-600 max-w-xs truncate" title={t.description}>
{t.description || '—'}
</td>
<td className="py-3.5 px-5">
<CustomStatus status={t.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleOpenEdit(t)}
title="Edit Action Type"
className="p-2 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
>
<PencilSimpleIcon size={17} weight="bold" />
</button>
<button
onClick={() => setDeleteModalTarget(t)}
title="Delete Action Type"
className="p-2 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
<TrashIcon size={17} weight="bold" />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination Footer Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between py-3.5 px-5 bg-[#F8FAFC] border-t border-gray-200 text-[13px] text-gray-500 gap-3 mt-auto">
<div>
Showing <strong className="text-slate-800">{totalItems > 0 ? startIndex : 0}</strong> to{' '}
<strong className="text-slate-800">{endIndex}</strong> of <strong className="text-slate-800">{totalItems}</strong> action types
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-1.5 rounded-lg border border-gray-300 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(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'
}`}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-1.5 rounded-lg border border-gray-300 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretRightIcon size={16} />
</button>
</div>
)}
</div>
</>
)}
</div>
{/* Confirmation Modal for Delete Action Type */}
{deleteModalTarget && (
<CustomConfirmationModal
isOpen={!!deleteModalTarget}
title={`Delete Action Type: ${deleteModalTarget.name}`}
description={`Are you sure you want to delete "${deleteModalTarget.name}"? This action cannot be undone.`}
onConfirm={handleConfirmDelete}
onClose={() => setDeleteModalTarget(null)}
confirmText="Delete Action Type"
cancelText="Cancel"
variant="danger"
/>
)}
</div>
);
}
@@ -1,663 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
LightningIcon,
CheckCircleIcon,
XCircleIcon,
PaperPlaneRightIcon,
CodeIcon,
PlayIcon,
} from '@phosphor-icons/react';
import {
CustomInput,
CustomDropdown,
CustomButton,
CustomTextArea,
CustomSwitch,
CustomCheckBox,
CustomMultiSelect,
CustomLoader,
} from '../../../components/custom';
import {
getActionCategories,
getActionTypesByCategory,
getFieldDefinitions,
getMasterDataOptions,
submitActionPayload,
} from '../ActionBuilderApi';
import type {
ActionCategory,
ActionType,
FieldDefinition,
FieldWidth,
ActionSubmissionPayload,
ActionSubmissionValue,
} from '../ActionBuilderTypes';
const WIDTH_GRID_MAP: Record<FieldWidth, 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',
};
export const DEFAULT_CURRENCIES = [
{ label: 'INR (Indian Rupee)', value: 'INR' },
{ label: 'USD (US Dollar)', value: 'USD' },
{ label: 'EUR (Euro)', value: 'EUR' },
{ label: 'GBP (British Pound)', value: 'GBP' },
{ label: 'AED (UAE Dirham)', value: 'AED' },
];
export function DynamicFormRenderer() {
const [categories, setCategories] = useState<ActionCategory[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [actionTypes, setActionTypes] = useState<ActionType[]>([]);
const [selectedActionTypeId, setSelectedActionTypeId] = useState<string>('');
const [fields, setFields] = useState<FieldDefinition[]>([]);
const [loadingFields, setLoadingFields] = useState<boolean>(false);
// Form values state map: key = fieldCode, value = field value
const [formValues, setFormValues] = useState<Record<string, any>>({});
// Master options cache: key = lookupSource, value = options list
const [lookupOptionsMap, setLookupOptionsMap] = useState<Record<string, { label: string; value: string }[]>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState<boolean>(false);
const [submissionResult, setSubmissionResult] = useState<any>(null);
const [apiError, setApiError] = useState<string | null>(null);
// Load categories
useEffect(() => {
getActionCategories().then((res) => {
const activeCats = (res || []).filter((c) => c.isActive !== false);
setCategories(activeCats);
});
}, []);
// Load Action Types when Category changes
useEffect(() => {
if (!selectedCategoryId) {
setActionTypes([]);
setSelectedActionTypeId('');
setFields([]);
return;
}
getActionTypesByCategory(selectedCategoryId).then((types) => {
const activeTypes = (types || []).filter((t) => t.isActive !== false);
setActionTypes(activeTypes);
setSelectedActionTypeId('');
setFields([]);
});
}, [selectedCategoryId]);
// Load Field definitions and lookup sources
useEffect(() => {
if (!selectedActionTypeId) return;
setLoadingFields(true);
setFormErrors({});
setSubmissionResult(null);
setApiError(null);
getFieldDefinitions(selectedActionTypeId).then(async (res) => {
const activeFields = (res || []).filter((f) => f.isActive !== false);
setFields(activeFields);
// Initialize default values
const initialValues: Record<string, any> = {};
const sourcesToFetch = new Set<string>();
activeFields.forEach((f) => {
if (f.defaultValue !== undefined && f.defaultValue !== '') {
initialValues[f.fieldCode] = f.defaultValue;
} else if (f.fieldType === 'checkbox' || f.fieldType === 'switch') {
initialValues[f.fieldCode] = false;
} else if (f.fieldType === 'multi_select') {
initialValues[f.fieldCode] = [];
} else if (f.fieldType === 'currency') {
initialValues[f.fieldCode] = { amount: '', currency: 'INR' };
} else {
initialValues[f.fieldCode] = '';
}
if (f.lookupSource) {
sourcesToFetch.add(f.lookupSource);
}
});
setFormValues(initialValues);
// Fetch lookup options from master data
const optsMap: Record<string, { label: string; value: string }[]> = {};
for (const src of Array.from(sourcesToFetch)) {
if (src === 'CURRENCIES') {
optsMap[src] = DEFAULT_CURRENCIES;
} else {
const opts = await getMasterDataOptions(src);
optsMap[src] = opts.length > 0 ? opts : [
{ label: `${src} Option 1`, value: `${src}_OPT_1` },
{ label: `${src} Option 2`, value: `${src}_OPT_2` },
];
}
}
setLookupOptionsMap(optsMap);
setLoadingFields(false);
});
}, [selectedActionTypeId]);
// Helper to normalize strings for comparison (case-insensitive, ignores spaces/hyphens/underscores)
const normalizeStr = (val: any): string => {
if (val === undefined || val === null) return '';
if (typeof val === 'object' && !Array.isArray(val)) {
val = val.amount || val.value || JSON.stringify(val);
}
return String(val)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '');
};
// Helper to get dependent field value flexibly by key or normalized key
const getFieldValue = (fieldCodeKey: string): any => {
if (formValues[fieldCodeKey] !== undefined) return formValues[fieldCodeKey];
const normKey = normalizeStr(fieldCodeKey);
const matchingKey = Object.keys(formValues).find(
(k) => normalizeStr(k) === normKey,
);
return matchingKey ? formValues[matchingKey] : undefined;
};
// Helper to check visibility of a field definition
const checkVisibility = (f: FieldDefinition): boolean => {
if (!f.visibilityConditionJson || !f.visibilityConditionJson.field) return true;
const { field: depField, operator, value: targetVal } = f.visibilityConditionJson;
const depRawVal = getFieldValue(depField);
// Find dependent field definition to check if it has lookup options
const depFieldDef = fields.find(
(item) => normalizeStr(item.fieldCode) === normalizeStr(depField),
);
// Get options if available
let depOptions: { label: string; value: string }[] = [];
if (depFieldDef?.lookupSource && lookupOptionsMap[depFieldDef.lookupSource]) {
depOptions = lookupOptionsMap[depFieldDef.lookupSource];
}
// Collect candidate values (both code and human label)
const candidates: string[] = [];
if (depRawVal !== undefined && depRawVal !== null && depRawVal !== '') {
if (Array.isArray(depRawVal)) {
depRawVal.forEach((v) => candidates.push(String(v)));
} else if (typeof depRawVal === 'object') {
if (depRawVal.amount) candidates.push(String(depRawVal.amount));
if (depRawVal.currency) candidates.push(String(depRawVal.currency));
if (depRawVal.value) candidates.push(String(depRawVal.value));
} else {
candidates.push(String(depRawVal));
}
// Also add matching option label & value from lookup map
depOptions.forEach((opt) => {
if (
normalizeStr(opt.value) === normalizeStr(depRawVal) ||
normalizeStr(opt.label) === normalizeStr(depRawVal)
) {
candidates.push(opt.label);
candidates.push(opt.value);
}
});
}
const normTarget = normalizeStr(targetVal);
const op = (operator || 'equals').toLowerCase();
if (op === 'equals') {
return candidates.some((c) => normalizeStr(c) === normTarget);
} else if (op === 'not_equals') {
return !candidates.some((c) => normalizeStr(c) === normTarget);
} else if (op === 'contains') {
return candidates.some(
(c) => normalizeStr(c).includes(normTarget) || normTarget.includes(normalizeStr(c)),
);
} else if (op === 'is_empty') {
return candidates.length === 0 || candidates.every((c) => c.trim() === '');
} else if (op === 'not_empty') {
return candidates.length > 0 && candidates.some((c) => c.trim() !== '');
}
return candidates.some((c) => normalizeStr(c) === normTarget);
};
const handleValueChange = (code: string, value: any) => {
setFormValues((prev) => ({ ...prev, [code]: value }));
if (formErrors[code]) {
setFormErrors((prev) => ({ ...prev, [code]: '' }));
}
};
// Frontend Validation
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
fields.forEach((f) => {
if (!checkVisibility(f)) return; // Skip hidden fields
const val = formValues[f.fieldCode];
if (f.isRequired) {
if (
val === undefined ||
val === null ||
(typeof val === 'string' && val.trim() === '') ||
(Array.isArray(val) && val.length === 0) ||
(f.fieldType === 'currency' && (!val.amount || String(val.amount).trim() === ''))
) {
errors[f.fieldCode] = `"${f.fieldName}" is required.`;
return;
}
}
if (val !== undefined && val !== null && val !== '') {
const vRules = f.validationJson || {};
if (vRules.min !== undefined) {
if (typeof val === 'number' && val < vRules.min) {
errors[f.fieldCode] = `Must be at least ${vRules.min}.`;
} else if (typeof val === 'string' && val.length < vRules.min) {
errors[f.fieldCode] = `Must be at least ${vRules.min} characters.`;
}
}
if (vRules.max !== undefined) {
if (typeof val === 'number' && val > vRules.max) {
errors[f.fieldCode] = `Cannot exceed ${vRules.max}.`;
} else if (typeof val === 'string' && val.length > vRules.max) {
errors[f.fieldCode] = `Cannot exceed ${vRules.max} characters.`;
}
}
if (vRules.regex && typeof val === 'string') {
try {
const reg = new RegExp(vRules.regex);
if (!reg.test(val)) {
errors[f.fieldCode] = `Format is invalid.`;
}
} catch (e) {
// Ignored
}
}
}
});
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
// Submit Handler
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setApiError(null);
setSubmissionResult(null);
if (!validateForm()) return;
const values: ActionSubmissionValue[] = [];
fields.filter(checkVisibility).forEach((field) => {
const value = formValues[field.fieldCode];
if (value === undefined || value === null || value === '') return;
const base = { field_definition_id: field.id };
if (field.fieldType === 'currency') {
const amt = typeof value === 'object' && value !== null ? value.amount : value;
const curr = typeof value === 'object' && value !== null ? (value.currency || value.id) : undefined;
values.push({
...base,
number_value: amt !== undefined && amt !== null && amt !== '' ? Number(amt) : undefined,
selected_value_id: curr ? String(curr) : undefined,
});
} else if (field.fieldType === 'dropdown' || field.fieldType === 'radio') {
const valStr = typeof value === 'object' && value !== null ? String(value.value || value.id || '') : String(value);
values.push({
...base,
selected_value_id: valStr,
text_value: valStr,
});
} else if (field.fieldType === 'multi_select') {
(Array.isArray(value) ? value : []).forEach((item, valueIndex) => {
const itemStr = typeof item === 'object' && item !== null ? String(item.value || item.id || '') : String(item);
values.push({
...base,
value_index: valueIndex,
selected_value_id: itemStr,
text_value: itemStr,
});
});
} else if (field.fieldType === 'checkbox' || field.fieldType === 'switch') {
values.push({ ...base, boolean_value: Boolean(value) });
} else if (field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage') {
values.push({ ...base, number_value: Number(value) });
} else if (field.fieldType === 'date') {
values.push({ ...base, date_value: String(value) });
} else if (field.fieldType === 'time') {
values.push({ ...base, time_value: String(value) });
} else if (field.fieldType === 'datetime') {
values.push({ ...base, timestamp_value: String(value) });
} else if (field.lookupSource) {
const valStr = typeof value === 'object' && value !== null ? String(value.value || value.id || '') : String(value);
values.push({
...base,
selected_value_id: valStr,
text_value: valStr,
});
} else {
values.push({
...base,
text_value: typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value),
});
}
});
const payload: ActionSubmissionPayload = {
category_id: selectedCategoryId,
action_type_id: selectedActionTypeId,
values,
};
setSubmitting(true);
try {
const res = await submitActionPayload(payload);
setSubmissionResult({ payload, response: res });
} catch (err: any) {
setApiError(err.message || err.response?.data?.message || 'Action submission failed on backend validation.');
} finally {
setSubmitting(false);
}
};
// Group visible fields by section
const visibleFields = fields.filter(checkVisibility);
const sections = Array.from(new Set(visibleFields.map((f) => f.section || 'General Information')));
const selectedCategoryObj = categories.find((c) => c.id === selectedCategoryId);
const selectedTypeObj = actionTypes.find((t) => t.id === selectedActionTypeId);
return (
<div className="w-full space-y-6 font-sans">
{/* Top Control Bar */}
<div className="bg-white p-5 rounded-[20px] border border-gray-100 shadow-sm space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h3 className="text-[17px] font-bold text-[#0F172B] flex items-center gap-2">
<PlayIcon size={22} className="text-[#1E7D5C]" weight="fill" />
Dynamic User Form Test & Live Renderer
</h3>
<p className="text-[13px] text-gray-500 mt-1">
Select Category & Action Type to dynamically render configured user fields and test submission validation.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-3 border-t border-gray-100">
<div>
<label className="block text-[12px] font-bold text-slate-600 uppercase tracking-wider mb-1.5">
Select Category
</label>
<CustomDropdown
options={categories.map((c) => ({ label: `${c.name} (${c.code})`, value: c.id }))}
value={selectedCategoryId}
onChange={(val) => setSelectedCategoryId(val)}
/>
</div>
<div>
<label className="block text-[12px] font-bold text-slate-600 uppercase tracking-wider mb-1.5">
Select Action Type
</label>
<CustomDropdown
options={actionTypes.map((t) => ({ label: `${t.name} (${t.code})`, value: t.id }))}
value={selectedActionTypeId}
onChange={(val) => setSelectedActionTypeId(val)}
placeholder="Select Action Type"
/>
</div>
</div>
</div>
{/* Main Dynamic Form Card */}
<div className="bg-white rounded-[20px] border border-gray-100 shadow-sm overflow-hidden">
<div className="p-5 border-b border-gray-100 bg-slate-50/70 flex items-center justify-between">
<div className="flex items-center gap-2 text-[15px] font-bold text-slate-800">
<LightningIcon size={20} className="text-[#1E7D5C]" weight="fill" />
<span>Form for: </span>
<span className="text-[#1E7D5C]">
{selectedTypeObj ? `${selectedTypeObj.name} (${selectedCategoryObj?.name})` : 'Select Action Type'}
</span>
</div>
<span className="text-[12px] font-semibold text-slate-500">
Active Controls: <strong className="text-slate-900">{visibleFields.length}</strong>
</span>
</div>
{loadingFields ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading Dynamic Form Fields..." />
</div>
) : visibleFields.length === 0 ? (
<div className="py-16 text-center text-slate-400 space-y-2">
<p className="text-[14px]">No active fields configured for this Action Type.</p>
<p className="text-[12px] text-slate-400">Use the "Configuration Field" tab to add input fields.</p>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 space-y-8">
{apiError && (
<div className="p-4 bg-red-50 border border-red-200 text-[#D40000] rounded-[12px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={20} weight="fill" />
{apiError}
</div>
)}
{/* Sections */}
{sections.map((sectionName) => {
const sectionFields = visibleFields.filter((f) => (f.section || 'General Information') === sectionName);
return (
<div key={sectionName} className="space-y-4">
<div className="border-b border-slate-200 pb-2">
<h4 className="text-[15px] font-bold text-slate-800">{sectionName}</h4>
</div>
{/* 12-Column Grid Layout */}
<div className="grid grid-cols-12 gap-5">
{sectionFields.map((field) => {
const widthClass = WIDTH_GRID_MAP[field.width || 'full'];
const fieldErr = formErrors[field.fieldCode];
const lookupOpts = lookupOptionsMap[field.lookupSource || ''] || [];
return (
<div key={field.id} className={widthClass}>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{/* Control Renderer */}
{field.fieldType === 'textarea' ? (
<CustomTextArea
value={formValues[field.fieldCode] || ''}
onChange={(e) => handleValueChange(field.fieldCode, e.target.value)}
placeholder={field.placeholder}
rows={3}
/>
) : field.fieldType === 'currency' ? (
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<CustomInput
type="number"
value={formValues[field.fieldCode]?.amount || ''}
onChange={(e) =>
handleValueChange(field.fieldCode, {
...formValues[field.fieldCode],
amount: e.target.value,
})
}
placeholder={field.placeholder || 'Amount'}
/>
</div>
<CustomDropdown
options={lookupOpts.length > 0 ? lookupOpts : DEFAULT_CURRENCIES}
value={formValues[field.fieldCode]?.currency || 'INR'}
onChange={(val) =>
handleValueChange(field.fieldCode, {
...formValues[field.fieldCode],
currency: val,
})
}
/>
</div>
) : field.fieldType === 'dropdown' ? (
<CustomDropdown
options={lookupOpts}
value={formValues[field.fieldCode] || ''}
onChange={(val) => handleValueChange(field.fieldCode, val)}
placeholder={field.placeholder || 'Select option...'}
/>
) : field.fieldType === 'radio' ? (
<div className="flex flex-wrap gap-4 pt-1">
{lookupOpts.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer text-[13px] font-medium text-slate-700">
<input
type="radio"
name={field.fieldCode}
value={opt.value}
checked={formValues[field.fieldCode] === opt.value}
onChange={(e) => handleValueChange(field.fieldCode, e.target.value)}
className="w-4 h-4 text-[#1E7D5C] focus:ring-[#1E7D5C]"
/>
{opt.label}
</label>
))}
</div>
) : field.fieldType === 'multi_select' ? (
<CustomMultiSelect
options={lookupOpts}
value={Array.isArray(formValues[field.fieldCode]) ? formValues[field.fieldCode] : []}
onChange={(vals) => handleValueChange(field.fieldCode, vals)}
placeholder={field.placeholder || 'Select multiple options...'}
/>
) : field.fieldType === 'checkbox' ? (
<CustomCheckBox
checked={!!formValues[field.fieldCode]}
onChange={(e) => handleValueChange(field.fieldCode, e.target.checked)}
label={field.helpText || field.fieldName}
/>
) : field.fieldType === 'switch' ? (
<div className="flex items-center gap-3">
<CustomSwitch
checked={!!formValues[field.fieldCode]}
onChange={(e) => handleValueChange(field.fieldCode, e.target.checked)}
/>
<span className="text-[13px] font-medium text-slate-700">
{formValues[field.fieldCode] ? 'Enabled' : 'Disabled'}
</span>
</div>
) : field.fieldType === 'color' ? (
<input
type="color"
value={formValues[field.fieldCode] || '#1E7D5C'}
onChange={(e) => handleValueChange(field.fieldCode, e.target.value)}
className="h-10 w-20 p-1 bg-white border border-gray-200 rounded-[10px] cursor-pointer"
/>
) : field.fieldType === 'formula' ? (
<div className="p-2.5 bg-slate-100 border border-slate-200 rounded-[10px] text-[13px] font-mono font-semibold text-slate-700">
Computed Formula (Read-only)
</div>
) : (
<CustomInput
type={
field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage'
? 'number'
: field.fieldType === 'email'
? 'email'
: field.fieldType === 'date'
? 'date'
: field.fieldType === 'time'
? 'time'
: field.fieldType === 'datetime'
? 'datetime-local'
: 'text'
}
value={formValues[field.fieldCode] ?? ''}
onChange={(e) => handleValueChange(field.fieldCode, e.target.value)}
placeholder={field.placeholder}
/>
)}
{field.helpText && field.fieldType !== 'checkbox' && (
<p className="text-[11px] text-slate-400 mt-1">{field.helpText}</p>
)}
{fieldErr && (
<p className="text-[12px] text-red-600 font-medium mt-1 flex items-center gap-1">
<XCircleIcon size={14} weight="fill" />
{fieldErr}
</p>
)}
</div>
);
})}
</div>
</div>
);
})}
{/* Submission Bar */}
<div className="flex items-center justify-end pt-6 border-t border-slate-200 gap-4">
<CustomButton
variant="primary"
type="submit"
loading={submitting}
leftIcon={<PaperPlaneRightIcon size={18} weight="fill" />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] font-semibold px-8 py-3 text-[15px] shadow-sm"
>
Submit Action Payload
</CustomButton>
</div>
</form>
)}
</div>
{/* Submission Output Payload Card */}
{submissionResult && (
<div className="bg-slate-900 rounded-[20px] p-6 text-white shadow-xl space-y-4 animate-slideDown">
<div className="flex items-center justify-between border-b border-slate-700 pb-3">
<h4 className="text-[16px] font-bold flex items-center gap-2 text-emerald-400">
<CheckCircleIcon size={22} weight="fill" />
Action Payload Submitted & Verified Successfully!
</h4>
<span className="text-xs font-mono text-slate-400">API: POST /api/actions</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<span className="block text-[11px] font-bold uppercase text-slate-400 mb-1.5 flex items-center gap-1">
<CodeIcon size={14} /> Submitted JSON Payload
</span>
<pre className="p-4 bg-slate-950 rounded-[12px] font-mono text-[12px] text-emerald-300 overflow-x-auto border border-slate-800">
{JSON.stringify(submissionResult.payload, null, 2)}
</pre>
</div>
<div>
<span className="block text-[11px] font-bold uppercase text-slate-400 mb-1.5 flex items-center gap-1">
<CheckCircleIcon size={14} /> Backend Response
</span>
<pre className="p-4 bg-slate-950 rounded-[12px] font-mono text-[12px] text-blue-300 overflow-x-auto border border-slate-800">
{JSON.stringify(submissionResult.response, null, 2)}
</pre>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,853 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
PlusIcon,
PencilSimpleIcon,
TrashIcon,
SlidersIcon,
CheckCircleIcon,
XCircleIcon,
CaretUpIcon,
CaretDownIcon,
EyeIcon,
} from '@phosphor-icons/react';
import {
CustomInput,
CustomDropdown,
CustomButton,
CustomTextArea,
CustomSwitch,
CustomStatus,
CustomConfirmationModal,
CustomLoader,
CustomModal,
CustomCheckBox,
} from '../../../components/custom';
import {
getActionCategories,
getActionTypesByCategory,
getFieldDefinitions,
createFieldDefinition,
updateFieldDefinition,
deleteFieldDefinition,
reorderFieldDefinitions,
} from '../ActionBuilderApi';
import { getMasterCategories } from '../../masterData/MasterDataApi';
import type {
ActionCategory,
ActionType,
FieldDefinition,
FieldDefinitionFormData,
FieldType,
FieldWidth,
} from '../ActionBuilderTypes';
const FIELD_TYPE_OPTIONS: { label: string; value: FieldType }[] = [
{ label: 'Text Box (Single Line)', value: 'textbox' },
{ label: 'Text Area (Multi Line)', value: 'textarea' },
{ label: 'Email Address', value: 'email' },
{ label: 'Phone Number', value: 'phone' },
{ label: 'URL / Link', value: 'url' },
{ label: 'Number (Integer)', value: 'number' },
{ label: 'Decimal / Price', value: 'decimal' },
{ label: 'Percentage (%)', value: 'percentage' },
{ label: 'Currency (Amount + Currency)', value: 'currency' },
{ label: 'Dropdown Selection', value: 'dropdown' },
{ label: 'Radio Selection', value: 'radio' },
{ label: 'Multi-Select Checkboxes', value: 'multi_select' },
{ label: 'Single Checkbox', value: 'checkbox' },
{ label: 'Toggle Switch', value: 'switch' },
{ label: 'Date Picker', value: 'date' },
{ label: 'Date & Time Picker', value: 'datetime' },
{ label: 'Time Picker', value: 'time' },
{ label: 'Color Picker', value: 'color' },
{ label: 'Formula (Read-only Computed)', value: 'formula' },
];
const WIDTH_OPTIONS: { label: string; value: FieldWidth }[] = [
{ label: 'Full Width (12/12)', value: 'full' },
{ label: 'Half Width (6/12)', value: 'half' },
{ label: 'One-Third Width (4/12)', value: 'third' },
{ label: 'Two-Thirds Width (8/12)', value: 'two_thirds' },
];
export function FieldBuilderManager() {
const [categories, setCategories] = useState<ActionCategory[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [actionTypes, setActionTypes] = useState<ActionType[]>([]);
const [selectedActionTypeId, setSelectedActionTypeId] = useState<string>('');
const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([
{ label: 'None (Manual / Free-text)', value: '' },
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
]);
const [fields, setFields] = useState<FieldDefinition[]>([]);
const [loading, setLoading] = useState<boolean>(false);
// Form Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingField, setEditingField] = useState<FieldDefinition | null>(null);
const [formData, setFormData] = useState<FieldDefinitionFormData>({
fieldCode: '',
fieldName: '',
fieldType: '' as FieldType,
lookupSource: '',
isRequired: false,
defaultValue: '',
placeholder: '',
helpText: '',
width: 'full',
section: 'General Information',
displayOrder: 1,
isActive: true,
validationJson: { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: { field: '', operator: 'equals', value: '' },
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Delete Modal State
const [deleteTarget, setDeleteTarget] = useState<FieldDefinition | null>(null);
// Load categories and master table categories for lookup dropdown
useEffect(() => {
getActionCategories().then((res) => {
const activeCats = (res || []).filter((c) => c.isActive !== false);
setCategories(activeCats);
});
getMasterCategories()
.then((masterCats) => {
if (Array.isArray(masterCats) && masterCats.length > 0) {
const opts = masterCats.map((c) => ({
label: `${c.name} (${c.code})`,
value: c.code,
}));
setMasterLookupOptions([
{ label: 'None (Manual / Free-text)', value: '' },
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
...opts,
]);
}
})
.catch((e) => {
console.error('Failed to load master lookup categories', e);
});
}, []);
// Load Action Types when Category changes
useEffect(() => {
if (!selectedCategoryId) {
setActionTypes([]);
setSelectedActionTypeId('');
setFields([]);
return;
}
getActionTypesByCategory(selectedCategoryId).then((types) => {
const activeTypes = (types || []).filter((t) => t.isActive !== false);
setActionTypes(activeTypes);
setSelectedActionTypeId('');
setFields([]);
});
}, [selectedCategoryId]);
// Load Fields when Action Type changes
const fetchFields = async (atId: string) => {
if (!atId) return;
setLoading(true);
try {
const res = await getFieldDefinitions(atId);
setFields(res || []);
} catch (e) {
console.error('Failed to load field definitions', e);
setFields([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (selectedActionTypeId) {
fetchFields(selectedActionTypeId);
}
}, [selectedActionTypeId]);
const resetForm = () => {
setFormData({
fieldCode: '',
fieldName: '',
fieldType: '' as FieldType,
lookupSource: '',
isRequired: false,
defaultValue: '',
placeholder: '',
helpText: '',
width: 'full',
section: 'General Information',
displayOrder: fields.length + 1,
isActive: true,
validationJson: { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: { field: '', operator: 'equals', value: '' },
});
setEditingField(null);
setErrorMsg(null);
setIsModalOpen(false);
};
const handleOpenCreate = () => {
if (!selectedActionTypeId) {
alert('Please select an Action Type first.');
return;
}
setEditingField(null);
setFormData({
fieldCode: '',
fieldName: '',
fieldType: '' as FieldType,
lookupSource: '',
isRequired: false,
defaultValue: '',
placeholder: '',
helpText: '',
width: 'full',
section: 'General Information',
displayOrder: fields.length + 1,
isActive: true,
validationJson: { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: { field: '', operator: 'equals', value: '' },
});
setErrorMsg(null);
setIsModalOpen(true);
};
const handleOpenEdit = (field: FieldDefinition) => {
setEditingField(field);
setFormData({
fieldCode: field.fieldCode,
fieldName: field.fieldName,
fieldType: field.fieldType,
lookupSource: field.lookupSource || '',
isRequired: field.isRequired,
defaultValue: field.defaultValue || '',
placeholder: field.placeholder || '',
helpText: field.helpText || '',
width: field.width || 'full',
section: field.section || 'General Information',
displayOrder: field.displayOrder || 1,
isActive: field.isActive !== false,
validationJson: field.validationJson || { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: field.visibilityConditionJson || { field: '', operator: 'equals', value: '' },
});
setErrorMsg(null);
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
setSuccessMsg(null);
if (!formData.fieldName.trim()) {
setErrorMsg('Field Name is required.');
return;
}
if (!formData.fieldCode.trim()) {
setErrorMsg('Field Code is required.');
return;
}
// Clean up empty validation / visibility json before payload
const payloadData: FieldDefinitionFormData = {
...formData,
fieldCode: formData.fieldCode.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
validationJson:
formData.validationJson?.min !== undefined ||
formData.validationJson?.max !== undefined ||
formData.validationJson?.regex
? formData.validationJson
: undefined,
visibilityConditionJson:
formData.visibilityConditionJson?.field && formData.visibilityConditionJson?.value
? formData.visibilityConditionJson
: undefined,
};
try {
if (editingField) {
await updateFieldDefinition(editingField.id, payloadData);
setSuccessMsg(`Field "${formData.fieldName}" updated successfully!`);
} else {
await createFieldDefinition(selectedActionTypeId, payloadData);
setSuccessMsg(`Field "${formData.fieldName}" created successfully!`);
}
await fetchFields(selectedActionTypeId);
resetForm();
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setErrorMsg(err.message || err.response?.data?.message || 'An error occurred saving field.');
}
};
const handleConfirmDelete = async () => {
if (!deleteTarget) return;
try {
await deleteFieldDefinition(deleteTarget.id);
setDeleteTarget(null);
setSuccessMsg('Field definition deleted successfully.');
await fetchFields(selectedActionTypeId);
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setErrorMsg(err.message || 'Failed to delete field definition.');
}
};
const handleMove = async (index: number, direction: 'up' | 'down') => {
const newFields = [...fields];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newFields.length) return;
const temp = newFields[index];
newFields[index] = newFields[targetIndex];
newFields[targetIndex] = temp;
setFields(newFields);
try {
await reorderFieldDefinitions(selectedActionTypeId, newFields.map((f) => f.id));
} catch (e) {
fetchFields(selectedActionTypeId);
}
};
const selectedTypeObj = actionTypes.find((t) => t.id === selectedActionTypeId);
return (
<div className="w-full space-y-6 font-sans">
{/* Top Banner / Selectors */}
<div className="bg-white p-5 rounded-[20px] border border-gray-100 shadow-sm space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h3 className="text-[17px] font-bold text-[#0F172B] flex items-center gap-2">
<SlidersIcon size={22} className="text-[#1E7D5C]" />
Action Type Configuration Fields
</h3>
<p className="text-[13px] text-gray-500 mt-1">
Define dynamic user form fields, layout widths, validation constraints, and visibility rules.
</p>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="primary"
onClick={handleOpenCreate}
disabled={!selectedActionTypeId}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[10px] !gap-[10px] !h-[40px] font-semibold text-[14px]"
>
Add Field Definition
</CustomButton>
</div>
</div>
{/* Category & Action Type Pickers */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-3 border-t border-gray-100">
<div>
<label className="block text-[12px] font-bold text-slate-600 uppercase tracking-wider mb-1.5">
1. Select Action Category
</label>
<CustomDropdown
options={categories.map((c) => ({ label: `${c.name} (${c.code})`, value: c.id }))}
value={selectedCategoryId}
onChange={(val) => setSelectedCategoryId(val)}
placeholder="Select Category"
/>
</div>
<div>
<label className="block text-[12px] font-bold text-slate-600 uppercase tracking-wider mb-1.5">
2. Select Action Type
</label>
<CustomDropdown
options={actionTypes.map((t) => ({ label: `${t.name} (${t.code})`, value: t.id }))}
value={selectedActionTypeId}
onChange={(val) => setSelectedActionTypeId(val)}
placeholder={actionTypes.length === 0 ? 'No Action Types in Category' : 'Select Action Type'}
/>
</div>
</div>
</div>
{/* Success Banner */}
{successMsg && (
<div className="p-4 bg-[#EAFAF5] border border-[#1E7D5C]/30 text-[#14704E] rounded-[12px] text-[14px] font-medium flex items-center gap-2 animate-fadeIn">
<CheckCircleIcon size={20} weight="fill" />
{successMsg}
</div>
)}
{/* Fields List Card */}
<div className="bg-white rounded-[20px] border border-gray-100 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-slate-50/50 flex items-center justify-between">
<div className="flex items-center gap-2 text-[14px] font-bold text-slate-800">
<span>Configured Fields for:</span>
<span className="text-[#1E7D5C]">
{selectedTypeObj ? `${selectedTypeObj.name} (${selectedTypeObj.code})` : 'Select an Action Type'}
</span>
</div>
<span className="text-[13px] font-semibold text-slate-500">
Total Fields: <strong className="text-slate-900">{fields.length}</strong>
</span>
</div>
{loading ? (
<div className="py-12 flex justify-center">
<CustomLoader label="Loading Field Definitions..." />
</div>
) : fields.length === 0 ? (
<div className="py-12 text-center text-slate-400 space-y-2">
<p className="text-[14px]">No field definitions configured for this Action Type yet.</p>
<p className="text-[12px] text-slate-400">Click "Add Field Definition" above to add dynamic inputs.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-100/70 border-b border-gray-200 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3.5 px-5 w-16">Reorder</th>
<th className="py-3.5 px-5">Field Name & Code</th>
<th className="py-3.5 px-5">Control Type</th>
<th className="py-3.5 px-5">Width & Section</th>
<th className="py-3.5 px-5">Master Lookup</th>
<th className="py-3.5 px-5">Validation & Visibility</th>
<th className="py-3.5 px-5">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{fields.map((f, idx) => (
<tr key={f.id} className="hover:bg-slate-50/80 transition-colors">
<td className="py-3.5 px-5">
<div className="flex items-center gap-1">
<button
onClick={() => handleMove(idx, 'up')}
disabled={idx === 0}
className="p-1 text-slate-400 hover:text-slate-700 disabled:opacity-30"
>
<CaretUpIcon size={14} weight="bold" />
</button>
<button
onClick={() => handleMove(idx, 'down')}
disabled={idx === fields.length - 1}
className="p-1 text-slate-400 hover:text-slate-700 disabled:opacity-30"
>
<CaretDownIcon size={14} weight="bold" />
</button>
<span className="font-mono text-xs text-slate-400">#{f.displayOrder}</span>
</div>
</td>
<td className="py-3.5 px-5">
<div className="font-bold text-slate-800 flex items-center gap-2">
{f.fieldName}
{f.isRequired && (
<span className="text-[10px] font-bold uppercase tracking-wider bg-red-100 text-red-700 px-1.5 py-0.5 rounded">
Required
</span>
)}
</div>
<span className="text-[11px] font-mono text-slate-400">{f.fieldCode}</span>
</td>
<td className="py-3.5 px-5">
<span className="inline-block px-2.5 py-1 rounded-lg text-[12px] font-mono font-semibold bg-blue-50 text-blue-700 border border-blue-100">
{f.fieldType}
</span>
</td>
<td className="py-3.5 px-5 text-slate-600">
<div className="text-[12px] font-medium">{f.width || 'full'} width</div>
<div className="text-[11px] text-slate-400">{f.section || 'General'}</div>
</td>
<td className="py-3.5 px-5">
{f.lookupSource ? (
<span className="inline-block px-2 py-0.5 rounded text-[11px] font-mono bg-purple-50 text-purple-700 border border-purple-100">
{f.lookupSource}
</span>
) : (
<span className="text-slate-400 text-xs"></span>
)}
</td>
<td className="py-3.5 px-5 space-y-1">
{f.visibilityConditionJson?.field && (
<div className="flex items-center gap-1 text-[11px] text-amber-700 bg-amber-50 px-2 py-0.5 rounded border border-amber-100">
<EyeIcon size={12} />
<span>Visible if {f.visibilityConditionJson.field} = {String(f.visibilityConditionJson.value)}</span>
</div>
)}
{f.validationJson && (f.validationJson.min !== undefined || f.validationJson.max !== undefined) && (
<div className="text-[11px] font-mono text-slate-500">
Min: {f.validationJson.min ?? '—'} | Max: {f.validationJson.max ?? '—'}
</div>
)}
</td>
<td className="py-3.5 px-5">
<CustomStatus status={f.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleOpenEdit(f)}
className="p-2 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
title="Edit Field Definition"
>
<PencilSimpleIcon size={17} weight="bold" />
</button>
<button
onClick={() => setDeleteTarget(f)}
className="p-2 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete Field Definition"
>
<TrashIcon size={17} weight="bold" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Field Definition Add/Edit Modal */}
<CustomModal
isOpen={isModalOpen}
onClose={resetForm}
title={editingField ? `Edit Field: ${editingField.fieldName}` : 'Create Field Definition'}
description="Configure field code, label, control type, section layout, validation rules, and visibility."
size="lg"
>
{errorMsg && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{errorMsg}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
{/* Section 1: Basic Identifiers */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Label / Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldName}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
fieldName: val,
fieldCode: editingField ? prev.fieldCode : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Payout Amount"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Code (JSON Key) <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldCode}
onChange={(e) => setFormData((prev) => ({ ...prev, fieldCode: e.target.value }))}
placeholder="e.g. payout_amount"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Control Type <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={FIELD_TYPE_OPTIONS}
value={formData.fieldType}
onChange={(val) => setFormData((prev) => ({ ...prev, fieldType: val as FieldType }))}
placeholder="Select Control Type..."
/>
</div>
</div>
{/* Section 2: Master Lookup & Layout Width */}
{(() => {
const needsLookup = ['dropdown', 'radio', 'multi_select', 'currency'].includes(formData.fieldType);
return (
<div className={`grid grid-cols-1 ${needsLookup ? 'md:grid-cols-3' : 'md:grid-cols-2'} gap-4`}>
{needsLookup && (
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Master Data Lookup Source <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={masterLookupOptions}
value={formData.lookupSource || ''}
onChange={(val) => setFormData((prev) => ({ ...prev, lookupSource: val }))}
placeholder="Select Master Data Lookup Source..."
/>
</div>
)}
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Form Width Layout
</label>
<CustomDropdown
options={WIDTH_OPTIONS}
value={formData.width}
onChange={(val) => setFormData((prev) => ({ ...prev, width: val as FieldWidth }))}
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Section Group Name
</label>
<CustomInput
value={formData.section || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, section: e.target.value }))}
placeholder="e.g. Financial Details"
/>
</div>
</div>
);
})()}
{/* Section 3: Help & Defaults */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Placeholder</label>
<CustomInput
value={formData.placeholder || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, placeholder: e.target.value }))}
placeholder="e.g. Enter amount..."
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Default Value</label>
<CustomInput
value={formData.defaultValue || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))}
placeholder="e.g. 600"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Help Text / Instructions</label>
<CustomTextArea
value={formData.helpText || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, helpText: e.target.value }))}
placeholder="e.g. Max allowed compensation payout in selected currency"
rows={2}
/>
</div>
{/* Section 4: Advanced Validation JSON */}
<div className="p-4 bg-slate-50 border border-slate-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-slate-800">Validation Rules (validation_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Min Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
min: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 50"
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Max Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
max: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 2000"
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Regex Pattern</label>
<CustomInput
value={formData.validationJson?.regex || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
regex: e.target.value,
},
}))
}
placeholder="e.g. ^[A-Z0-9]+$"
/>
</div>
</div>
</div>
{/* Section 5: Conditional Visibility JSON */}
{(() => {
const dependentFieldOptions = [
{ label: 'None (No Dependency)', value: '' },
...fields
.filter((f) => !editingField || (f.id !== editingField.id && f.fieldCode !== editingField.fieldCode))
.map((f) => ({
label: `${f.fieldName} (${f.fieldCode})`,
value: f.fieldCode,
})),
];
return (
<div className="p-4 bg-amber-50/60 border border-amber-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-amber-900">Conditional Visibility (visibility_condition_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Depends on Field Code</label>
<CustomDropdown
options={dependentFieldOptions}
value={formData.visibilityConditionJson?.field || ''}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { operator: 'equals', value: '' }),
field: val,
},
}))
}
placeholder="Select dependent field..."
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Operator</label>
<CustomDropdown
options={[
{ label: 'Equals', value: 'equals' },
{ label: 'Not Equals', value: 'not_equals' },
{ label: 'Contains', value: 'contains' },
]}
value={formData.visibilityConditionJson?.operator || 'equals'}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', value: '' }),
operator: val as any,
},
}))
}
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Target Value</label>
<CustomInput
value={formData.visibilityConditionJson?.value || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', operator: 'equals' }),
value: e.target.value,
},
}))
}
placeholder="e.g. BANK_TRANSFER"
/>
</div>
</div>
</div>
);
})()}
{/* Controls Footer */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-4">
<CustomCheckBox
checked={formData.isRequired}
onChange={(e) => setFormData((prev) => ({ ...prev, isRequired: e.target.checked }))}
label="Is Required Field"
/>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Active</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
</div>
<div className="flex items-center gap-3">
<CustomButton variant="outlined" type="button" onClick={resetForm} className="!border-gray-300 !text-gray-600 px-5">
Cancel
</CustomButton>
<CustomButton variant="primary" type="submit" className="!bg-[#1E7D5C] hover:!bg-[#17664B] px-6">
{editingField ? 'Save Changes' : 'Create Field'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
{/* Delete Confirmation Modal */}
{deleteTarget && (
<CustomConfirmationModal
isOpen={!!deleteTarget}
title={`Delete Field: ${deleteTarget.fieldName}`}
description={`Are you sure you want to delete field "${deleteTarget.fieldName}" (${deleteTarget.fieldCode})?`}
onConfirm={handleConfirmDelete}
onClose={() => setDeleteTarget(null)}
confirmText="Delete Field"
cancelText="Cancel"
variant="danger"
/>
)}
</div>
);
}
@@ -1,42 +0,0 @@
import { useState } from 'react';
import { CustomModal, CustomTabs } from '../../../components/custom';
import { ActionCategoryManager } from './ActionCategoryManager';
import { ActionTypeManager } from './ActionTypeManager';
interface ManageCategoriesTypesModalProps {
isOpen: boolean;
onClose: () => void;
defaultTab?: 'categories' | 'types';
}
export function ManageCategoriesTypesModal({
isOpen,
onClose,
defaultTab = 'categories',
}: ManageCategoriesTypesModalProps) {
const [activeTab, setActiveTab] = useState<string>(defaultTab);
const tabs = [
{ id: 'categories', label: '1. Action Categories CRUD', content: <ActionCategoryManager /> },
{ id: 'types', label: '2. Action Types CRUD', content: <ActionTypeManager /> },
];
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
size="xl"
title="Action Builder Configuration: Categories & Types Manager"
description="Create, view, update, and delete action categories and their associated action types."
showCloseButton={true}
>
<div className="py-2 space-y-6">
<CustomTabs
tabs={tabs}
value={activeTab}
onChange={(tabId) => setActiveTab(tabId)}
/>
</div>
</CustomModal>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { useState, useEffect } from 'react';
import { LightningIcon } from '@phosphor-icons/react';
import { CustomTabs } from '../../components/custom';
import { ActionCategoryManager } from './components/ActionCategoryManager';
import { ActionTypeManager } from './components/ActionTypeManager';
import { FieldBuilderManager } from './components/FieldBuilderManager';
import { DynamicFormRenderer } from './components/DynamicFormRenderer';
import { getActionCategories, getActionTypes } from './ActionBuilderApi';
export default function ActionBuilderPage() {
const [categoriesCount, setCategoriesCount] = useState<number>(0);
const [typesCount, setTypesCount] = useState<number>(0);
const [activeTab, setActiveTab] = useState('categories');
useEffect(() => {
Promise.all([
getActionCategories().catch(() => []),
getActionTypes().catch(() => []),
]).then(([cats, types]) => {
setCategoriesCount((cats || []).length);
setTypesCount((types || []).length);
});
}, [activeTab]);
const tabs = [
{
id: 'categories',
label: 'Action Categories',
content: <ActionCategoryManager />,
},
{
id: 'types',
label: 'Action Types',
content: <ActionTypeManager />,
},
{
id: 'configuration_fields',
label: 'Configuration Field',
content: <FieldBuilderManager />,
},
{
id: 'dynamic_user_form',
label: 'Dynamic User Form',
content: <DynamicFormRenderer />,
},
];
return (
<div className="w-full space-y-6 font-sans">
{/* Top Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-[20px] border border-gray-100 shadow-sm">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-[16px] bg-[#E8F3EF] text-[#1E7D5C] flex items-center justify-center shrink-0 shadow-inner">
<LightningIcon size={26} weight="fill" />
</div>
<div>
<h1 className="text-2xl font-extrabold text-[#0F172B]">Action Builder</h1>
<p className="text-[14px] text-slate-500 mt-1">
Configure dynamic categories, action types, metadata fields, and dynamic user form workflows
</p>
</div>
</div>
{/* Stats Pills */}
<div className="flex items-center gap-3">
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">Categories</span>
<span className="text-lg font-black text-[#1E7D5C]">{categoriesCount}</span>
</div>
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">Action Types</span>
<span className="text-lg font-black text-[#1E7D5C]">{typesCount}</span>
</div>
</div>
</div>
{/* Main Tabs Navigation */}
<div>
<CustomTabs
tabs={tabs}
value={activeTab}
onChange={(tabId) => setActiveTab(tabId)}
/>
</div>
</div>
);
}
@@ -22,8 +22,8 @@ import {
getRevenueSegments,
getRegions,
getTripPurposes,
} from '../../masterData/MasterDataApi';
import type { MasterDataItem } from '../../masterData/MasterDataTypes';
} from '../../configuration/masterData/MasterDataApi';
import type { MasterDataItem } from '../../configuration/masterData/MasterDataTypes';
import type {
CohartResponse,
CreateCohartPayload,
@@ -1,4 +1,4 @@
import { ApiClient } from '../api/ApiClient';
import { ApiClient } from '../../api/ApiClient';
import type {
ActionCategory,
ActionType,
@@ -0,0 +1,150 @@
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomDropdown,
CustomTextArea,
CustomSwitch,
CustomButton,
} from '../../../../components/custom';
import type { ActionCategory, ActionType, ActionTypeFormData } from '../ActionBuilderTypes';
interface ActionTypeFormModalProps {
isOpen: boolean;
editingType: ActionType | null;
categories: ActionCategory[];
formData: ActionTypeFormData;
setFormData: React.Dispatch<React.SetStateAction<ActionTypeFormData>>;
error: string | null;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
export function ActionTypeFormModal({
isOpen,
editingType,
categories,
formData,
setFormData,
error,
onClose,
onSubmit,
}: ActionTypeFormModalProps) {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingType ? `Edit Action Type: ${editingType.name}` : 'Create New Action Type'}
description="Select category, configure type code, name, description, and status."
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
)}
<form onSubmit={onSubmit} className="space-y-5 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={categories.map((c) => ({ label: `${c.name} (${c.code})`, value: c.id }))}
value={formData.categoryId}
onChange={(val) => setFormData((prev) => ({ ...prev, categoryId: val }))}
placeholder="Select Category"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Action Type Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.name}
onChange={(e) => {
const nameVal = e.target.value;
setFormData((prev) => ({
...prev,
name: nameVal,
code: editingType ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '_'),
}));
}}
placeholder="e.g. Award Miles"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Action Type Code <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. award_miles"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-3">
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Description
</label>
<CustomTextArea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Provide details about how this action type is executed..."
rows={3}
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))}
placeholder="1"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingType ? 'Save Changes' : 'Create Action Type'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
}
@@ -0,0 +1,140 @@
import { useState } from 'react';
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
import { CustomInput, CustomButton } from '../../../../components/custom';
import type { ActionType } from '../ActionBuilderTypes';
interface ActionTypesColumnProps {
actionTypes: ActionType[];
selectedCategoryId: string;
selectedActionTypeId: string;
onSelectActionType: (id: string) => void;
fieldCountMap: Record<string, number>;
onOpenAdd: () => void;
onOpenEdit: (type: ActionType, e: React.MouseEvent) => void;
onDelete: (type: ActionType, e: React.MouseEvent) => void;
}
export function ActionTypesColumn({
actionTypes,
selectedCategoryId,
selectedActionTypeId,
onSelectActionType,
fieldCountMap,
onOpenAdd,
onOpenEdit,
onDelete,
}: ActionTypesColumnProps) {
const [search, setSearch] = useState('');
const categoryActionTypes = actionTypes.filter((t) => t.categoryId === selectedCategoryId);
const filteredActionTypes = categoryActionTypes.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-white p-4 rounded-[20px] border border-gray-200/70 shadow-sm space-y-4 flex flex-col min-h-[600px]">
{/* Column Header */}
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-bold text-[#0F172B]">Action Types</h3>
<CustomButton
variant="outlined"
size="sm"
onClick={onOpenAdd}
disabled={!selectedCategoryId}
leftIcon={<PlusIcon size={15} weight="bold" />}
>
Add
</CustomButton>
</div>
{/* Search Input */}
<div className="relative">
<CustomInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search Type by name or code..."
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]"
containerClassName="!gap-0"
/>
</div>
{/* Action Types Cards List */}
<div className="space-y-2.5 flex-1 overflow-y-auto max-h-[580px] pr-0.5">
{!selectedCategoryId ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
Select a category to view action types.
</div>
) : filteredActionTypes.length === 0 ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
No action types in this category. Click "+ Add" to create one.
</div>
) : (
filteredActionTypes.map((t) => {
const isSelected = t.id === selectedActionTypeId;
const fieldCount = fieldCountMap[t.id] ?? 0;
return (
<div
key={t.id}
onClick={() => onSelectActionType(t.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'
}`}
>
<div className="pr-3 space-y-0.5 min-w-0">
<div className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{t.name}
</div>
<div className={`font-mono text-[11px] uppercase tracking-wider ${isSelected ? 'text-white/80' : 'text-slate-400'}`}>
{t.code}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Hover Actions */}
<div className="hidden group-hover:flex items-center gap-1">
<button
type="button"
onClick={(e) => onOpenEdit(t, e)}
title="Edit Action Type"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-[#1E7D5C] hover:bg-white'
}`}
>
<PencilSimpleIcon size={15} weight="bold" />
</button>
<button
type="button"
onClick={(e) => onDelete(t, e)}
title="Delete Action Type"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-red-600 hover:bg-white'
}`}
>
<TrashIcon size={15} weight="bold" />
</button>
</div>
{/* Count Badge */}
<div
className={`w-7 h-7 rounded-full font-bold text-[12px] flex items-center justify-center ${isSelected
? 'bg-white/20 text-white'
: 'bg-[#E8F3EF] text-[#1E7D5C]'
}`}
>
{fieldCount}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { useState } from 'react';
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
import { CustomInput, CustomButton } from '../../../../components/custom';
import type { ActionCategory, ActionType } from '../ActionBuilderTypes';
interface CategoriesColumnProps {
categories: ActionCategory[];
selectedCategoryId: string;
onSelectCategory: (id: string) => void;
actionTypes: ActionType[];
onOpenAdd: () => void;
onOpenEdit: (cat: ActionCategory, e: React.MouseEvent) => void;
onDelete: (cat: ActionCategory, e: React.MouseEvent) => void;
}
export function CategoriesColumn({
categories,
selectedCategoryId,
onSelectCategory,
actionTypes,
onOpenAdd,
onOpenEdit,
onDelete,
}: CategoriesColumnProps) {
const [search, setSearch] = useState('');
const filteredCategories = categories.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-white p-4 rounded-[20px] border border-gray-200/70 shadow-sm space-y-4 flex flex-col min-h-[600px]">
{/* Column Header */}
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-bold text-[#0F172B]">Categories</h3>
<CustomButton
variant="outlined"
size="sm"
onClick={onOpenAdd}
leftIcon={<PlusIcon size={15} weight="bold" />}
>
Add
</CustomButton>
</div>
{/* Search Input */}
<div className="relative">
<CustomInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search Category by name or code..."
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]"
containerClassName="!gap-0"
/>
</div>
{/* Category Cards List */}
<div className="space-y-2.5 flex-1 overflow-y-auto max-h-[580px] pr-0.5">
{filteredCategories.length === 0 ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
No categories found.
</div>
) : (
filteredCategories.map((cat) => {
const isSelected = cat.id === selectedCategoryId;
const childCount = actionTypes.filter((t) => t.categoryId === cat.id).length;
return (
<div
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'
}`}
>
<div className="pr-3 space-y-0.5 min-w-0">
<div className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{cat.name}
</div>
<div className={`font-mono text-[11px] uppercase tracking-wider ${isSelected ? 'text-white/80' : 'text-slate-400'}`}>
{cat.code}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Hover Actions */}
<div className="hidden group-hover:flex items-center gap-1">
<button
type="button"
onClick={(e) => onOpenEdit(cat, e)}
title="Edit Category"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-[#1E7D5C] hover:bg-white'
}`}
>
<PencilSimpleIcon size={15} weight="bold" />
</button>
<button
type="button"
onClick={(e) => onDelete(cat, e)}
title="Delete Category"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-red-600 hover:bg-white'
}`}
>
<TrashIcon size={15} weight="bold" />
</button>
</div>
{/* Count Badge */}
<div
className={`w-7 h-7 rounded-full font-bold text-[12px] flex items-center justify-center ${isSelected
? 'bg-white/20 text-white'
: 'bg-[#E8F3EF] text-[#1E7D5C]'
}`}
>
{childCount}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,133 @@
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomTextArea,
CustomSwitch,
CustomButton,
} from '../../../../components/custom';
import type { ActionCategory, ActionCategoryFormData } from '../ActionBuilderTypes';
interface CategoryFormModalProps {
isOpen: boolean;
editingCategory: ActionCategory | null;
formData: ActionCategoryFormData;
setFormData: React.Dispatch<React.SetStateAction<ActionCategoryFormData>>;
error: string | null;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
export function CategoryFormModal({
isOpen,
editingCategory,
formData,
setFormData,
error,
onClose,
onSubmit,
}: CategoryFormModalProps) {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingCategory ? `Edit Category: ${editingCategory.name}` : 'Create New Action Category'}
description="Provide category code, name, description, and display configuration."
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.name}
onChange={(e) => {
const nameVal = e.target.value;
setFormData((prev) => ({
...prev,
name: nameVal,
code: editingCategory ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'),
}));
}}
placeholder="e.g. Refunds"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category Code <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. refunds"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))}
placeholder="1"
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Description
</label>
<CustomTextArea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Briefly describe what actions belong in this category..."
rows={3}
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingCategory ? 'Save Changes' : 'Create Category'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
}
@@ -0,0 +1,274 @@
import { useState } from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
PencilSimpleLineIcon,
TrashIcon,
DotsSixVerticalIcon,
} from '@phosphor-icons/react';
import { CustomInput, CustomLoader, CustomButton } from '../../../../components/custom';
import type { FieldDefinition } from '../ActionBuilderTypes';
interface ConfigurationFieldsColumnProps {
fields: FieldDefinition[];
selectedActionTypeId: string;
loading: boolean;
onOpenAdd: () => void;
onOpenEdit: (field: FieldDefinition) => void;
onDelete: (field: FieldDefinition) => void;
onReorderFields?: (fromIndex: number, toIndex: number) => void;
}
export function ConfigurationFieldsColumn({
fields,
selectedActionTypeId,
loading,
onOpenAdd,
onOpenEdit,
onDelete,
onReorderFields,
}: ConfigurationFieldsColumnProps) {
const [search, setSearch] = useState('');
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const filteredFields = fields.filter(
(f) =>
f.fieldName.toLowerCase().includes(search.toLowerCase()) ||
f.fieldCode.toLowerCase().includes(search.toLowerCase()) ||
(f.section || '').toLowerCase().includes(search.toLowerCase())
);
const fieldNameByCode = new Map(fields.map((f) => [f.fieldCode, f.fieldName]));
return (
<div className="bg-[#FFFFFF] p-4 rounded-[20px] border border-gray-200/70 shadow-sm space-y-4 flex flex-col min-h-[600px]">
{/* Column Header */}
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-bold text-[#0F172B]">Configuration Field</h3>
<CustomButton
variant="outlined"
size="sm"
onClick={onOpenAdd}
disabled={!selectedActionTypeId}
leftIcon={<PlusIcon size={15} weight="bold" />}
>
Add
</CustomButton>
</div>
{/* Search Input */}
<div className="relative">
<CustomInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or code..."
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]"
containerClassName="!gap-0"
/>
</div>
{/* Field Cards List */}
<div className="space-y-3 flex-1 overflow-y-auto max-h-[580px] pr-0.5">
{loading ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading fields..." />
</div>
) : !selectedActionTypeId ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
Select an Action Type to view configuration fields.
</div>
) : filteredFields.length === 0 ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
No fields configured for this action type yet. Click "+ Add" above to create one.
</div>
) : (
filteredFields.map((f, idx) => {
const hasVisibility = f.visibilityConditionJson?.field && f.visibilityConditionJson?.value;
const depFieldName = hasVisibility
? fieldNameByCode.get(f.visibilityConditionJson!.field) || f.visibilityConditionJson!.field
: 'Field Name';
const minVal = f.validationJson?.min;
const maxVal = f.validationJson?.max;
const hasMinMax = minVal !== undefined || maxVal !== undefined;
const isDragging = draggedIndex === idx;
const isDragOver = dragOverIndex === idx;
return (
<div
key={f.id}
draggable
onDragStart={(e) => {
setDraggedIndex(idx);
e.dataTransfer.setData('text/plain', String(idx));
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIndex !== idx) {
setDragOverIndex(idx);
}
}}
onDragLeave={() => {
if (dragOverIndex === idx) {
setDragOverIndex(null);
}
}}
onDrop={(e) => {
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== idx) {
const fromOriginalIndex = fields.findIndex((item) => item.id === filteredFields[draggedIndex].id);
const toOriginalIndex = fields.findIndex((item) => item.id === f.id);
if (fromOriginalIndex !== -1 && toOriginalIndex !== -1) {
onReorderFields?.(fromOriginalIndex, toOriginalIndex);
}
}
setDraggedIndex(null);
setDragOverIndex(null);
}}
onDragEnd={() => {
setDraggedIndex(null);
setDragOverIndex(null);
}}
className={`bg-[#EFF3F7] rounded-[16px] border border-[#E2E8F0] overflow-hidden flex items-stretch transition-all duration-150 ${isDragOver ? 'ring-2 ring-[#1E7D5C] scale-[1.01]' : 'hover:border-slate-300'
} ${isDragging ? 'opacity-40' : ''}`}
>
{/* Left Drag Handle Bar */}
<div
className="w-8 flex items-center justify-center text-slate-400 hover:text-slate-700 cursor-grab active:cursor-grabbing flex-shrink-0 select-none"
title="Drag to reorder"
>
<DotsSixVerticalIcon size={18} weight="bold" />
</div>
{/* Inner White Card Content */}
<div className="bg-white flex-1 p-4 rounded-r-[15px] border-l border-[#E2E8F0] space-y-3 min-w-0">
{/* Top Header Row */}
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-bold text-[15px] text-[#0F172B] leading-tight">
{f.fieldName}
</div>
<div className="font-mono text-[12px] text-[#8C9AA8] uppercase tracking-wider mt-0.5">
{f.fieldCode}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Required Badge */}
{f.isRequired && (
<span className="bg-[#FEECEB] text-[#D9383A] text-[12px] font-medium px-3 py-1 rounded-full">
Required
</span>
)}
{/* Active Badge */}
<span
className={`text-[12px] font-medium px-3 py-1 rounded-full ${f.isActive !== false
? 'bg-[#E6F7ED] text-[#1E7D5C]'
: 'bg-slate-100 text-slate-500'
}`}
>
{f.isActive !== false ? 'Active' : 'Inactive'}
</span>
{/* Divider */}
<span className="text-slate-300 font-light mx-0.5">|</span>
{/* Action Buttons */}
<button
type="button"
onClick={() => onOpenEdit(f)}
title="Edit Field"
className="p-1 text-slate-800 hover:text-[#1E7D5C] transition-colors cursor-pointer"
>
<PencilSimpleLineIcon size={18} weight="bold" />
</button>
<button
type="button"
onClick={() => onDelete(f)}
title="Delete Field"
className="p-1 text-[#D9383A] hover:text-red-700 transition-colors cursor-pointer"
>
<TrashIcon size={18} weight="bold" />
</button>
</div>
</div>
<div className="border-b border-slate-100 my-2" />
{/* Details Grid (2 columns x 2 rows) */}
<div className="grid grid-cols-2 gap-y-3 gap-x-4">
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
CONTROL TYPE
</div>
<div className="text-[13px] font-bold text-[#0F172B] mt-0.5">
{f.fieldType}
</div>
</div>
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
WIDTH
</div>
<div className="text-[13px] font-bold text-[#0F172B] mt-0.5">
{f.width ? `${f.width} width` : 'full width'}
</div>
</div>
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
MASTER LOOKUP
</div>
<div className="text-[13px] font-bold text-[#0F172B] mt-0.5">
{f.lookupSource || '--'}
</div>
</div>
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
SECTION
</div>
<div className="text-[13px] font-medium text-[#5A6E85] mt-0.5">
{f.section || 'General Information'}
</div>
</div>
</div>
{/* Visibility Condition Banner & Min/Max Footer */}
<div className="pt-2 flex items-end justify-between gap-3">
{hasVisibility ? (
<div className="bg-[#FFF8E6] border border-[#FDE6B8] text-[#B45309] rounded-[10px] px-3.5 py-2 text-[12px] space-y-0.5 max-w-[70%]">
<div className="font-medium text-[#B45309]">Visible if {depFieldName} =</div>
<div className="font-bold text-[#B45309] uppercase break-all">
{String(f.visibilityConditionJson?.value)}
</div>
</div>
) : (
<div />
)}
{hasMinMax && (
<div className="text-right text-[12px] font-medium text-[#5A6E85] ml-auto">
Min: {minVal ?? '—'} | Max: {maxVal ?? '—'}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,376 @@
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomDropdown,
CustomTextArea,
CustomSwitch,
CustomButton,
} from '../../../../components/custom';
import type {
FieldDefinition,
FieldDefinitionFormData,
FieldType,
FieldWidth,
} from '../ActionBuilderTypes';
const FIELD_TYPE_OPTIONS: { label: string; value: FieldType }[] = [
{ label: 'Text Box (Single Line)', value: 'textbox' },
{ label: 'Text Area (Multi Line)', value: 'textarea' },
{ label: 'Email Address', value: 'email' },
{ label: 'Phone Number', value: 'phone' },
{ label: 'URL / Link', value: 'url' },
{ label: 'Number (Integer)', value: 'number' },
{ label: 'Decimal / Price', value: 'decimal' },
{ label: 'Percentage (%)', value: 'percentage' },
{ label: 'Currency (Amount + Currency)', value: 'currency' },
{ label: 'Dropdown Selection', value: 'dropdown' },
{ label: 'Radio Selection', value: 'radio' },
{ label: 'Multi-Select Checkboxes', value: 'multi_select' },
{ label: 'Single Checkbox', value: 'checkbox' },
{ label: 'Toggle Switch', value: 'switch' },
{ label: 'Date Picker', value: 'date' },
{ label: 'Date & Time Picker', value: 'datetime' },
{ label: 'Time Picker', value: 'time' },
{ label: 'Color Picker', value: 'color' },
{ label: 'Formula (Read-only Computed)', value: 'formula' },
];
const WIDTH_OPTIONS: { label: string; value: FieldWidth }[] = [
{ label: 'Full Width (12/12)', value: 'full' },
{ label: 'Half Width (6/12)', value: 'half' },
{ label: 'One-Third Width (4/12)', value: 'third' },
{ label: 'Two-Thirds Width (8/12)', value: 'two_thirds' },
];
interface FieldDefinitionFormModalProps {
isOpen: boolean;
editingField: FieldDefinition | null;
fields: FieldDefinition[];
masterLookupOptions: { label: string; value: string }[];
formData: FieldDefinitionFormData;
setFormData: React.Dispatch<React.SetStateAction<FieldDefinitionFormData>>;
error: string | null;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
export function FieldDefinitionFormModal({
isOpen,
editingField,
fields,
masterLookupOptions,
formData,
setFormData,
error,
onClose,
onSubmit,
}: FieldDefinitionFormModalProps) {
const dependentOptions = [
{ label: 'None (No Dependency)', value: '' },
...fields
.filter((f) => !editingField || f.id !== editingField.id)
.map((f) => ({
label: `${f.fieldName} (${f.fieldCode})`,
value: f.fieldCode,
})),
];
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingField ? `Edit Field: ${editingField.fieldName}` : 'Create Field Definition'}
description="Configure field code, label, control type, section layout, validation rules, and visibility."
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Label / Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldName}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
fieldName: val,
fieldCode: editingField ? prev.fieldCode : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Field Name"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Code (JSON Key) <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldCode}
onChange={(e) => setFormData((prev) => ({ ...prev, fieldCode: e.target.value }))}
placeholder="e.g. field_code"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Control Type <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={FIELD_TYPE_OPTIONS}
value={formData.fieldType}
onChange={(val) => setFormData((prev) => ({ ...prev, fieldType: val as FieldType }))}
placeholder="Select Control Type..."
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Master Data Lookup Source
</label>
<CustomDropdown
options={masterLookupOptions}
value={formData.lookupSource || ''}
onChange={(val) => setFormData((prev) => ({ ...prev, lookupSource: val }))}
placeholder="Select Lookup Source..."
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Form Width Layout
</label>
<CustomDropdown
options={WIDTH_OPTIONS}
value={formData.width}
onChange={(val) => setFormData((prev) => ({ ...prev, width: val as FieldWidth }))}
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Section Group Name
</label>
<CustomInput
value={formData.section || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, section: e.target.value }))}
placeholder="e.g. General Information"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Placeholder</label>
<CustomInput
value={formData.placeholder || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, placeholder: e.target.value }))}
placeholder="e.g. Enter value..."
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Default Value</label>
<CustomInput
value={formData.defaultValue || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))}
placeholder="e.g. Default"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Help Text / Instructions</label>
<CustomTextArea
value={formData.helpText || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, helpText: e.target.value }))}
placeholder="e.g. Instructions for end users filling this field"
rows={2}
/>
</div>
{/* Validation JSON Rules */}
<div className="p-4 bg-slate-50 border border-slate-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-slate-800">Validation Rules (validation_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Min Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
min: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 312"
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Max Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
max: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 245"
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Regex Pattern</label>
<CustomInput
value={formData.validationJson?.regex || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
regex: e.target.value,
},
}))
}
placeholder="e.g. ^[A-Z0-9]+$"
/>
</div>
</div>
</div>
{/* Conditional Visibility */}
<div className="p-4 bg-amber-50/60 border border-amber-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-amber-900">Conditional Visibility (visibility_condition_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Depends on Field Code</label>
<CustomDropdown
options={dependentOptions}
value={formData.visibilityConditionJson?.field || ''}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { operator: 'equals', value: '' }),
field: val,
},
}))
}
placeholder="Select dependent field..."
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Operator</label>
<CustomDropdown
options={[
{ label: 'Equals', value: 'equals' },
{ label: 'Not Equals', value: 'not_equals' },
{ label: 'Contains', value: 'contains' },
]}
value={formData.visibilityConditionJson?.operator || 'equals'}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', value: '' }),
operator: val as any,
},
}))
}
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Target Value</label>
<CustomInput
value={formData.visibilityConditionJson?.value || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', operator: 'equals' }),
value: e.target.value,
},
}))
}
placeholder="e.g. DGYRHTFUJKYHUSDERTYUIK"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Required Field</span>
<CustomSwitch
checked={formData.isRequired}
onChange={(e) => setFormData((prev) => ({ ...prev, isRequired: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingField ? 'Save Changes' : 'Create Field Definition'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
}
@@ -0,0 +1,6 @@
export { CategoriesColumn } from './CategoriesColumn';
export { ActionTypesColumn } from './ActionTypesColumn';
export { ConfigurationFieldsColumn } from './ConfigurationFieldsColumn';
export { CategoryFormModal } from './CategoryFormModal';
export { ActionTypeFormModal } from './ActionTypeFormModal';
export { FieldDefinitionFormModal } from './FieldDefinitionFormModal';
@@ -0,0 +1,648 @@
import { useState, useEffect } from 'react';
import { CheckCircleIcon } from '@phosphor-icons/react';
import { CustomConfirmationModal, CustomLoader } from '../../../components/custom';
import {
CategoriesColumn,
ActionTypesColumn,
ConfigurationFieldsColumn,
CategoryFormModal,
ActionTypeFormModal,
FieldDefinitionFormModal,
} from './components';
import {
getActionCategories,
getActionTypes,
createActionCategory,
updateActionCategory,
deleteActionCategory,
createActionType,
updateActionType,
deleteActionType,
getFieldDefinitions,
createFieldDefinition,
updateFieldDefinition,
deleteFieldDefinition,
reorderFieldDefinitions,
} from './ActionBuilderApi';
import { getMasterCategories } from '../masterData/MasterDataApi';
import type {
ActionCategory,
ActionType,
FieldDefinition,
ActionCategoryFormData,
ActionTypeFormData,
FieldDefinitionFormData,
FieldType,
} from './ActionBuilderTypes';
export default function ActionBuilderPage() {
// Data States
const [categories, setCategories] = useState<ActionCategory[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [actionTypes, setActionTypes] = useState<ActionType[]>([]);
const [selectedActionTypeId, setSelectedActionTypeId] = useState<string>('');
const [fields, setFields] = useState<FieldDefinition[]>([]);
const [fieldCountMap, setFieldCountMap] = useState<Record<string, number>>({});
const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([
{ label: 'None (Manual / Free-text)', value: '' },
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
]);
// Loading & Notification States
const [loadingCategories, setLoadingCategories] = useState<boolean>(true);
const [loadingFields, setLoadingFields] = useState<boolean>(false);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// ─── Modal States ──────────────────────────────────────────────────────────
// Category Modal
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<ActionCategory | null>(null);
const [categoryFormData, setCategoryFormData] = useState<ActionCategoryFormData>({
code: '',
name: '',
description: '',
displayOrder: 1,
isActive: true,
});
const [categoryError, setCategoryError] = useState<string | null>(null);
// Action Type Modal
const [isTypeModalOpen, setIsTypeModalOpen] = useState(false);
const [editingType, setEditingType] = useState<ActionType | null>(null);
const [typeFormData, setTypeFormData] = useState<ActionTypeFormData>({
categoryId: '',
code: '',
name: '',
description: '',
displayOrder: 1,
isActive: true,
});
const [typeError, setTypeError] = useState<string | null>(null);
// Field Definition Modal
const [isFieldModalOpen, setIsFieldModalOpen] = useState(false);
const [editingField, setEditingField] = useState<FieldDefinition | null>(null);
const [fieldFormData, setFieldFormData] = useState<FieldDefinitionFormData>({
fieldCode: '',
fieldName: '',
fieldType: 'textbox' as FieldType,
lookupSource: '',
isRequired: false,
defaultValue: '',
placeholder: '',
helpText: '',
width: 'full',
section: 'General Information',
displayOrder: 1,
isActive: true,
validationJson: { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: { field: '', operator: 'equals', value: '' },
});
const [fieldError, setFieldError] = useState<string | null>(null);
// Delete Confirmation Modals
const [deleteCategoryTarget, setDeleteCategoryTarget] = useState<ActionCategory | null>(null);
const [deleteCategoryError, setDeleteCategoryError] = useState<string | null>(null);
const [deleteTypeTarget, setDeleteTypeTarget] = useState<ActionType | null>(null);
const [deleteFieldTarget, setDeleteFieldTarget] = useState<FieldDefinition | null>(null);
// ─── Data Fetching ────────────────────────────────────────────────────────
const fetchCategoriesAndTypes = async () => {
setLoadingCategories(true);
try {
const [catsRes, typesRes] = await Promise.all([
getActionCategories().catch(() => []),
getActionTypes().catch(() => []),
]);
const catsList = Array.isArray(catsRes) ? catsRes : [];
const typesList = Array.isArray(typesRes) ? typesRes : [];
setCategories(catsList);
setActionTypes(typesList);
// Default selection if none selected
if (catsList.length > 0 && !selectedCategoryId) {
setSelectedCategoryId(catsList[0].id);
}
// Pre-fetch field counts for each type
const counts: Record<string, number> = {};
await Promise.all(
typesList.map(async (t) => {
try {
const fList = await getFieldDefinitions(t.id);
counts[t.id] = fList ? fList.length : 0;
} catch {
counts[t.id] = 0;
}
})
);
setFieldCountMap(counts);
} catch (e) {
console.error('Error fetching categories/types data', e);
} finally {
setLoadingCategories(false);
}
};
useEffect(() => {
fetchCategoriesAndTypes();
getMasterCategories()
.then((masterCats) => {
if (Array.isArray(masterCats) && masterCats.length > 0) {
const opts = masterCats.map((c) => ({
label: `${c.name} (${c.code})`,
value: c.code,
}));
setMasterLookupOptions([
{ label: 'None (Manual / Free-text)', value: '' },
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
...opts,
]);
}
})
.catch(() => { });
}, []);
// When selected category changes, auto-select first action type in that category
useEffect(() => {
if (!selectedCategoryId) {
setSelectedActionTypeId('');
setFields([]);
return;
}
const catTypes = actionTypes.filter((t) => t.categoryId === selectedCategoryId);
if (catTypes.length > 0) {
if (!catTypes.some((t) => t.id === selectedActionTypeId)) {
setSelectedActionTypeId(catTypes[0].id);
}
} else {
setSelectedActionTypeId('');
setFields([]);
}
}, [selectedCategoryId, actionTypes]);
// When selected action type changes, fetch fields
const fetchFieldsForType = async (atId: string) => {
if (!atId) {
setFields([]);
return;
}
setLoadingFields(true);
try {
const res = await getFieldDefinitions(atId);
const fieldList = res || [];
setFields(fieldList);
setFieldCountMap((prev) => ({ ...prev, [atId]: fieldList.length }));
} catch (e) {
console.error('Failed loading field definitions', e);
setFields([]);
} finally {
setLoadingFields(false);
}
};
useEffect(() => {
if (selectedActionTypeId) {
fetchFieldsForType(selectedActionTypeId);
} else {
setFields([]);
}
}, [selectedActionTypeId]);
const showSuccess = (msg: string) => {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
};
// ─── Category CRUD Handlers ───────────────────────────────────────────────
const handleOpenAddCategory = () => {
setEditingCategory(null);
setCategoryFormData({
code: '',
name: '',
description: '',
displayOrder: categories.length + 1,
isActive: true,
});
setCategoryError(null);
setIsCategoryModalOpen(true);
};
const handleOpenEditCategory = (cat: ActionCategory, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setEditingCategory(cat);
setCategoryFormData({
code: cat.code,
name: cat.name,
description: cat.description || '',
displayOrder: cat.displayOrder || 1,
isActive: cat.isActive !== false,
});
setCategoryError(null);
setIsCategoryModalOpen(true);
};
const handleSaveCategory = async (e: React.FormEvent) => {
e.preventDefault();
setCategoryError(null);
if (!categoryFormData.name.trim()) {
setCategoryError('Category Name is required.');
return;
}
if (!categoryFormData.code.trim()) {
setCategoryError('Category Code is required.');
return;
}
try {
if (editingCategory) {
await updateActionCategory(editingCategory.id, categoryFormData);
showSuccess(`Category "${categoryFormData.name}" updated successfully.`);
} else {
const created = await createActionCategory(categoryFormData);
showSuccess(`Category "${categoryFormData.name}" created successfully.`);
if (created?.id) setSelectedCategoryId(created.id);
}
setIsCategoryModalOpen(false);
await fetchCategoriesAndTypes();
} catch (err: any) {
setCategoryError(err.message || 'Failed to save category.');
}
};
const handleConfirmDeleteCategory = async () => {
if (!deleteCategoryTarget) return;
setDeleteCategoryError(null);
const hasChildTypes = actionTypes.some((t) => t.categoryId === deleteCategoryTarget.id);
if (hasChildTypes) {
setDeleteCategoryError('Cannot delete category because it has associated Action Types.');
return;
}
try {
await deleteActionCategory(deleteCategoryTarget.id);
showSuccess(`Category deleted successfully.`);
setDeleteCategoryTarget(null);
await fetchCategoriesAndTypes();
} catch (err: any) {
setDeleteCategoryError(err.message || 'Failed to delete category.');
}
};
// ─── Action Type CRUD Handlers ────────────────────────────────────────────
const handleOpenAddType = () => {
setEditingType(null);
setTypeFormData({
categoryId: selectedCategoryId || (categories[0]?.id ?? ''),
code: '',
name: '',
description: '',
displayOrder: actionTypes.length + 1,
isActive: true,
});
setTypeError(null);
setIsTypeModalOpen(true);
};
const handleOpenEditType = (t: ActionType, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setEditingType(t);
setTypeFormData({
categoryId: t.categoryId,
code: t.code,
name: t.name,
description: t.description || '',
displayOrder: t.displayOrder || 1,
isActive: t.isActive !== false,
});
setTypeError(null);
setIsTypeModalOpen(true);
};
const handleSaveType = async (e: React.FormEvent) => {
e.preventDefault();
setTypeError(null);
if (!typeFormData.categoryId) {
setTypeError('Please select a Category.');
return;
}
if (!typeFormData.name.trim()) {
setTypeError('Action Type Name is required.');
return;
}
if (!typeFormData.code.trim()) {
setTypeError('Action Type Code is required.');
return;
}
try {
if (editingType) {
await updateActionType(editingType.id, typeFormData);
showSuccess(`Action Type "${typeFormData.name}" updated successfully.`);
} else {
const created = await createActionType(typeFormData);
showSuccess(`Action Type "${typeFormData.name}" created successfully.`);
if (created?.id) setSelectedActionTypeId(created.id);
}
setIsTypeModalOpen(false);
await fetchCategoriesAndTypes();
} catch (err: any) {
setTypeError(err.message || 'Failed to save action type.');
}
};
const handleConfirmDeleteType = async () => {
if (!deleteTypeTarget) return;
try {
await deleteActionType(deleteTypeTarget.id);
showSuccess('Action Type deleted successfully.');
setDeleteTypeTarget(null);
await fetchCategoriesAndTypes();
} catch (err: any) {
alert(err.message || 'Failed to delete action type.');
}
};
// ─── Field Definition CRUD Handlers ───────────────────────────────────────
const handleOpenAddField = () => {
if (!selectedActionTypeId) {
alert('Please select an Action Type first.');
return;
}
setEditingField(null);
setFieldFormData({
fieldCode: '',
fieldName: '',
fieldType: 'textbox' as FieldType,
lookupSource: '',
isRequired: false,
defaultValue: '',
placeholder: '',
helpText: '',
width: 'full',
section: 'General Information',
displayOrder: fields.length + 1,
isActive: true,
validationJson: { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: { field: '', operator: 'equals', value: '' },
});
setFieldError(null);
setIsFieldModalOpen(true);
};
const handleOpenEditField = (f: FieldDefinition) => {
setEditingField(f);
setFieldFormData({
fieldCode: f.fieldCode,
fieldName: f.fieldName,
fieldType: f.fieldType,
lookupSource: f.lookupSource || '',
isRequired: f.isRequired,
defaultValue: f.defaultValue || '',
placeholder: f.placeholder || '',
helpText: f.helpText || '',
width: f.width || 'full',
section: f.section || 'General Information',
displayOrder: f.displayOrder || 1,
isActive: f.isActive !== false,
validationJson: f.validationJson || { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: f.visibilityConditionJson || { field: '', operator: 'equals', value: '' },
});
setFieldError(null);
setIsFieldModalOpen(true);
};
const handleSaveField = async (e: React.FormEvent) => {
e.preventDefault();
setFieldError(null);
if (!fieldFormData.fieldName.trim()) {
setFieldError('Field Name is required.');
return;
}
if (!fieldFormData.fieldCode.trim()) {
setFieldError('Field Code is required.');
return;
}
const payloadData: FieldDefinitionFormData = {
...fieldFormData,
fieldCode: fieldFormData.fieldCode.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
validationJson:
fieldFormData.validationJson?.min !== undefined ||
fieldFormData.validationJson?.max !== undefined ||
fieldFormData.validationJson?.regex
? fieldFormData.validationJson
: undefined,
visibilityConditionJson:
fieldFormData.visibilityConditionJson?.field && fieldFormData.visibilityConditionJson?.value
? fieldFormData.visibilityConditionJson
: undefined,
};
try {
if (editingField) {
await updateFieldDefinition(editingField.id, payloadData);
showSuccess(`Field "${fieldFormData.fieldName}" updated successfully.`);
} else {
await createFieldDefinition(selectedActionTypeId, payloadData);
showSuccess(`Field "${fieldFormData.fieldName}" created successfully.`);
}
setIsFieldModalOpen(false);
await fetchFieldsForType(selectedActionTypeId);
} catch (err: any) {
setFieldError(err.message || 'Failed to save field definition.');
}
};
const handleConfirmDeleteField = async () => {
if (!deleteFieldTarget) return;
try {
await deleteFieldDefinition(deleteFieldTarget.id);
showSuccess('Field definition deleted successfully.');
setDeleteFieldTarget(null);
await fetchFieldsForType(selectedActionTypeId);
} catch (err: any) {
alert(err.message || 'Failed to delete field definition.');
}
};
const handleReorderFields = async (fromIndex: number, toIndex: number) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
const newFields = [...fields];
const [movedItem] = newFields.splice(fromIndex, 1);
newFields.splice(toIndex, 0, movedItem);
const updatedFields = newFields.map((f, idx) => ({ ...f, displayOrder: idx + 1 }));
setFields(updatedFields);
try {
await reorderFieldDefinitions(
selectedActionTypeId,
updatedFields.map((f) => f.id)
);
showSuccess('Fields reordered successfully.');
} catch (err: any) {
console.error('Failed to persist field order:', err);
fetchFieldsForType(selectedActionTypeId);
}
};
return (
<div className="w-full space-y-5 font-sans">
{/* Top Banner / Notification */}
{successMsg && (
<div className="p-3.5 bg-[#EAFAF5] border border-[#1E7D5C]/30 text-[#14704E] rounded-[12px] text-[14px] font-medium flex items-center gap-2 animate-fadeIn">
<CheckCircleIcon size={20} weight="fill" />
{successMsg}
</div>
)}
{/* 3-Column Drilldown Layout */}
{loadingCategories && categories.length === 0 ? (
<div className="py-20 flex justify-center bg-white rounded-[20px] border border-gray-100 shadow-sm">
<CustomLoader label="Loading Action Builder Configurations..." />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-10 gap-2 items-start">
{/* COLUMN 1: CATEGORIES */}
<div className="lg:col-span-3">
<CategoriesColumn
categories={categories}
selectedCategoryId={selectedCategoryId}
onSelectCategory={setSelectedCategoryId}
actionTypes={actionTypes}
onOpenAdd={handleOpenAddCategory}
onOpenEdit={handleOpenEditCategory}
onDelete={(cat, e) => {
e.stopPropagation();
setDeleteCategoryTarget(cat);
}}
/>
</div>
{/* COLUMN 2: ACTION TYPES */}
<div className="lg:col-span-3">
<ActionTypesColumn
actionTypes={actionTypes}
selectedCategoryId={selectedCategoryId}
selectedActionTypeId={selectedActionTypeId}
onSelectActionType={setSelectedActionTypeId}
fieldCountMap={fieldCountMap}
onOpenAdd={handleOpenAddType}
onOpenEdit={handleOpenEditType}
onDelete={(type, e) => {
e.stopPropagation();
setDeleteTypeTarget(type);
}}
/>
</div>
{/* COLUMN 3: CONFIGURATION FIELDS */}
<div className="lg:col-span-4">
<ConfigurationFieldsColumn
fields={fields}
selectedActionTypeId={selectedActionTypeId}
loading={loadingFields}
onOpenAdd={handleOpenAddField}
onOpenEdit={handleOpenEditField}
onDelete={setDeleteFieldTarget}
onReorderFields={handleReorderFields}
/>
</div>
</div>
)}
{/* ─── MODALS ───────────────────────────────────────────────────────────── */}
<CategoryFormModal
isOpen={isCategoryModalOpen}
editingCategory={editingCategory}
formData={categoryFormData}
setFormData={setCategoryFormData}
error={categoryError}
onClose={() => setIsCategoryModalOpen(false)}
onSubmit={handleSaveCategory}
/>
<ActionTypeFormModal
isOpen={isTypeModalOpen}
editingType={editingType}
categories={categories}
formData={typeFormData}
setFormData={setTypeFormData}
error={typeError}
onClose={() => setIsTypeModalOpen(false)}
onSubmit={handleSaveType}
/>
<FieldDefinitionFormModal
isOpen={isFieldModalOpen}
editingField={editingField}
fields={fields}
masterLookupOptions={masterLookupOptions}
formData={fieldFormData}
setFormData={setFieldFormData}
error={fieldError}
onClose={() => setIsFieldModalOpen(false)}
onSubmit={handleSaveField}
/>
{/* ─── CONFIRMATION DELETE MODALS ──────────────────────────────────────── */}
{deleteCategoryTarget && (
<CustomConfirmationModal
isOpen={!!deleteCategoryTarget}
title={`Delete Category: ${deleteCategoryTarget.name}`}
description={
deleteCategoryError
? deleteCategoryError
: `Are you sure you want to delete "${deleteCategoryTarget.name}"? This action cannot be undone.`
}
onConfirm={handleConfirmDeleteCategory}
onClose={() => {
setDeleteCategoryTarget(null);
setDeleteCategoryError(null);
}}
confirmText="Delete Category"
cancelText="Cancel"
variant="danger"
/>
)}
{deleteTypeTarget && (
<CustomConfirmationModal
isOpen={!!deleteTypeTarget}
title={`Delete Action Type: ${deleteTypeTarget.name}`}
description={`Are you sure you want to delete "${deleteTypeTarget.name}"? This action cannot be undone.`}
onConfirm={handleConfirmDeleteType}
onClose={() => setDeleteTypeTarget(null)}
confirmText="Delete Action Type"
cancelText="Cancel"
variant="danger"
/>
)}
{deleteFieldTarget && (
<CustomConfirmationModal
isOpen={!!deleteFieldTarget}
title={`Delete Field: ${deleteFieldTarget.fieldName}`}
description={`Are you sure you want to delete "${deleteFieldTarget.fieldName}"? This action cannot be undone.`}
onConfirm={handleConfirmDeleteField}
onClose={() => setDeleteFieldTarget(null)}
confirmText="Delete Field Definition"
cancelText="Cancel"
variant="danger"
/>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useSearchParams } from 'react-router-dom';
import ActionBuilderPage from './actionBuilder';
import MasterDataManagement from './masterData';
import { CustomTabs } from '../../components/custom';
export default function ConfigurationPage() {
const [searchParams, setSearchParams] = useSearchParams();
const currentTabParam = searchParams.get('tab');
const activeTab = currentTabParam === 'master-data' ? 'master-data' : 'action-builder';
const handleTabChange = (tabId: string) => {
setSearchParams({ tab: tabId });
};
const tabs = [
{
id: 'action-builder',
label: 'Action Builder',
content: <ActionBuilderPage />,
},
{
id: 'master-data',
label: 'Master Data Configurations',
content: <MasterDataManagement />,
},
];
return (
<div className="w-full space-y-6 font-sans">
<CustomTabs
tabs={tabs}
value={activeTab}
onChange={handleTabChange}
/>
</div>
);
}
@@ -1,4 +1,4 @@
import { ApiClient } from '../api/ApiClient';
import { ApiClient } from '../../api/ApiClient';
import type {
MasterDataCategoryItem,
MasterDataItem,
@@ -0,0 +1,105 @@
import React from 'react';
import { ListBulletsIcon, MagnifyingGlassIcon, DatabaseIcon } from '@phosphor-icons/react';
import { CustomInput, CustomLoader } from '../../../../components/custom';
import type { MasterDataCategoryItem } from '../MasterDataTypes';
interface CategorySidebarProps {
filteredCategories: MasterDataCategoryItem[];
selectedCategory: MasterDataCategoryItem | null;
onSelectCategory: (category: MasterDataCategoryItem) => void;
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
}
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
filteredCategories,
selectedCategory,
onSelectCategory,
loading,
searchQuery,
onSearchChange,
}) => {
return (
<div className="lg:col-span-4 bg-white rounded-[20px] border border-gray-200 shadow-sm p-4 space-y-4 h-fit">
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<h2 className="text-[16px] font-extrabold text-[#0F172B] flex items-center gap-2">
<ListBulletsIcon size={20} className="text-[#1E7D5C]" />
Master Categories
</h2>
<span className="text-[12px] font-bold text-slate-400 bg-slate-100 px-2 py-0.5 rounded-full">
{filteredCategories.length}
</span>
</div>
{/* Search Input */}
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search category name or code..."
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F8FAFC] !rounded-[12px] !h-[38px] !border-[#E2E8F0] text-[13px]"
/>
{/* Categories Selector List */}
{loading ? (
<div className="py-8 flex justify-center">
<CustomLoader label="Loading Categories..." />
</div>
) : filteredCategories.length === 0 ? (
<div className="py-8 text-center text-slate-400 text-[13px]">
No matching master categories found.
</div>
) : (
<div className="space-y-1.5 max-h-[520px] overflow-y-auto pr-1">
{filteredCategories.map((category) => {
const isSelected = selectedCategory?.code === category.code;
return (
<button
key={category.code}
onClick={() => onSelectCategory(category)}
className={`w-full text-left p-3 rounded-[14px] transition-all duration-150 flex items-center justify-between group ${
isSelected
? 'bg-[#1E7D5C] text-white shadow-md shadow-[#1E7D5C]/20'
: 'hover:bg-slate-50 text-slate-700 border border-transparent hover:border-slate-100'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`w-8 h-8 rounded-[10px] flex items-center justify-center shrink-0 ${
isSelected ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500 group-hover:bg-[#E8F3EF] group-hover:text-[#1E7D5C]'
}`}
>
<DatabaseIcon size={16} weight={isSelected ? 'bold' : 'regular'} />
</div>
<div className="min-w-0">
<h3
className={`text-[13px] font-bold truncate leading-tight ${
isSelected ? 'text-white' : 'text-[#0F172B]'
}`}
>
{category.name}
</h3>
<p
className={`text-[11px] font-mono truncate mt-0.5 ${
isSelected ? 'text-white/80' : 'text-slate-400'
}`}
>
{category.code}
</p>
</div>
</div>
<div
className={`w-2 h-2 rounded-full shrink-0 ${
isSelected ? 'bg-white' : 'bg-slate-200 group-hover:bg-[#1E7D5C]'
}`}
/>
</button>
);
})}
</div>
)}
</div>
);
};
@@ -5,7 +5,7 @@ import {
CustomInput,
CustomSwitch,
CustomButton,
} from '../../../components/custom';
} from '../../../../components/custom';
import type {
MasterDataCategoryItem,
MasterDataItem,
@@ -37,8 +37,12 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingItem ? `Edit ${selectedCategory?.name || 'Item'}` : `Add ${selectedCategory?.name || 'Item'}`}
description={`Configure item value for category: ${selectedCategory?.name}`}
title={
editingItem
? `Edit Master Data Item: ${editingItem.value || editingItem.label}`
: `Add New Item to ${selectedCategory?.name || 'Category'}`
}
description="Configure master lookup code, value label, display sequence, and active status."
size="md"
>
{errorMsg && (
@@ -51,7 +55,7 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Value / Label <span className="text-red-500">*</span>
Item Value / Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.value}
@@ -60,36 +64,46 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
setFormData((prev) => ({
...prev,
value: val,
code: editingItem ? prev.code : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
code: editingItem ? prev.code : val.toLowerCase().trim().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Economy Class"
placeholder="e.g. Platinum Tier / USD"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Code Key</label>
<CustomInput
value={formData.code || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. economy_class"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Code Identifier
</label>
<CustomInput
value={formData.code || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. platinum_tier"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Sequence Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder || 1)}
onChange={(e) =>
setFormData((prev) => ({
...prev,
displayOrder: parseInt(e.target.value) || 1,
}))
}
placeholder="1"
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
<CustomInput
type="number"
value={String(formData.displayOrder || 1)}
onChange={(e) =>
setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))
}
/>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Active</span>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
@@ -101,7 +115,7 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 px-4"
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
@@ -109,7 +123,7 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] px-6"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingItem ? 'Save Changes' : 'Create Item'}
</CustomButton>
@@ -0,0 +1,208 @@
import React from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
PencilSimpleIcon,
TrashIcon,
CaretLeftIcon,
CaretRightIcon,
} from '@phosphor-icons/react';
import {
CustomButton,
CustomInput,
CustomLoader,
CustomStatus,
} from '../../../../components/custom';
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
interface MasterItemTableProps {
selectedCategory: MasterDataCategoryItem | null;
filteredItems: MasterDataItem[];
paginatedItems: MasterDataItem[];
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (page: number) => void;
onOpenCreate: () => void;
onOpenEdit: (item: MasterDataItem) => void;
onOpenDelete: (item: MasterDataItem) => void;
}
export const MasterItemTable: React.FC<MasterItemTableProps> = ({
selectedCategory,
filteredItems,
paginatedItems,
loading,
searchQuery,
onSearchChange,
currentPage,
totalPages,
pageSize,
onPageChange,
onOpenCreate,
onOpenEdit,
onOpenDelete,
}) => {
const startIndex = filteredItems.length > 0 ? (currentPage - 1) * pageSize + 1 : 0;
const endIndex = Math.min(currentPage * pageSize, filteredItems.length);
return (
<div className="lg:col-span-8 bg-white rounded-[20px] border border-gray-200 shadow-sm p-6 space-y-5 flex flex-col justify-between">
<div className="space-y-4">
{/* Panel Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 className="text-lg font-extrabold text-[#0F172B]">
{selectedCategory ? `${selectedCategory.name} Values` : 'Category Items'}
</h2>
<p className="text-[13px] text-slate-500 mt-0.5">
{selectedCategory?.description || 'View and manage configured entries for this category.'}
</p>
</div>
<CustomButton
variant="primary"
onClick={onOpenCreate}
disabled={!selectedCategory}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] !gap-[8px] !h-[40px] font-semibold text-[14px] shrink-0"
>
Add Master Value
</CustomButton>
</div>
{/* Search Bar */}
<div className="w-full sm:w-80">
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search items by value or code..."
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F8FAFC] !rounded-[12px] !h-[38px] !border-[#E2E8F0] text-[13px]"
/>
</div>
{/* Items Table */}
{loading ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading Category Values..." />
</div>
) : !selectedCategory ? (
<div className="py-16 text-center text-slate-400 text-[14px]">
Select a master category from the left sidebar to view items.
</div>
) : (
<div className="overflow-x-auto border border-gray-100 rounded-[16px]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50/80 border-b border-gray-100 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3 px-4 w-16">Seq</th>
<th className="py-3 px-4">Value / Label</th>
<th className="py-3 px-4">Code</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{paginatedItems.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
No items found in {selectedCategory.name}. Click "Add Master Value" to create one.
</td>
</tr>
) : (
paginatedItems.map((item, idx) => (
<tr key={item.id || idx} className="hover:bg-slate-50/60 transition-colors">
<td className="py-3 px-4 font-mono text-slate-400 font-medium">
#{item.displayOrder || idx + 1}
</td>
<td className="py-3 px-4 font-bold text-[#0F172B]">
{item.value || item.label || item.name}
</td>
<td className="py-3 px-4 font-mono text-slate-500 text-[12px]">
{item.code || '—'}
</td>
<td className="py-3 px-4">
<CustomStatus status={item.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3 px-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => onOpenEdit(item)}
title="Edit Item"
className="p-1.5 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
>
<PencilSimpleIcon size={16} weight="bold" />
</button>
<button
onClick={() => onOpenDelete(item)}
title="Delete Item"
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
<TrashIcon size={16} weight="bold" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination Footer Bar */}
{selectedCategory && (
<div className="flex flex-col sm:flex-row items-center justify-between pt-4 border-t border-gray-100 text-[13px] text-slate-500 gap-3">
<div>
Showing <strong className="text-slate-800">{filteredItems.length > 0 ? startIndex : 0}</strong> to{' '}
<strong className="text-slate-800">{endIndex}</strong> of <strong className="text-slate-800">{filteredItems.length}</strong> items
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="p-1.5 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<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'
}`}
>
{page}
</button>
))}
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="p-1.5 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretRightIcon size={16} />
</button>
</div>
)}
</div>
)}
</div>
);
};
@@ -1,4 +1,4 @@
export { MasterDataHeader } from './MasterDataHeader';
export { CategorySidebar } from './CategorySidebar';
export { MasterItemTable } from './MasterItemTable';
export { MasterItemFormModal } from './MasterItemFormModal';
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { CheckCircleIcon } from '@phosphor-icons/react';
import { CustomConfirmationModal } from '../../components/custom';
import { CustomConfirmationModal } from '../../../components/custom';
import {
getMasterCategories,
getCategoryValues,
@@ -14,7 +14,6 @@ import type {
MasterDataValueFormData,
} from './MasterDataTypes';
import {
MasterDataHeader,
CategorySidebar,
MasterItemTable,
MasterItemFormModal,
@@ -22,6 +21,7 @@ import {
const PAGE_SIZE = 10;
export default function MasterDataManagement() {
const [categories, setCategories] = useState<MasterDataCategoryItem[]>([]);
const [loadingCategories, setLoadingCategories] = useState<boolean>(true);
@@ -196,12 +196,6 @@ export default function MasterDataManagement() {
return (
<div className="w-full space-y-6 font-sans">
{/* Top Header Component */}
<MasterDataHeader
categoriesCount={categories.length}
itemsCount={items.length}
/>
{/* Success Notification Banner */}
{successMsg && (
<div className="p-4 bg-[#EAFAF5] border border-[#1E7D5C]/30 text-[#14704E] rounded-[14px] text-[14px] font-medium flex items-center gap-2 animate-fadeIn">
@@ -1,89 +0,0 @@
import React from 'react';
import { ListBulletsIcon, MagnifyingGlassIcon, DatabaseIcon } from '@phosphor-icons/react';
import { CustomInput, CustomLoader } from '../../../components/custom';
import type { MasterDataCategoryItem } from '../MasterDataTypes';
interface CategorySidebarProps {
filteredCategories: MasterDataCategoryItem[];
selectedCategory: MasterDataCategoryItem | null;
onSelectCategory: (category: MasterDataCategoryItem) => void;
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
}
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
filteredCategories,
selectedCategory,
onSelectCategory,
loading,
searchQuery,
onSearchChange,
}) => {
return (
<div className="lg:col-span-4 bg-white rounded-[20px] border border-gray-200 shadow-sm p-4 space-y-4 h-fit">
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<h3 className="text-[15px] font-bold text-[#0F172B] flex items-center gap-2">
<ListBulletsIcon size={20} className="text-[#1E7D5C]" />
Master Categories
</h3>
<span className="text-xs font-mono font-semibold text-slate-400">
{filteredCategories.length} Categories
</span>
</div>
{/* Search Category */}
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search categories..."
leftIcon={<MagnifyingGlassIcon size={16} />}
size="sm"
/>
{/* Category List */}
{loading ? (
<div className="py-12 flex justify-center">
<CustomLoader label="Loading Categories..." />
</div>
) : filteredCategories.length === 0 ? (
<div className="py-8 text-center text-slate-400 text-[13px]">
No matching categories found.
</div>
) : (
<div className="space-y-1.5 max-h-[560px] overflow-y-auto pr-1">
{filteredCategories.map((cat) => {
const isSelected = selectedCategory?.code === cat.code;
return (
<button
key={cat.id || cat.code}
onClick={() => onSelectCategory(cat)}
className={`w-full text-left p-3 rounded-[12px] transition-all flex items-center justify-between group ${
isSelected
? 'bg-[#1E7D5C] text-white shadow-sm font-semibold'
: 'bg-slate-50/70 hover:bg-slate-100 text-slate-700 font-medium'
}`}
>
<div className="overflow-hidden pr-2">
<span className="block text-[13px] truncate">{cat.name}</span>
<span
className={`text-[11px] font-mono truncate block ${
isSelected ? 'text-emerald-100' : 'text-slate-400'
}`}
>
{cat.code}
</span>
</div>
<DatabaseIcon
size={16}
className={isSelected ? 'text-white' : 'text-slate-400 group-hover:text-slate-600'}
/>
</button>
);
})}
</div>
)}
</div>
);
};
@@ -1,45 +0,0 @@
import React from 'react';
import { GearIcon } from '@phosphor-icons/react';
interface MasterDataHeaderProps {
categoriesCount: number;
itemsCount: number;
}
export const MasterDataHeader: React.FC<MasterDataHeaderProps> = ({
categoriesCount,
itemsCount,
}) => {
return (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-[24px] border border-gray-200 shadow-sm">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-[16px] bg-[#E8F3EF] text-[#1E7D5C] flex items-center justify-center shrink-0 shadow-inner">
<GearIcon size={26} weight="fill" />
</div>
<div>
<h1 className="text-2xl font-extrabold text-[#0F172B]">Master Data Configurations</h1>
<p className="text-[14px] text-slate-500 mt-1">
Manage system rule categories, lookup values, and master data parameters for AeroResolve
</p>
</div>
</div>
{/* Stats Pills */}
<div className="flex items-center gap-3">
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Categories
</span>
<span className="text-lg font-black text-[#1E7D5C]">{categoriesCount}</span>
</div>
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Loaded Items
</span>
<span className="text-lg font-black text-[#1E7D5C]">{itemsCount}</span>
</div>
</div>
</div>
);
};
@@ -1,195 +0,0 @@
import React from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
PencilSimpleIcon,
TrashIcon,
CaretLeftIcon,
CaretRightIcon,
} from '@phosphor-icons/react';
import {
CustomButton,
CustomInput,
CustomLoader,
CustomStatus,
} from '../../../components/custom';
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
interface MasterItemTableProps {
selectedCategory: MasterDataCategoryItem | null;
filteredItems: MasterDataItem[];
paginatedItems: MasterDataItem[];
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (pageUpdater: (prev: number) => number) => void;
onOpenCreate: () => void;
onOpenEdit: (item: MasterDataItem) => void;
onOpenDelete: (item: MasterDataItem) => void;
}
export const MasterItemTable: React.FC<MasterItemTableProps> = ({
selectedCategory,
filteredItems,
paginatedItems,
loading,
searchQuery,
onSearchChange,
currentPage,
totalPages,
pageSize,
onPageChange,
onOpenCreate,
onOpenEdit,
onOpenDelete,
}) => {
return (
<div className="lg:col-span-8 bg-white rounded-[20px] border border-gray-200 shadow-sm overflow-hidden flex flex-col justify-between">
<div>
{/* Header for Selected Category */}
<div className="p-5 border-b border-gray-100 bg-slate-50/60 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-[17px] font-extrabold text-[#0F172B]">
{selectedCategory ? selectedCategory.name : 'Select a Category'}
</h3>
<p className="text-[12px] text-slate-500 mt-0.5 font-mono">
{selectedCategory
? `Code: ${selectedCategory.code} | Table: ${selectedCategory.tableName || 'masters'}`
: ''}
</p>
</div>
<CustomButton
variant="primary"
onClick={onOpenCreate}
size='sm'
disabled={!selectedCategory}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] text-[13px] font-semibold"
>
Add Master Item
</CustomButton>
</div>
{/* Filter Search Bar */}
<div className="p-4 border-b border-gray-100 bg-white">
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={`Search ${selectedCategory?.name || 'items'}...`}
leftIcon={<MagnifyingGlassIcon size={16} />}
size="sm"
/>
</div>
{/* Items Table */}
{loading ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading Master Data Items..." />
</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center text-slate-400 space-y-2">
<p className="text-[14px]">No master data items found in this category.</p>
<p className="text-[12px]">Click "Add Master Item" above to create an entry.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-100/70 border-b border-gray-200 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3.5 px-5 w-16">#</th>
<th className="py-3.5 px-5">Label / Value</th>
<th className="py-3.5 px-5">Code Key</th>
<th className="py-3.5 px-5">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{paginatedItems.map((item, idx) => {
const displayVal = item.value || item.label || item.name || '';
const displayCode =
item.code || displayVal.toLowerCase().replace(/[^a-z0-9_]+/g, '_');
const orderNum = item.displayOrder || (currentPage - 1) * pageSize + idx + 1;
return (
<tr key={item.id} className="hover:bg-slate-50/80 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-400 text-xs">
#{orderNum}
</td>
<td className="py-3.5 px-5 font-bold text-slate-800">
{displayVal}
</td>
<td className="py-3.5 px-5">
<span className="inline-block px-2.5 py-0.5 rounded font-mono text-[11px] bg-slate-100 text-slate-600 border border-slate-200">
{displayCode}
</span>
</td>
<td className="py-3.5 px-5">
<CustomStatus status={item.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onOpenEdit(item)}
className="p-2 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
title="Edit Item"
>
<PencilSimpleIcon size={17} weight="bold" />
</button>
<button
onClick={() => onOpenDelete(item)}
className="p-2 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete Item"
>
<TrashIcon size={17} weight="bold" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination Footer */}
{filteredItems.length > 0 && (
<div className="p-4 border-t border-gray-100 bg-slate-50/40 flex items-center justify-between">
<span className="text-[13px] text-slate-500">
Showing {Math.min((currentPage - 1) * pageSize + 1, filteredItems.length)} to{' '}
{Math.min(currentPage * pageSize, filteredItems.length)} of {filteredItems.length} entries
</span>
<div className="flex items-center gap-1.5">
<button
onClick={() => onPageChange((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
<span className="px-3 text-[13px] font-semibold text-slate-700">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretRightIcon size={16} />
</button>
</div>
</div>
)}
</div>
);
};
@@ -1,10 +1,54 @@
import { ApiClient } from '../api/ApiClient';
import type { RecoveryIncident } from './RecoveryIncidentsTypes';
import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes';
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
return ApiClient.get<any, RecoveryIncident[]>('/recovery-incidents');
}
export function getRecoveryMetrics(): Promise<MetricCardData[]> {
return ApiClient.get<any, MetricCardData[]>('/recovery-incidents/metrics')
.catch(() => [
{
id: 'total-recoveries',
title: "Total Recoveries",
value: "1,284",
trendValue: "40%",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
{
id: 'pending-approval',
title: "Pending Approval",
value: "274",
trendValue: "High Priority",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
{
id: 'refund-value',
title: "Refund Value",
value: "$412k",
trendValue: "MTD",
trendText: "since last week",
trendType: "negative",
sparklineColor: "red",
},
{
id: 'customer-satisfaction',
title: "Customer Satisfaction",
value: "94%",
trendValue: "+2.1%",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
]);
}
export function getRecoveryIncident(id: string): Promise<RecoveryIncident> {
return ApiClient.get<any, RecoveryIncident>(`/recovery-incidents/${id}`);
}
@@ -12,8 +12,20 @@ export interface RecoveryIncident {
flightNumber: string;
flightRoute: string;
category?: string;
statuses: IncidentStatus[];
statuses?: IncidentStatus[];
status?: string;
value: string;
isPerksClaimed?: boolean;
isGroupHeader?: boolean;
}
export interface MetricCardData {
id?: string;
title: string;
value?: string;
trendText: string;
trendValue: string;
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
@@ -7,6 +7,7 @@ import {
CustomCheckBox,
} from "../../../components/custom";
import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi';
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface AddRecoveryIncidentsProps {
@@ -19,6 +20,9 @@ const SECTION_TITLE_CLASS = "flex items-center gap-2 mb-4 text-[#4A5568] font-bo
const SECTION_CONTAINER_CLASS = "bg-[#F9FAFB] rounded-[14px] p-5 border border-gray-100";
export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddRecoveryIncidentsProps) {
const [loyaltyTierOptions, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]);
const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>([]);
const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>([]);
const [formData, setFormData] = useState({
passengerName: "",
pnr: "",
@@ -34,13 +38,70 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
isPerksClaimed: false,
});
useEffect(() => {
if (isOpen) {
// 1. Fetch Loyalty Tier Master Data
getMembershipTiers()
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.value,
value: m.value || m.id || m.label,
}));
if (activeOptions.length > 0) {
setLoyaltyTierOptions(activeOptions);
}
}
})
.catch((err) => {
console.error("Failed to fetch loyalty tier master data:", err);
});
// 2. Fetch Jurisdiction Master Data
getCategoryValues('jurisdiction')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setJurisdictionOptions(activeOptions);
}
}
})
.catch(() => { });
// 3. Fetch Scenario Master Data
getCategoryValues('flight-disruption-type')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setScenarioOptions(activeOptions);
}
}
})
.catch(() => { });
}
}, [isOpen]);
useEffect(() => {
if (incident && isOpen) {
const [origin, destination] = incident.flightRoute ? incident.flightRoute.split(' → ') : ["", ""];
setFormData({
passengerName: incident.passengerName || "",
pnr: incident.pnr || "",
loyaltyTier: "",
loyaltyTier: (incident as any).loyaltyTier || "",
flightNumber: incident.flightNumber || "",
date: incident.date ? incident.date.split('T')[0] : "",
origin: origin?.trim() || "",
@@ -83,18 +144,18 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
setLoading(true);
try {
const payload = {
recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 10000),
recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 10000),
passengerName: formData.passengerName || "Unknown",
pnr: formData.pnr || "N/A",
flightNumber: formData.flightNumber || "TBD",
flightRoute: `${formData.origin || 'UNK'}${formData.destination || 'UNK'}`,
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
category: formData.category || "General",
statuses: incident ? incident.statuses : [{ text: "New", variant: "info" as const }],
status: incident ? (incident.status || "Pending") : "Pending",
value: incident ? incident.value : "$0",
isPerksClaimed: formData.isPerksClaimed,
};
console.log("Submitting payload:", payload);
if (incident) {
await updateRecoveryIncident(incident.id, payload);
@@ -150,14 +211,10 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
/>
<CustomDropdown
label="Loyalty Tier"
placeholder="Selected Option"
placeholder="Select Loyalty Tier"
value={formData.loyaltyTier}
onChange={(val) => handleInputChange("loyaltyTier", val as string)}
options={[
{ label: "Gold", value: "gold" },
{ label: "Silver", value: "silver" },
{ label: "Bronze", value: "bronze" },
]}
options={loyaltyTierOptions}
/>
</div>
</div>
@@ -235,21 +292,14 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
placeholder="Selected Option"
value={formData.scenario}
onChange={(val) => handleInputChange("scenario", val as string)}
options={[
{ label: "Delay", value: "delay" },
{ label: "Cancellation", value: "cancellation" },
{ label: "Denied Boarding", value: "denied_boarding" },
]}
options={scenarioOptions}
/>
<CustomDropdown
label="Jurisdiction"
placeholder="Selected Option"
value={formData.jurisdiction}
onChange={(val) => handleInputChange("jurisdiction", val as string)}
options={[
{ label: "EU261", value: "eu261" },
{ label: "US DOT", value: "us_dot" },
]}
options={jurisdictionOptions}
/>
<CustomInput
type="number"
@@ -0,0 +1,89 @@
export interface MetricCardProps {
title: string;
value?: string;
trendText: string;
trendValue: string;
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
export function MetricCard({
title,
value,
trendText,
trendValue,
trendType,
sparklineColor,
}: MetricCardProps) {
return (
<div className="bg-[#F8F9FA] rounded-[16px] p-5 flex flex-col gap-4 flex-1 min-w-[220px]">
<div className="flex justify-between items-center">
<span className="text-[14px] font-semibold text-gray-700">{title}</span>
</div>
<div className="flex items-end justify-between">
<div className="flex flex-col gap-1">
{value && (
<span className="text-[28px] font-bold text-gray-900 leading-none">
{value}
</span>
)}
<div className="flex items-center gap-1 mt-1">
<span
className={`text-[12px] font-bold ${
trendType === "positive"
? "text-[#1B9869]"
: trendType === "negative"
? "text-red-500"
: "text-gray-500"
}`}
>
{trendValue}
</span>
<span className="text-[12px] font-medium text-gray-500">
{trendText}
</span>
</div>
</div>
{/* Simple SVG Sparkline placeholder based on color */}
<div className="w-[60px] h-[30px] flex items-center justify-end">
{sparklineColor === "green" ? (
<svg
width="60"
height="24"
viewBox="0 0 60 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 20C10 20 12 12 20 12C28 12 32 18 40 18C48 18 52 4 58 4"
stroke="#1B9869"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="60"
height="24"
viewBox="0 0 60 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4C10 4 12 12 20 12C28 12 32 6 40 6C48 6 52 20 58 20"
stroke="#EF4444"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</div>
</div>
);
}
export default MetricCard;
@@ -1,17 +1,17 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Plus,
MagnifyingGlass,
CaretUp,
CaretDown,
FadersHorizontal,
SquaresFour,
Eye,
PencilSimple,
CheckCircle,
XCircle,
Clock,
EyeIcon,
PencilSimpleIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
FunnelSimpleIcon,
SquaresFourIcon,
PlusIcon,
CaretDownIcon,
CaretUpIcon,
MagnifyingGlassIcon,
} from "@phosphor-icons/react";
import {
CustomTable,
@@ -24,9 +24,11 @@ import {
CustomActionItem,
} from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
import type { RecoveryIncident } from "../RecoveryIncidentsTypes";
import type { RecoveryIncident, MetricCardData } from "../RecoveryIncidentsTypes";
import AddRecoveryIncidents from "./AddRecoveryIncidents";
import { getRecoveryIncidents } from "../RecoveryIncidentsApi";
import { MetricCard } from "./MetricCard";
import { getRecoveryIncidents, getRecoveryMetrics } from "../RecoveryIncidentsApi";
import { formatDate } from "../../../utils/formatDate";
const PAGE_SIZE = 10;
@@ -71,94 +73,14 @@ function BadgeLabel({ text }: { text: string }) {
);
}
// ─── Metric Card Component ───────────────────────────────────────────────────
interface MetricCardProps {
title: string;
value?: string;
trendText: string;
trendValue: string;
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
function MetricCard({
title,
value,
trendText,
trendValue,
trendType,
sparklineColor,
}: MetricCardProps) {
return (
<div className="bg-[#F8F9FA] rounded-[16px] p-5 flex flex-col gap-4 flex-1 min-w-[220px]">
<div className="flex justify-between items-center">
<span className="text-[14px] font-semibold text-gray-700">{title}</span>
</div>
<div className="flex items-end justify-between">
<div className="flex flex-col gap-1">
{value && (
<span className="text-[28px] font-bold text-gray-900 leading-none">
{value}
</span>
)}
<div className="flex items-center gap-1 mt-1">
<span
className={`text-[12px] font-bold ${
trendType === "positive"
? "text-[#1B9869]"
: trendType === "negative"
? "text-red-500"
: "text-gray-500"
}`}
>
{trendValue}
</span>
<span className="text-[12px] font-medium text-gray-500">
{trendText}
</span>
</div>
</div>
{/* Simple SVG Sparkline placeholder based on color */}
<div className="w-[60px] h-[30px] flex items-center justify-end">
{sparklineColor === "green" ? (
<svg
width="60"
height="24"
viewBox="0 0 60 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 20C10 20 12 12 20 12C28 12 32 18 40 18C48 18 52 4 58 4"
stroke="#1B9869"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="60"
height="24"
viewBox="0 0 60 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4C10 4 12 12 20 12C28 12 32 6 40 6C48 6 52 20 58 20"
stroke="#EF4444"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</div>
</div>
);
function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" | "brand" {
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";
}
// ─── Component ───────────────────────────────────────────────────────────────
@@ -166,12 +88,17 @@ function MetricCard({
export default function RecoveryIncidentsList() {
const navigate = useNavigate();
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getRecoveryMetrics()
.then((data) => setMetrics(data))
.catch((err) => console.error("Failed to fetch recovery metrics:", err));
}, []);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// Filters
const [search, setSearch] = useState("");
@@ -185,41 +112,29 @@ export default function RecoveryIncidentsList() {
// ─── Fetch data ─────────────────────────────────────────────
const fetchIncidents = useCallback(
async (page: number) => {
setLoading(true);
try {
const data = await getRecoveryIncidents();
const filteredData = data.filter((p) => {
const matchesSearch = p.recoveryCode.toLowerCase().includes(search.toLowerCase()) ||
p.flightNumber.toLowerCase().includes(search.toLowerCase());
const matchesGroup = isGrouped ? true : !p.isGroupHeader;
return matchesSearch && matchesGroup;
});
const total = filteredData.length;
const pages = Math.ceil(total / PAGE_SIZE);
const start = (page - 1) * PAGE_SIZE;
const paginatedData = filteredData.slice(start, start + PAGE_SIZE);
setIncidents(paginatedData);
setTotalItems(total);
setTotalPages(pages || 1);
} catch (error) {
console.error("Failed to fetch recovery incidents", error);
setIncidents([]);
} finally {
setLoading(false);
}
},
[search, isGrouped],
);
const fetchIncidents = useCallback(async () => {
setLoading(true);
try {
const data = await getRecoveryIncidents();
setIncidents(data);
} catch (error) {
console.error("Failed to fetch recovery incidents", error);
setIncidents([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchIncidents(currentPage);
}, [currentPage, search, isGrouped, fetchIncidents]);
fetchIncidents();
}, [fetchIncidents]);
useEffect(() => {
if (incidents.length > 0) {
const keys = new Set(incidents.map((i) => i.flightNumber || "Other"));
setCollapsedGroups(keys);
}
}, [incidents]);
// ─── Handlers ──────────────────────────────────────────────────────────────
@@ -250,40 +165,123 @@ export default function RecoveryIncidentsList() {
setSelectedIds(newSelected);
};
const toggleGroup = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const toggleGroup = (groupKey: string, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(id);
next.add(groupKey);
}
return next;
});
};
const handleStatusChange = async (incident: RecoveryIncident, text: string, variant: "success" | "error" | "warning" | "info" | "neutral" | "brand") => {
const handleStatusChange = async (incident: RecoveryIncident, text: string) => {
try {
const { updateRecoveryIncident } = await import('../RecoveryIncidentsApi');
await updateRecoveryIncident(incident.id, {
statuses: [{ text, variant }]
status: text
});
fetchIncidents(currentPage);
fetchIncidents();
} catch (error) {
console.error("Failed to update status", error);
}
};
const filteredIncidents = useMemo(() => {
return incidents.filter((p) => {
const q = search.toLowerCase();
return (
(p.recoveryCode || "").toLowerCase().includes(q) ||
(p.flightNumber || "").toLowerCase().includes(q) ||
(p.passengerName || "").toLowerCase().includes(q) ||
(p.pnr || "").toLowerCase().includes(q)
);
});
}, [incidents, search]);
const displayData = useMemo(() => {
if (!isGrouped) {
return filteredIncidents;
}
// Group items by flight number
const groupMap = new Map<string, RecoveryIncident[]>();
filteredIncidents.forEach((item) => {
const key = item.flightNumber || "Other";
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(item);
});
const result: (RecoveryIncident & { groupKey?: string })[] = [];
groupMap.forEach((groupItems, flightNo) => {
const firstItem = groupItems[0];
// Calculate status summary for group header
const pendingCount = groupItems.filter(i => {
const s = (i.status || "").toLowerCase();
return s.includes("pending") || s.includes("review") || s.includes("new");
}).length;
const approvedCount = groupItems.filter(i => {
const s = (i.status || "").toLowerCase();
return s.includes("approved") || s.includes("active");
}).length;
const headerStatuses = [
{ text: `${pendingCount} Pending`, variant: "warning" as const },
{ text: `${approvedCount} Approved`, variant: "success" as const },
];
// Calculate sum of values
const sumVal = groupItems.reduce((acc, curr) => {
const num = parseFloat((curr.value || "").replace(/[^0-9.]/g, "")) || 0;
return acc + num;
}, 0);
const groupKey = flightNo;
// Group Header Row
result.push({
id: `header-${groupKey}`,
recoveryCode: firstItem.recoveryCode,
date: firstItem.date,
flightNumber: flightNo,
flightRoute: firstItem.flightRoute,
category: firstItem.category,
statuses: headerStatuses,
value: `$${sumVal}`,
isGroupHeader: true,
groupKey: groupKey,
});
// Child Rows (if group is expanded)
if (!collapsedGroups.has(groupKey)) {
groupItems.forEach((child) => {
result.push({
...child,
groupKey: groupKey,
});
});
}
});
return result;
}, [filteredIncidents, isGrouped, collapsedGroups]);
const totalItems = displayData.length;
const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1;
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
const displayData = useMemo(() => {
if (!isGrouped) return incidents;
return incidents.filter(
(p) => p.isGroupHeader || !collapsedGroups.has(p.recoveryCode)
);
}, [incidents, isGrouped, collapsedGroups]);
const paginatedDisplayData = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return displayData.slice(start, start + PAGE_SIZE);
}, [displayData, currentPage]);
// ─── Table columns ─────────────────────────────────────────────────────────
@@ -311,7 +309,7 @@ export default function RecoveryIncidentsList() {
accessor: (row) => (
<div>
<PrimaryText text={row.recoveryCode} />
<SecondaryText text={row.date} />
<SecondaryText text={formatDate(row.date)} />
</div>
),
},
@@ -319,7 +317,7 @@ export default function RecoveryIncidentsList() {
header: (
<HeaderLabel
text="Passenger / PNR"
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) =>
@@ -334,7 +332,7 @@ export default function RecoveryIncidentsList() {
header: (
<HeaderLabel
text="Flight"
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => (
@@ -351,23 +349,34 @@ export default function RecoveryIncidentsList() {
},
{
header: <HeaderLabel text="Status" />,
accessor: (row) => (
<div className="flex items-center gap-2">
{row.statuses.map((status, idx) => (
<CustomStatus
key={idx}
status={status.text}
variant={status.variant}
/>
))}
</div>
),
accessor: (row) => {
if (row.isGroupHeader) {
return (
<div className="flex items-center gap-2">
{(row.statuses || []).map((status, idx) => (
<CustomStatus
key={idx}
status={status.text}
variant={status.variant}
/>
))}
</div>
);
}
const statusText = row.status || "Pending";
return (
<CustomStatus
status={statusText}
variant={getStatusVariant(statusText)}
/>
);
},
},
{
header: (
<HeaderLabel
text="Value"
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => <PrimaryText text={row.value} />,
@@ -382,62 +391,90 @@ export default function RecoveryIncidentsList() {
{
header: <HeaderLabel text="Action" />,
className: "text-right",
accessor: (row) => (
<div className="flex justify-end pr-2">
{row.isGroupHeader ? (
<div
className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1"
onClick={(e) => toggleGroup(row.recoveryCode, e)}
>
{collapsedGroups.has(row.recoveryCode) ? (
<CaretDown size={20} />
) : (
<CaretUp size={20} />
)}
</div>
) : (
<CustomActionMenu>
<CustomActionItem
onClick={() => navigate(`/recovery/${row.id}`)}
icon={<Eye size={16} className="text-blue-500" />}
accessor: (row) => {
const key = (row as any).groupKey || row.flightNumber || row.recoveryCode;
return (
<div className="flex justify-end pr-2">
{row.isGroupHeader ? (
<div
className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1"
onClick={(e) => toggleGroup(key, e)}
>
View Details
</CustomActionItem>
<CustomActionItem
onClick={() => {
setEditingIncident(row);
setIsModalOpen(true);
}}
icon={<PencilSimple size={16} className="text-yellow-500" />}
>
Edit Incident
</CustomActionItem>
<CustomActionItem
icon={<CheckCircle size={16} className="text-green-500" />}
onClick={() => handleStatusChange(row, "Approved", "success")}
>
Approve
</CustomActionItem>
<CustomActionItem
variant="danger"
icon={<XCircle size={16} className="text-red-500" />}
onClick={() => handleStatusChange(row, "Rejected", "error")}
>
Reject
</CustomActionItem>
<CustomActionItem
icon={<Clock size={16} className="text-yellow-600" />}
onClick={() => handleStatusChange(row, "Under Review", "warning")}
>
Mark for Review
</CustomActionItem>
</CustomActionMenu>
)}
</div>
),
{collapsedGroups.has(key) ? (
<CaretDownIcon size={20} />
) : (
<CaretUpIcon size={20} />
)}
</div>
) : (
<CustomActionMenu>
<CustomActionItem
onClick={() => navigate(`/recovery/${row.id}`)}
icon={<EyeIcon size={16} className="text-blue-500" />}
>
View Details
</CustomActionItem>
<CustomActionItem
onClick={() => {
setEditingIncident(row);
setIsModalOpen(true);
}}
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
>
Edit Incident
</CustomActionItem>
<CustomActionItem
icon={<CheckCircleIcon size={16} className="text-green-500" />}
onClick={() => handleStatusChange(row, "Approved")}
>
Approve
</CustomActionItem>
<CustomActionItem
variant="danger"
icon={<XCircleIcon size={16} className="text-red-500" />}
onClick={() => handleStatusChange(row, "Rejected")}
>
Reject
</CustomActionItem>
<CustomActionItem
icon={<ClockIcon size={16} className="text-yellow-600" />}
onClick={() => handleStatusChange(row, "Under Review")}
>
Mark for Review
</CustomActionItem>
</CustomActionMenu>
)}
</div>
);
},
},
];
const isAnyGroupExpanded = useMemo(() => {
if (!isGrouped) return true;
const allGroupKeys = Array.from(
new Set(incidents.map((i) => i.flightNumber || "Other"))
);
return allGroupKeys.some((key) => !collapsedGroups.has(key));
}, [isGrouped, incidents, collapsedGroups]);
const activeColumns = useMemo(() => {
if (!isGrouped || isAnyGroupExpanded) {
return columns;
}
return columns.filter((col) => {
const headerText =
typeof col.header === "object" && col.header !== null && "props" in col.header
? (col.header as any).props.text
: "";
return (
headerText !== "Passenger / PNR" &&
headerText !== "Category" &&
headerText !== "Perks Claimed"
);
});
}, [columns, isGrouped, isAnyGroupExpanded]);
// ─── Loading skeleton ──────────────────────────────────────────────────────
if (loading) {
@@ -464,52 +501,22 @@ export default function RecoveryIncidentsList() {
<div className="w-full flex flex-col gap-6">
{/* Metrics Row */}
<div className="flex gap-4 w-full">
<MetricCard
title="Total Recoveries"
value="1,284"
trendValue="40%"
trendText="since last week"
trendType="positive"
sparklineColor="green"
/>
<MetricCard
title="Pending Approval"
trendValue="High Priority"
trendText="since last week"
trendType="positive"
value="274"
sparklineColor="green"
/>
<MetricCard
title="Refund Value"
value="$412k"
trendValue="MTD"
trendText="since last week"
trendType="negative"
sparklineColor="red"
/>
<MetricCard
title="Customer Satisfaction"
value="94%"
trendValue="+2.1%"
trendText="since last week"
trendType="positive"
sparklineColor="green"
/>
{metrics.map((metric) => (
<MetricCard key={metric.id || metric.title} {...metric} />
))}
</div>
{/* Table Section */}
<CustomTable<RecoveryIncident>
columns={columns}
data={displayData}
columns={activeColumns}
data={paginatedDisplayData}
leftHeaderActions={
<div className="w-[320px]">
<CustomInput
placeholder="Search framework registry..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
leftIcon={<MagnifyingGlass size={16} />}
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
/>
@@ -520,15 +527,7 @@ export default function RecoveryIncidentsList() {
<CustomButton
variant="outlined"
size="md"
leftIcon={<FadersHorizontal size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
>
Filters
</CustomButton>
<CustomButton
variant="outlined"
size="md"
leftIcon={<SquaresFour size={16} />}
leftIcon={<SquaresFourIcon size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
onClick={() => setIsGrouped(!isGrouped)}
>
@@ -537,7 +536,7 @@ export default function RecoveryIncidentsList() {
<CustomButton
variant="primary"
size="md"
leftIcon={<Plus size={16} />}
leftIcon={<PlusIcon size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
onClick={() => {
setEditingIncident(null);
@@ -554,20 +553,27 @@ export default function RecoveryIncidentsList() {
startIndex={startIndex}
endIndex={endIndex}
onPageChange={handlePageChange}
itemName="Policies"
onRowClick={(row) => !row.isGroupHeader && navigate(`/recovery/${row.id}`)}
rowClassName={(row) => row.isGroupHeader ? "bg-white" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"}
itemName="Recovery Incidents"
onRowClick={(row) => {
if (row.isGroupHeader) {
const key = (row as any).groupKey || row.flightNumber || row.recoveryCode;
toggleGroup(key);
} else {
navigate(`/recovery/${row.id}`);
}
}}
rowClassName={(row) => row.isGroupHeader ? "bg-white border-b border-gray-100 hover:bg-gray-50/60" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"}
/>
{/* Modal */}
<AddRecoveryIncidents
isOpen={isModalOpen}
<AddRecoveryIncidents
isOpen={isModalOpen}
incident={editingIncident}
onClose={() => {
setIsModalOpen(false);
setEditingIncident(null);
fetchIncidents(currentPage); // Refresh list
}}
fetchIncidents(); // Refresh list
}}
/>
</div>
);
+2 -2
View File
@@ -6,8 +6,8 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
'/cohorts': { title: 'Cohort Management', subtitle: 'Dynamic passenger segmentation for targeted recovery and recovery intelligence.' },
'/policy-engine': { title: 'Policy Engine Framework Registry', subtitle: 'Manage framework policies, conditions, and automated actions.' },
'/policy-engine/add': { title: 'Deploy New Policy', subtitle: 'Configure policy framework details, targeting rules, and action payloads.' },
'/action-builder': { title: 'Action Builder', subtitle: 'Configure dynamic categories, action types, metadata fields, and dynamic user form workflows.' },
'/config': { title: 'Master Data Configurations', subtitle: 'Manage system rule categories, lookup values, and master data parameters.' },
'/action-builder': { title: 'Configuration - Action Builder', subtitle: 'Configure dynamic categories, action types, metadata fields, and dynamic user form workflows.' },
'/config': { title: 'Configuration', subtitle: 'Manage dynamic action workflows, categories, metadata fields, and system master data.' },
'/recovery': { title: 'Recovery Incidents', subtitle: 'Operational workspace for managing passenger disruption cases.' },
};
+2 -5
View File
@@ -11,7 +11,6 @@ import {
GearIcon,
ClockCounterClockwiseIcon,
CaretDoubleRightIcon,
LightningIcon,
} from "@phosphor-icons/react";
import { ShieldCheckIcon } from "lucide-react";
@@ -21,7 +20,6 @@ const NAV_ITEMS = [
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon },
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon },
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
{ label: "Action Builder", path: "/action-builder", icon: LightningIcon },
{ label: "Configuration", path: "/config", icon: GearIcon },
{ label: "Audit Logs", path: "/audit", icon: ClockCounterClockwiseIcon },
];
@@ -111,11 +109,10 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
onClick={() => {
if (window.innerWidth < 1024) onClose();
}}
className={`group flex items-center ${isCollapsed ? "justify-center px-0 w-12 mx-auto" : "gap-3 px-3.5"} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${
isActive
className={`group flex items-center ${isCollapsed ? "justify-center px-0 w-12 mx-auto" : "gap-3 px-3.5"} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${isActive
? "bg-gradient-to-b from-primary to-primary-dark text-white shadow-md shadow-primary/20 font-semibold"
: "text-[#475569] font-medium hover:bg-slate-200/40 hover:text-slate-900"
}`}
}`}
>
<Icon
size={18}
+26
View File
@@ -0,0 +1,26 @@
export function formatDate(dateString?: string | Date): string {
if (!dateString) return '';
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
if (isNaN(date.getTime())) return String(dateString);
const isISO = typeof dateString === 'string' && (dateString.includes('T') || dateString.includes('Z'));
const day = isISO ? date.getUTCDate() : date.getDate();
const monthIdx = isISO ? date.getUTCMonth() : date.getMonth();
const year = isISO ? date.getUTCFullYear() : date.getFullYear();
const monthNames = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
const month = monthNames[monthIdx];
let hours = isISO ? date.getUTCHours() : date.getHours();
const minutes = (isISO ? date.getUTCMinutes() : date.getMinutes()).toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
hours = hours % 12;
hours = hours ? hours : 12;
return `${day} ${month} ${year}, ${hours}:${minutes}${ampm}`;
}
export default formatDate;