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 Layout from './layout/AppLayout'
|
||||||
import HomePage from './app/dashboard'
|
import HomePage from './app/dashboard'
|
||||||
import CohortManage from './app/cohartManage'
|
import CohortManage from './app/cohartManage'
|
||||||
@@ -6,8 +6,7 @@ import PolicyEngineList from './app/policyEngine/components/PolicyEngineList'
|
|||||||
import AddPolicyEngine from './app/policyEngine/components/AddPolicyEngine'
|
import AddPolicyEngine from './app/policyEngine/components/AddPolicyEngine'
|
||||||
import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIncidentsList'
|
import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIncidentsList'
|
||||||
import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index'
|
import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index'
|
||||||
import ActionBuilderPage from './app/actionBuilder'
|
import ConfigurationPage from './app/configuration'
|
||||||
import MasterDataManagement from './app/masterData'
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -18,8 +17,8 @@ function AppRoutes() {
|
|||||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
||||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
||||||
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
|
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
|
||||||
<Route path="/action-builder" element={<ActionBuilderPage />} />
|
<Route path="/action-builder" element={<Navigate to="/config?tab=action-builder" replace />} />
|
||||||
<Route path="/config" element={<MasterDataManagement />} />
|
<Route path="/config" element={<ConfigurationPage />} />
|
||||||
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
||||||
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
||||||
</Routes>
|
</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,
|
getRevenueSegments,
|
||||||
getRegions,
|
getRegions,
|
||||||
getTripPurposes,
|
getTripPurposes,
|
||||||
} from '../../masterData/MasterDataApi';
|
} from '../../configuration/masterData/MasterDataApi';
|
||||||
import type { MasterDataItem } from '../../masterData/MasterDataTypes';
|
import type { MasterDataItem } from '../../configuration/masterData/MasterDataTypes';
|
||||||
import type {
|
import type {
|
||||||
CohartResponse,
|
CohartResponse,
|
||||||
CreateCohartPayload,
|
CreateCohartPayload,
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ApiClient } from '../api/ApiClient';
|
import { ApiClient } from '../../api/ApiClient';
|
||||||
import type {
|
import type {
|
||||||
ActionCategory,
|
ActionCategory,
|
||||||
ActionType,
|
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 {
|
import type {
|
||||||
MasterDataCategoryItem,
|
MasterDataCategoryItem,
|
||||||
MasterDataItem,
|
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,
|
CustomInput,
|
||||||
CustomSwitch,
|
CustomSwitch,
|
||||||
CustomButton,
|
CustomButton,
|
||||||
} from '../../../components/custom';
|
} from '../../../../components/custom';
|
||||||
import type {
|
import type {
|
||||||
MasterDataCategoryItem,
|
MasterDataCategoryItem,
|
||||||
MasterDataItem,
|
MasterDataItem,
|
||||||
@@ -37,8 +37,12 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
|
|||||||
<CustomModal
|
<CustomModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={editingItem ? `Edit ${selectedCategory?.name || 'Item'}` : `Add ${selectedCategory?.name || 'Item'}`}
|
title={
|
||||||
description={`Configure item value for category: ${selectedCategory?.name}`}
|
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"
|
size="md"
|
||||||
>
|
>
|
||||||
{errorMsg && (
|
{errorMsg && (
|
||||||
@@ -51,7 +55,7 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
|
|||||||
<form onSubmit={onSubmit} className="space-y-4 pt-1">
|
<form onSubmit={onSubmit} className="space-y-4 pt-1">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
|
<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>
|
</label>
|
||||||
<CustomInput
|
<CustomInput
|
||||||
value={formData.value}
|
value={formData.value}
|
||||||
@@ -60,36 +64,46 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
|
|||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
value: val,
|
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>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Code Key</label>
|
<div>
|
||||||
<CustomInput
|
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
|
||||||
value={formData.code || ''}
|
Code Identifier
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
|
</label>
|
||||||
placeholder="e.g. economy_class"
|
<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>
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
|
||||||
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
|
<div className="flex items-center gap-3">
|
||||||
<CustomInput
|
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
|
||||||
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>
|
|
||||||
<CustomSwitch
|
<CustomSwitch
|
||||||
checked={formData.isActive}
|
checked={formData.isActive}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
|
||||||
@@ -101,7 +115,7 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="!border-gray-300 !text-gray-600 px-4"
|
className="!border-gray-300 !text-gray-600 font-semibold px-5"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
@@ -109,7 +123,7 @@ export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
|
|||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
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'}
|
{editingItem ? 'Save Changes' : 'Create Item'}
|
||||||
</CustomButton>
|
</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 { CategorySidebar } from './CategorySidebar';
|
||||||
export { MasterItemTable } from './MasterItemTable';
|
export { MasterItemTable } from './MasterItemTable';
|
||||||
export { MasterItemFormModal } from './MasterItemFormModal';
|
export { MasterItemFormModal } from './MasterItemFormModal';
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { CheckCircleIcon } from '@phosphor-icons/react';
|
import { CheckCircleIcon } from '@phosphor-icons/react';
|
||||||
import { CustomConfirmationModal } from '../../components/custom';
|
import { CustomConfirmationModal } from '../../../components/custom';
|
||||||
import {
|
import {
|
||||||
getMasterCategories,
|
getMasterCategories,
|
||||||
getCategoryValues,
|
getCategoryValues,
|
||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
MasterDataValueFormData,
|
MasterDataValueFormData,
|
||||||
} from './MasterDataTypes';
|
} from './MasterDataTypes';
|
||||||
import {
|
import {
|
||||||
MasterDataHeader,
|
|
||||||
CategorySidebar,
|
CategorySidebar,
|
||||||
MasterItemTable,
|
MasterItemTable,
|
||||||
MasterItemFormModal,
|
MasterItemFormModal,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
|
||||||
export default function MasterDataManagement() {
|
export default function MasterDataManagement() {
|
||||||
const [categories, setCategories] = useState<MasterDataCategoryItem[]>([]);
|
const [categories, setCategories] = useState<MasterDataCategoryItem[]>([]);
|
||||||
const [loadingCategories, setLoadingCategories] = useState<boolean>(true);
|
const [loadingCategories, setLoadingCategories] = useState<boolean>(true);
|
||||||
@@ -196,12 +196,6 @@ export default function MasterDataManagement() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-6 font-sans">
|
<div className="w-full space-y-6 font-sans">
|
||||||
{/* Top Header Component */}
|
|
||||||
<MasterDataHeader
|
|
||||||
categoriesCount={categories.length}
|
|
||||||
itemsCount={items.length}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Success Notification Banner */}
|
{/* Success Notification Banner */}
|
||||||
{successMsg && (
|
{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">
|
<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 { ApiClient } from '../api/ApiClient';
|
||||||
import type { RecoveryIncident } from './RecoveryIncidentsTypes';
|
import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
|
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
|
||||||
return ApiClient.get<any, RecoveryIncident[]>('/recovery-incidents');
|
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> {
|
export function getRecoveryIncident(id: string): Promise<RecoveryIncident> {
|
||||||
return ApiClient.get<any, RecoveryIncident>(`/recovery-incidents/${id}`);
|
return ApiClient.get<any, RecoveryIncident>(`/recovery-incidents/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,20 @@ export interface RecoveryIncident {
|
|||||||
flightNumber: string;
|
flightNumber: string;
|
||||||
flightRoute: string;
|
flightRoute: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
statuses: IncidentStatus[];
|
statuses?: IncidentStatus[];
|
||||||
|
status?: string;
|
||||||
value: string;
|
value: string;
|
||||||
isPerksClaimed?: boolean;
|
isPerksClaimed?: boolean;
|
||||||
isGroupHeader?: 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,
|
CustomCheckBox,
|
||||||
} from "../../../components/custom";
|
} from "../../../components/custom";
|
||||||
import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi';
|
import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi';
|
||||||
|
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
|
||||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||||
|
|
||||||
interface AddRecoveryIncidentsProps {
|
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";
|
const SECTION_CONTAINER_CLASS = "bg-[#F9FAFB] rounded-[14px] p-5 border border-gray-100";
|
||||||
|
|
||||||
export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddRecoveryIncidentsProps) {
|
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({
|
const [formData, setFormData] = useState({
|
||||||
passengerName: "",
|
passengerName: "",
|
||||||
pnr: "",
|
pnr: "",
|
||||||
@@ -34,13 +38,70 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
|||||||
isPerksClaimed: false,
|
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(() => {
|
useEffect(() => {
|
||||||
if (incident && isOpen) {
|
if (incident && isOpen) {
|
||||||
const [origin, destination] = incident.flightRoute ? incident.flightRoute.split(' → ') : ["", ""];
|
const [origin, destination] = incident.flightRoute ? incident.flightRoute.split(' → ') : ["", ""];
|
||||||
setFormData({
|
setFormData({
|
||||||
passengerName: incident.passengerName || "",
|
passengerName: incident.passengerName || "",
|
||||||
pnr: incident.pnr || "",
|
pnr: incident.pnr || "",
|
||||||
loyaltyTier: "",
|
loyaltyTier: (incident as any).loyaltyTier || "",
|
||||||
flightNumber: incident.flightNumber || "",
|
flightNumber: incident.flightNumber || "",
|
||||||
date: incident.date ? incident.date.split('T')[0] : "",
|
date: incident.date ? incident.date.split('T')[0] : "",
|
||||||
origin: origin?.trim() || "",
|
origin: origin?.trim() || "",
|
||||||
@@ -83,18 +144,18 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
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",
|
passengerName: formData.passengerName || "Unknown",
|
||||||
pnr: formData.pnr || "N/A",
|
pnr: formData.pnr || "N/A",
|
||||||
flightNumber: formData.flightNumber || "TBD",
|
flightNumber: formData.flightNumber || "TBD",
|
||||||
flightRoute: `${formData.origin || 'UNK'} → ${formData.destination || 'UNK'}`,
|
flightRoute: `${formData.origin || 'UNK'} → ${formData.destination || 'UNK'}`,
|
||||||
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
|
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
|
||||||
category: formData.category || "General",
|
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",
|
value: incident ? incident.value : "$0",
|
||||||
isPerksClaimed: formData.isPerksClaimed,
|
isPerksClaimed: formData.isPerksClaimed,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Submitting payload:", payload);
|
console.log("Submitting payload:", payload);
|
||||||
if (incident) {
|
if (incident) {
|
||||||
await updateRecoveryIncident(incident.id, payload);
|
await updateRecoveryIncident(incident.id, payload);
|
||||||
@@ -150,14 +211,10 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
|||||||
/>
|
/>
|
||||||
<CustomDropdown
|
<CustomDropdown
|
||||||
label="Loyalty Tier"
|
label="Loyalty Tier"
|
||||||
placeholder="Selected Option"
|
placeholder="Select Loyalty Tier"
|
||||||
value={formData.loyaltyTier}
|
value={formData.loyaltyTier}
|
||||||
onChange={(val) => handleInputChange("loyaltyTier", val as string)}
|
onChange={(val) => handleInputChange("loyaltyTier", val as string)}
|
||||||
options={[
|
options={loyaltyTierOptions}
|
||||||
{ label: "Gold", value: "gold" },
|
|
||||||
{ label: "Silver", value: "silver" },
|
|
||||||
{ label: "Bronze", value: "bronze" },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,21 +292,14 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
|||||||
placeholder="Selected Option"
|
placeholder="Selected Option"
|
||||||
value={formData.scenario}
|
value={formData.scenario}
|
||||||
onChange={(val) => handleInputChange("scenario", val as string)}
|
onChange={(val) => handleInputChange("scenario", val as string)}
|
||||||
options={[
|
options={scenarioOptions}
|
||||||
{ label: "Delay", value: "delay" },
|
|
||||||
{ label: "Cancellation", value: "cancellation" },
|
|
||||||
{ label: "Denied Boarding", value: "denied_boarding" },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<CustomDropdown
|
<CustomDropdown
|
||||||
label="Jurisdiction"
|
label="Jurisdiction"
|
||||||
placeholder="Selected Option"
|
placeholder="Selected Option"
|
||||||
value={formData.jurisdiction}
|
value={formData.jurisdiction}
|
||||||
onChange={(val) => handleInputChange("jurisdiction", val as string)}
|
onChange={(val) => handleInputChange("jurisdiction", val as string)}
|
||||||
options={[
|
options={jurisdictionOptions}
|
||||||
{ label: "EU261", value: "eu261" },
|
|
||||||
{ label: "US DOT", value: "us_dot" },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<CustomInput
|
<CustomInput
|
||||||
type="number"
|
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 { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Plus,
|
EyeIcon,
|
||||||
MagnifyingGlass,
|
PencilSimpleIcon,
|
||||||
CaretUp,
|
CheckCircleIcon,
|
||||||
CaretDown,
|
XCircleIcon,
|
||||||
FadersHorizontal,
|
ClockIcon,
|
||||||
SquaresFour,
|
FunnelSimpleIcon,
|
||||||
Eye,
|
SquaresFourIcon,
|
||||||
PencilSimple,
|
PlusIcon,
|
||||||
CheckCircle,
|
CaretDownIcon,
|
||||||
XCircle,
|
CaretUpIcon,
|
||||||
Clock,
|
MagnifyingGlassIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import {
|
import {
|
||||||
CustomTable,
|
CustomTable,
|
||||||
@@ -24,9 +24,11 @@ import {
|
|||||||
CustomActionItem,
|
CustomActionItem,
|
||||||
} from "../../../components/custom";
|
} from "../../../components/custom";
|
||||||
import type { Column } from "../../../components/custom/CustomTable";
|
import type { Column } from "../../../components/custom/CustomTable";
|
||||||
import type { RecoveryIncident } from "../RecoveryIncidentsTypes";
|
import type { RecoveryIncident, MetricCardData } from "../RecoveryIncidentsTypes";
|
||||||
import AddRecoveryIncidents from "./AddRecoveryIncidents";
|
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;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@@ -71,94 +73,14 @@ function BadgeLabel({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Metric Card Component ───────────────────────────────────────────────────
|
function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" | "brand" {
|
||||||
|
if (!status) return "neutral";
|
||||||
interface MetricCardProps {
|
const s = status.toLowerCase();
|
||||||
title: string;
|
if (s.includes("appr") || s.includes("active") || s.includes("success")) return "success";
|
||||||
value?: string;
|
if (s.includes("reject") || s.includes("denied") || s.includes("error")) return "error";
|
||||||
trendText: string;
|
if (s.includes("pend") || s.includes("review") || s.includes("warn")) return "warning";
|
||||||
trendValue: string;
|
if (s.includes("new") || s.includes("info")) return "info";
|
||||||
trendType: "positive" | "negative" | "neutral";
|
return "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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
@@ -166,12 +88,17 @@ function MetricCard({
|
|||||||
export default function RecoveryIncidentsList() {
|
export default function RecoveryIncidentsList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
|
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
|
||||||
|
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRecoveryMetrics()
|
||||||
|
.then((data) => setMetrics(data))
|
||||||
|
.catch((err) => console.error("Failed to fetch recovery metrics:", err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -185,41 +112,29 @@ export default function RecoveryIncidentsList() {
|
|||||||
|
|
||||||
// ─── Fetch data ─────────────────────────────────────────────
|
// ─── Fetch data ─────────────────────────────────────────────
|
||||||
|
|
||||||
const fetchIncidents = useCallback(
|
const fetchIncidents = useCallback(async () => {
|
||||||
async (page: number) => {
|
setLoading(true);
|
||||||
setLoading(true);
|
try {
|
||||||
|
const data = await getRecoveryIncidents();
|
||||||
try {
|
setIncidents(data);
|
||||||
const data = await getRecoveryIncidents();
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch recovery incidents", error);
|
||||||
const filteredData = data.filter((p) => {
|
setIncidents([]);
|
||||||
const matchesSearch = p.recoveryCode.toLowerCase().includes(search.toLowerCase()) ||
|
} finally {
|
||||||
p.flightNumber.toLowerCase().includes(search.toLowerCase());
|
setLoading(false);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchIncidents(currentPage);
|
fetchIncidents();
|
||||||
}, [currentPage, search, isGrouped, fetchIncidents]);
|
}, [fetchIncidents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (incidents.length > 0) {
|
||||||
|
const keys = new Set(incidents.map((i) => i.flightNumber || "Other"));
|
||||||
|
setCollapsedGroups(keys);
|
||||||
|
}
|
||||||
|
}, [incidents]);
|
||||||
|
|
||||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -250,40 +165,123 @@ export default function RecoveryIncidentsList() {
|
|||||||
setSelectedIds(newSelected);
|
setSelectedIds(newSelected);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleGroup = (id: string, e: React.MouseEvent) => {
|
const toggleGroup = (groupKey: string, e?: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
if (e) e.stopPropagation();
|
||||||
setCollapsedGroups((prev) => {
|
setCollapsedGroups((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(groupKey)) {
|
||||||
next.delete(id);
|
next.delete(groupKey);
|
||||||
} else {
|
} else {
|
||||||
next.add(id);
|
next.add(groupKey);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (incident: RecoveryIncident, text: string, variant: "success" | "error" | "warning" | "info" | "neutral" | "brand") => {
|
const handleStatusChange = async (incident: RecoveryIncident, text: string) => {
|
||||||
try {
|
try {
|
||||||
const { updateRecoveryIncident } = await import('../RecoveryIncidentsApi');
|
const { updateRecoveryIncident } = await import('../RecoveryIncidentsApi');
|
||||||
await updateRecoveryIncident(incident.id, {
|
await updateRecoveryIncident(incident.id, {
|
||||||
statuses: [{ text, variant }]
|
status: text
|
||||||
});
|
});
|
||||||
fetchIncidents(currentPage);
|
fetchIncidents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update status", 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 startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
||||||
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
||||||
|
|
||||||
const displayData = useMemo(() => {
|
const paginatedDisplayData = useMemo(() => {
|
||||||
if (!isGrouped) return incidents;
|
const start = (currentPage - 1) * PAGE_SIZE;
|
||||||
return incidents.filter(
|
return displayData.slice(start, start + PAGE_SIZE);
|
||||||
(p) => p.isGroupHeader || !collapsedGroups.has(p.recoveryCode)
|
}, [displayData, currentPage]);
|
||||||
);
|
|
||||||
}, [incidents, isGrouped, collapsedGroups]);
|
|
||||||
|
|
||||||
// ─── Table columns ─────────────────────────────────────────────────────────
|
// ─── Table columns ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -311,7 +309,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
accessor: (row) => (
|
accessor: (row) => (
|
||||||
<div>
|
<div>
|
||||||
<PrimaryText text={row.recoveryCode} />
|
<PrimaryText text={row.recoveryCode} />
|
||||||
<SecondaryText text={row.date} />
|
<SecondaryText text={formatDate(row.date)} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -319,7 +317,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
header: (
|
header: (
|
||||||
<HeaderLabel
|
<HeaderLabel
|
||||||
text="Passenger / PNR"
|
text="Passenger / PNR"
|
||||||
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
|
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
accessor: (row) =>
|
accessor: (row) =>
|
||||||
@@ -334,7 +332,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
header: (
|
header: (
|
||||||
<HeaderLabel
|
<HeaderLabel
|
||||||
text="Flight"
|
text="Flight"
|
||||||
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
|
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
accessor: (row) => (
|
accessor: (row) => (
|
||||||
@@ -351,23 +349,34 @@ export default function RecoveryIncidentsList() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: <HeaderLabel text="Status" />,
|
header: <HeaderLabel text="Status" />,
|
||||||
accessor: (row) => (
|
accessor: (row) => {
|
||||||
<div className="flex items-center gap-2">
|
if (row.isGroupHeader) {
|
||||||
{row.statuses.map((status, idx) => (
|
return (
|
||||||
<CustomStatus
|
<div className="flex items-center gap-2">
|
||||||
key={idx}
|
{(row.statuses || []).map((status, idx) => (
|
||||||
status={status.text}
|
<CustomStatus
|
||||||
variant={status.variant}
|
key={idx}
|
||||||
/>
|
status={status.text}
|
||||||
))}
|
variant={status.variant}
|
||||||
</div>
|
/>
|
||||||
),
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const statusText = row.status || "Pending";
|
||||||
|
return (
|
||||||
|
<CustomStatus
|
||||||
|
status={statusText}
|
||||||
|
variant={getStatusVariant(statusText)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: (
|
header: (
|
||||||
<HeaderLabel
|
<HeaderLabel
|
||||||
text="Value"
|
text="Value"
|
||||||
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
|
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
accessor: (row) => <PrimaryText text={row.value} />,
|
accessor: (row) => <PrimaryText text={row.value} />,
|
||||||
@@ -382,62 +391,90 @@ export default function RecoveryIncidentsList() {
|
|||||||
{
|
{
|
||||||
header: <HeaderLabel text="Action" />,
|
header: <HeaderLabel text="Action" />,
|
||||||
className: "text-right",
|
className: "text-right",
|
||||||
accessor: (row) => (
|
accessor: (row) => {
|
||||||
<div className="flex justify-end pr-2">
|
const key = (row as any).groupKey || row.flightNumber || row.recoveryCode;
|
||||||
{row.isGroupHeader ? (
|
return (
|
||||||
<div
|
<div className="flex justify-end pr-2">
|
||||||
className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1"
|
{row.isGroupHeader ? (
|
||||||
onClick={(e) => toggleGroup(row.recoveryCode, e)}
|
<div
|
||||||
>
|
className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1"
|
||||||
{collapsedGroups.has(row.recoveryCode) ? (
|
onClick={(e) => toggleGroup(key, e)}
|
||||||
<CaretDown size={20} />
|
|
||||||
) : (
|
|
||||||
<CaretUp size={20} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CustomActionMenu>
|
|
||||||
<CustomActionItem
|
|
||||||
onClick={() => navigate(`/recovery/${row.id}`)}
|
|
||||||
icon={<Eye size={16} className="text-blue-500" />}
|
|
||||||
>
|
>
|
||||||
View Details
|
{collapsedGroups.has(key) ? (
|
||||||
</CustomActionItem>
|
<CaretDownIcon size={20} />
|
||||||
<CustomActionItem
|
) : (
|
||||||
onClick={() => {
|
<CaretUpIcon size={20} />
|
||||||
setEditingIncident(row);
|
)}
|
||||||
setIsModalOpen(true);
|
</div>
|
||||||
}}
|
) : (
|
||||||
icon={<PencilSimple size={16} className="text-yellow-500" />}
|
<CustomActionMenu>
|
||||||
>
|
<CustomActionItem
|
||||||
Edit Incident
|
onClick={() => navigate(`/recovery/${row.id}`)}
|
||||||
</CustomActionItem>
|
icon={<EyeIcon size={16} className="text-blue-500" />}
|
||||||
<CustomActionItem
|
>
|
||||||
icon={<CheckCircle size={16} className="text-green-500" />}
|
View Details
|
||||||
onClick={() => handleStatusChange(row, "Approved", "success")}
|
</CustomActionItem>
|
||||||
>
|
<CustomActionItem
|
||||||
Approve
|
onClick={() => {
|
||||||
</CustomActionItem>
|
setEditingIncident(row);
|
||||||
<CustomActionItem
|
setIsModalOpen(true);
|
||||||
variant="danger"
|
}}
|
||||||
icon={<XCircle size={16} className="text-red-500" />}
|
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
|
||||||
onClick={() => handleStatusChange(row, "Rejected", "error")}
|
>
|
||||||
>
|
Edit Incident
|
||||||
Reject
|
</CustomActionItem>
|
||||||
</CustomActionItem>
|
<CustomActionItem
|
||||||
<CustomActionItem
|
icon={<CheckCircleIcon size={16} className="text-green-500" />}
|
||||||
icon={<Clock size={16} className="text-yellow-600" />}
|
onClick={() => handleStatusChange(row, "Approved")}
|
||||||
onClick={() => handleStatusChange(row, "Under Review", "warning")}
|
>
|
||||||
>
|
Approve
|
||||||
Mark for Review
|
</CustomActionItem>
|
||||||
</CustomActionItem>
|
<CustomActionItem
|
||||||
</CustomActionMenu>
|
variant="danger"
|
||||||
)}
|
icon={<XCircleIcon size={16} className="text-red-500" />}
|
||||||
</div>
|
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 ──────────────────────────────────────────────────────
|
// ─── Loading skeleton ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -464,52 +501,22 @@ export default function RecoveryIncidentsList() {
|
|||||||
<div className="w-full flex flex-col gap-6">
|
<div className="w-full flex flex-col gap-6">
|
||||||
{/* Metrics Row */}
|
{/* Metrics Row */}
|
||||||
<div className="flex gap-4 w-full">
|
<div className="flex gap-4 w-full">
|
||||||
<MetricCard
|
{metrics.map((metric) => (
|
||||||
title="Total Recoveries"
|
<MetricCard key={metric.id || metric.title} {...metric} />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Section */}
|
{/* Table Section */}
|
||||||
<CustomTable<RecoveryIncident>
|
<CustomTable<RecoveryIncident>
|
||||||
columns={columns}
|
columns={activeColumns}
|
||||||
data={displayData}
|
data={paginatedDisplayData}
|
||||||
leftHeaderActions={
|
leftHeaderActions={
|
||||||
<div className="w-[320px]">
|
<div className="w-[320px]">
|
||||||
<CustomInput
|
<CustomInput
|
||||||
placeholder="Search framework registry..."
|
placeholder="Search framework registry..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
leftIcon={<MagnifyingGlass size={16} />}
|
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||||
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
|
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
|
||||||
containerClassName="!gap-0"
|
containerClassName="!gap-0"
|
||||||
/>
|
/>
|
||||||
@@ -520,15 +527,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
<CustomButton
|
<CustomButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="md"
|
size="md"
|
||||||
leftIcon={<FadersHorizontal size={16} />}
|
leftIcon={<SquaresFourIcon 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} />}
|
|
||||||
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
|
||||||
onClick={() => setIsGrouped(!isGrouped)}
|
onClick={() => setIsGrouped(!isGrouped)}
|
||||||
>
|
>
|
||||||
@@ -537,7 +536,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="md"
|
||||||
leftIcon={<Plus size={16} />}
|
leftIcon={<PlusIcon size={16} />}
|
||||||
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingIncident(null);
|
setEditingIncident(null);
|
||||||
@@ -554,20 +553,27 @@ export default function RecoveryIncidentsList() {
|
|||||||
startIndex={startIndex}
|
startIndex={startIndex}
|
||||||
endIndex={endIndex}
|
endIndex={endIndex}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
itemName="Policies"
|
itemName="Recovery Incidents"
|
||||||
onRowClick={(row) => !row.isGroupHeader && navigate(`/recovery/${row.id}`)}
|
onRowClick={(row) => {
|
||||||
rowClassName={(row) => row.isGroupHeader ? "bg-white" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"}
|
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 */}
|
{/* Modal */}
|
||||||
<AddRecoveryIncidents
|
<AddRecoveryIncidents
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
incident={editingIncident}
|
incident={editingIncident}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingIncident(null);
|
setEditingIncident(null);
|
||||||
fetchIncidents(currentPage); // Refresh list
|
fetchIncidents(); // Refresh list
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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.' },
|
'/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': { 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.' },
|
'/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.' },
|
'/action-builder': { title: 'Configuration - 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.' },
|
'/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.' },
|
'/recovery': { title: 'Recovery Incidents', subtitle: 'Operational workspace for managing passenger disruption cases.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
GearIcon,
|
GearIcon,
|
||||||
ClockCounterClockwiseIcon,
|
ClockCounterClockwiseIcon,
|
||||||
CaretDoubleRightIcon,
|
CaretDoubleRightIcon,
|
||||||
LightningIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { ShieldCheckIcon } from "lucide-react";
|
import { ShieldCheckIcon } from "lucide-react";
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ const NAV_ITEMS = [
|
|||||||
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon },
|
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon },
|
||||||
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon },
|
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon },
|
||||||
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
|
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
|
||||||
{ label: "Action Builder", path: "/action-builder", icon: LightningIcon },
|
|
||||||
{ label: "Configuration", path: "/config", icon: GearIcon },
|
{ label: "Configuration", path: "/config", icon: GearIcon },
|
||||||
{ label: "Audit Logs", path: "/audit", icon: ClockCounterClockwiseIcon },
|
{ label: "Audit Logs", path: "/audit", icon: ClockCounterClockwiseIcon },
|
||||||
];
|
];
|
||||||
@@ -111,11 +109,10 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.innerWidth < 1024) onClose();
|
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 ${
|
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
|
||||||
isActive
|
|
||||||
? "bg-gradient-to-b from-primary to-primary-dark text-white shadow-md shadow-primary/20 font-semibold"
|
? "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"
|
: "text-[#475569] font-medium hover:bg-slate-200/40 hover:text-slate-900"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={18}
|
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