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:
+4
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
+43
-29
@@ -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
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user