mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 13:40:57 +00:00
Merge freight/develop
This commit is contained in:
@@ -1,718 +0,0 @@
|
||||
// src/components/ruleEngine/ContractType.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// ==================== Toast Notification Component ====================
|
||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== API Service ====================
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||
|
||||
const apiService = {
|
||||
// Cargo Types
|
||||
getCargoTypes: () => fetch(`${API_BASE_URL}/cargo-types`).then(res => res.json()),
|
||||
createCargoType: (data: any) => fetch(`${API_BASE_URL}/cargo-types`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
updateCargoType: (id: string, data: any) => fetch(`${API_BASE_URL}/cargo-types/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
deleteCargoType: (id: string) => fetch(`${API_BASE_URL}/cargo-types/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json()),
|
||||
|
||||
// Container Types
|
||||
getContainerTypes: () => fetch(`${API_BASE_URL}/container-types`).then(res => res.json()),
|
||||
createContainerType: (data: any) => fetch(`${API_BASE_URL}/container-types`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
updateContainerType: (id: string, data: any) => fetch(`${API_BASE_URL}/container-types/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
deleteContainerType: (id: string) => fetch(`${API_BASE_URL}/container-types/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json()),
|
||||
|
||||
// Priority Rules
|
||||
getPriorityRules: () => fetch(`${API_BASE_URL}/priority-rules`).then(res => res.json()),
|
||||
createPriorityRule: (data: any) => fetch(`${API_BASE_URL}/priority-rules`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
updatePriorityRule: (id: string, data: any) => fetch(`${API_BASE_URL}/priority-rules/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
deletePriorityRule: (id: string) => fetch(`${API_BASE_URL}/priority-rules/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json()),
|
||||
|
||||
// Service Types
|
||||
getServiceTypes: () => fetch(`${API_BASE_URL}/service-types`).then(res => res.json()),
|
||||
createServiceType: (data: any) => fetch(`${API_BASE_URL}/service-types`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
updateServiceType: (id: string, data: any) => fetch(`${API_BASE_URL}/service-types/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
deleteServiceType: (id: string) => fetch(`${API_BASE_URL}/service-types/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json()),
|
||||
|
||||
// Surcharge Types
|
||||
getSurchargeTypes: () => fetch(`${API_BASE_URL}/surcharge-types`).then(res => res.json()),
|
||||
createSurchargeType: (data: any) => fetch(`${API_BASE_URL}/surcharge-types`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
updateSurchargeType: (id: string, data: any) => fetch(`${API_BASE_URL}/surcharge-types/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
deleteSurchargeType: (id: string) => fetch(`${API_BASE_URL}/surcharge-types/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json()),
|
||||
|
||||
// Surcharges
|
||||
getSurcharges: () => fetch(`${API_BASE_URL}/surcharges`).then(res => res.json()),
|
||||
createSurcharge: (data: any) => fetch(`${API_BASE_URL}/surcharges`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
updateSurcharge: (id: string, data: any) => fetch(`${API_BASE_URL}/surcharges/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
deleteSurcharge: (id: string) => fetch(`${API_BASE_URL}/surcharges/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json()),
|
||||
|
||||
// Weight Limit Rules
|
||||
getWeightLimitRules: () => fetch(`${API_BASE_URL}/weight-limit-rules`).then(res => res.json()),
|
||||
createWeightLimitRule: (data: any) => fetch(`${API_BASE_URL}/weight-limit-rules`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
updateWeightLimitRule: (id: string, data: any) => fetch(`${API_BASE_URL}/weight-limit-rules/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(res => res.json()),
|
||||
deleteWeightLimitRule: (id: string) => fetch(`${API_BASE_URL}/weight-limit-rules/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json()),
|
||||
};
|
||||
|
||||
// ==================== Entity Table Component ====================
|
||||
const EntityTable = ({
|
||||
title,
|
||||
data,
|
||||
columns,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isLoading
|
||||
}: any) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredData = data?.filter((item: any) =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
) || [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-green-500 border-t-transparent"></div>
|
||||
<p className="mt-2">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden transition-all duration-300">
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-gray-50 to-white border-b border-gray-200 cursor-pointer hover:bg-gray-50 transition-colors group"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-green-600 transition-transform group-hover:scale-110">
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">
|
||||
{filteredData.length} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
||||
<button
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-all hover:bg-green-700 hover:shadow-md"
|
||||
onClick={onAdd}
|
||||
>
|
||||
+ Add {title.slice(0, -1)}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((col: any) => (
|
||||
<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredData.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 transition-colors">
|
||||
{columns.map((col: any) => (
|
||||
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{col.render ? col.render(item[col.key], item) : item[col.key]}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button
|
||||
className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs font-medium transition-all hover:bg-yellow-600 mr-2"
|
||||
onClick={() => onEdit(item)}
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button
|
||||
className="bg-red-600 text-white px-3 py-1 rounded text-xs font-medium transition-all hover:bg-red-700"
|
||||
onClick={() => onDelete(item)}
|
||||
>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredData.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="mt-2">No data found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Main Component ====================
|
||||
const ContractTypePage = () => {
|
||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [currentEntity, setCurrentEntity] = useState('');
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error') => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
// Fetch all data
|
||||
const { data: cargoTypes = [], isLoading: cargoLoading } = useQuery({
|
||||
queryKey: ['cargo-types'],
|
||||
queryFn: apiService.getCargoTypes,
|
||||
});
|
||||
|
||||
const { data: containerTypes = [], isLoading: containerLoading } = useQuery({
|
||||
queryKey: ['container-types'],
|
||||
queryFn: apiService.getContainerTypes,
|
||||
});
|
||||
|
||||
const { data: priorityRules = [], isLoading: priorityLoading } = useQuery({
|
||||
queryKey: ['priority-rules'],
|
||||
queryFn: apiService.getPriorityRules,
|
||||
});
|
||||
|
||||
const { data: serviceTypes = [], isLoading: serviceLoading } = useQuery({
|
||||
queryKey: ['service-types'],
|
||||
queryFn: apiService.getServiceTypes,
|
||||
});
|
||||
|
||||
const { data: surchargeTypes = [], isLoading: surchargeTypeLoading } = useQuery({
|
||||
queryKey: ['surcharge-types'],
|
||||
queryFn: apiService.getSurchargeTypes,
|
||||
});
|
||||
|
||||
const { data: surcharges = [], isLoading: surchargeLoading } = useQuery({
|
||||
queryKey: ['surcharges'],
|
||||
queryFn: apiService.getSurcharges,
|
||||
});
|
||||
|
||||
const { data: weightLimitRules = [], isLoading: weightLimitLoading } = useQuery({
|
||||
queryKey: ['weight-limit-rules'],
|
||||
queryFn: apiService.getWeightLimitRules,
|
||||
});
|
||||
|
||||
// Mutations for Cargo Types
|
||||
const createCargoType = useMutation({
|
||||
mutationFn: apiService.createCargoType,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cargo-types'] });
|
||||
showToast('Cargo type created successfully', 'success');
|
||||
setModalOpen(false);
|
||||
setFormData({});
|
||||
},
|
||||
onError: () => showToast('Failed to create cargo type', 'error'),
|
||||
});
|
||||
|
||||
const updateCargoType = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => apiService.updateCargoType(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cargo-types'] });
|
||||
showToast('Cargo type updated successfully', 'success');
|
||||
setModalOpen(false);
|
||||
setFormData({});
|
||||
setEditingItem(null);
|
||||
},
|
||||
onError: () => showToast('Failed to update cargo type', 'error'),
|
||||
});
|
||||
|
||||
const deleteCargoType = useMutation({
|
||||
mutationFn: apiService.deleteCargoType,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cargo-types'] });
|
||||
showToast('Cargo type deleted successfully', 'success');
|
||||
},
|
||||
onError: () => showToast('Failed to delete cargo type', 'error'),
|
||||
});
|
||||
|
||||
const handleAdd = (entity: string) => {
|
||||
setCurrentEntity(entity);
|
||||
setEditingItem(null);
|
||||
setFormData(getDefaultFormData(entity));
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (entity: string, item: any) => {
|
||||
setCurrentEntity(entity);
|
||||
setEditingItem(item);
|
||||
setFormData(item);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (entity: string, item: any) => {
|
||||
if (window.confirm(`Are you sure you want to delete this ${entity}?`)) {
|
||||
if (entity === 'cargo-types') deleteCargoType.mutate(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (currentEntity === 'cargo-types') {
|
||||
if (editingItem) {
|
||||
updateCargoType.mutate({ id: editingItem.id, data: formData });
|
||||
} else {
|
||||
createCargoType.mutate(formData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultFormData = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
return { code: '', cargoTypeName: '', showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 1 };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const getEntityData = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types': return cargoTypes;
|
||||
case 'container-types': return containerTypes;
|
||||
case 'priority-rules': return priorityRules;
|
||||
case 'service-types': return serviceTypes;
|
||||
case 'surcharge-types': return surchargeTypes;
|
||||
case 'surcharges': return surcharges;
|
||||
case 'weight-limit-rules': return weightLimitRules;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getEntityLoading = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types': return cargoLoading;
|
||||
case 'container-types': return containerLoading;
|
||||
case 'priority-rules': return priorityLoading;
|
||||
case 'service-types': return serviceLoading;
|
||||
case 'surcharge-types': return surchargeTypeLoading;
|
||||
case 'surcharges': return surchargeLoading;
|
||||
case 'weight-limit-rules': return weightLimitLoading;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getColumns = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
return [
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'cargoTypeName', label: 'Name' },
|
||||
{ key: 'displayOrder', label: 'Order' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (val: boolean) => (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{val ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
case 'container-types':
|
||||
return [
|
||||
{ key: 'sizeCode', label: 'Size Code' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'containersPerWagon', label: 'Containers/Wagon' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (val: boolean) => (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{val ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
case 'priority-rules':
|
||||
return [
|
||||
{ key: 'priorityType', label: 'Priority Type' },
|
||||
{ key: 'ruleName', label: 'Rule Name' },
|
||||
{ key: 'bonusPoints', label: 'Bonus Points' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (val: boolean) => (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{val ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
case 'service-types':
|
||||
return [
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'serviceName', label: 'Service Name' },
|
||||
{ key: 'displayOrder', label: 'Order' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (val: boolean) => (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{val ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
case 'surcharge-types':
|
||||
return [
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (val: boolean) => (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{val ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
case 'surcharges':
|
||||
return [
|
||||
{ key: 'feeName', label: 'Fee Name' },
|
||||
{ key: 'calculationMethod', label: 'Method' },
|
||||
{
|
||||
key: 'rate',
|
||||
label: 'Rate',
|
||||
render: (val: number, item: any) => `${val} ${item.currency}`
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (val: boolean) => (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{val ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
case 'weight-limit-rules':
|
||||
return [
|
||||
{ key: 'tradeDirection', label: 'Direction' },
|
||||
{
|
||||
key: 'maxWeightTons',
|
||||
label: 'Max Weight',
|
||||
render: (val: number) => `${val} tons`
|
||||
},
|
||||
{ key: 'exceededAction', label: 'Action' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (val: boolean) => (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{val ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'cargo-types', label: 'Cargo Types' },
|
||||
{ id: 'container-types', label: 'Container Types' },
|
||||
{ id: 'priority-rules', label: 'Priority Rules' },
|
||||
{ id: 'service-types', label: 'Service Types' },
|
||||
{ id: 'surcharge-types', label: 'Surcharge Types' },
|
||||
{ id: 'surcharges', label: 'Surcharges' },
|
||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules' },
|
||||
];
|
||||
|
||||
const isLoading = cargoLoading || containerLoading || priorityLoading || serviceLoading || surchargeTypeLoading || surchargeLoading || weightLimitLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-green-500 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600">Loading master data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contract-type-page">
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="flex space-x-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'text-green-600 border-b-2 border-green-600'
|
||||
: 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
||||
<EntityTable
|
||||
title={tab.label}
|
||||
data={getEntityData(tab.id)}
|
||||
columns={getColumns(tab.id)}
|
||||
onAdd={() => handleAdd(tab.id)}
|
||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
||||
isLoading={getEntityLoading(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{modalOpen && currentEntity === 'cargo-types' && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="flex justify-between items-center px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{editingItem ? 'Edit Cargo Type' : 'Add Cargo Type'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
setEditingItem(null);
|
||||
setFormData({});
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={formData.code || ''}
|
||||
onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={formData.cargoTypeName || ''}
|
||||
onChange={(e) => setFormData({...formData, cargoTypeName: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={formData.displayOrder || 1}
|
||||
onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showFreeTextBox"
|
||||
className="mr-2"
|
||||
checked={formData.showFreeTextBox || false}
|
||||
onChange={(e) => setFormData({...formData, showFreeTextBox: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor="showFreeTextBox" className="text-sm text-gray-700">Show Free Text Box</label>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="requiresDirectorApproval"
|
||||
className="mr-2"
|
||||
checked={formData.requiresDirectorApproval || false}
|
||||
onChange={(e) => setFormData({...formData, requiresDirectorApproval: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor="requiresDirectorApproval" className="text-sm text-gray-700">Requires Director Approval</label>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
className="mr-2"
|
||||
checked={formData.isActive !== false}
|
||||
onChange={(e) => setFormData({...formData, isActive: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
setEditingItem(null);
|
||||
setFormData({});
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createCargoType.isPending || updateCargoType.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{createCargoType.isPending || updateCargoType.isPending ? 'Saving...' : editingItem ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractTypePage;
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
// src/components/ruleEngine/ContractType.tsx
|
||||
import { useState } from 'react';
|
||||
|
||||
// ==================== MOCK DATA (Replace with your API calls later) ====================
|
||||
const mockCargoTypes = [
|
||||
{ id: '1', code: 'BULK', cargoTypeName: 'Bulk Cargo', displayOrder: 1, isActive: true },
|
||||
{ id: '2', code: 'BREAK_BULK', cargoTypeName: 'Break Bulk', displayOrder: 2, isActive: true },
|
||||
{ id: '3', code: 'CONTAINER', cargoTypeName: 'Containerized', displayOrder: 3, isActive: false },
|
||||
{ id: '4', code: 'LIQUID', cargoTypeName: 'Liquid Bulk', displayOrder: 4, isActive: true },
|
||||
];
|
||||
|
||||
const mockContainerTypes = [
|
||||
{ id: '1', sizeCode: '20FT', description: '20 Foot Standard Container', containersPerWagon: 2, isActive: true },
|
||||
{ id: '2', sizeCode: '40FT', description: '40 Foot Standard Container', containersPerWagon: 1, isActive: true },
|
||||
{ id: '3', sizeCode: '20RF', description: '20 Foot Refrigerated', containersPerWagon: 2, isActive: true },
|
||||
];
|
||||
|
||||
const mockPriorityRules = [
|
||||
{ id: '1', priorityType: 'HIGH', ruleName: 'High Priority Booking', bonusPoints: 100, isActive: true },
|
||||
{ id: '2', priorityType: 'URGENT', ruleName: 'Urgent Delivery', bonusPoints: 200, isActive: true },
|
||||
{ id: '3', priorityType: 'LOW', ruleName: 'Standard Booking', bonusPoints: 0, isActive: true },
|
||||
];
|
||||
|
||||
const mockServiceTypes = [
|
||||
{ id: '1', code: 'RAIL', serviceName: 'Rail Only', displayOrder: 1, isActive: true },
|
||||
{ id: '2', code: 'RAIL_FIRST', serviceName: 'Rail + First Mile', displayOrder: 2, isActive: true },
|
||||
{ id: '3', code: 'RAIL_LAST', serviceName: 'Rail + Last Mile', displayOrder: 3, isActive: false },
|
||||
];
|
||||
|
||||
const mockSurchargeTypes = [
|
||||
{ id: '1', code: 'HAZ', name: 'Hazardous Material', isActive: true },
|
||||
{ id: '2', code: 'REF', name: 'Refrigerated', isActive: true },
|
||||
{ id: '3', code: 'OVR', name: 'Overweight', isActive: true },
|
||||
];
|
||||
|
||||
const mockSurcharges = [
|
||||
{ id: '1', feeName: 'Hazardous Fee', calculationMethod: 'FLAT', rate: 150, currency: 'USD', isActive: true },
|
||||
{ id: '2', feeName: 'Refrigeration Fee', calculationMethod: 'PER_TON', rate: 25, currency: 'USD', isActive: true },
|
||||
];
|
||||
|
||||
const mockWeightLimitRules = [
|
||||
{ id: '1', tradeDirection: 'IMPORT', maxWeightTons: 20, exceededAction: 'WARNING_ONLY', isActive: true },
|
||||
{ id: '2', tradeDirection: 'EXPORT', maxWeightTons: 22, exceededAction: 'BLOCK', isActive: true },
|
||||
];
|
||||
|
||||
// ==================== Toast Component ====================
|
||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
||||
setTimeout(onClose, 3000);
|
||||
const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Entity Table Component ====================
|
||||
const EntityTable = ({ title, data, columns, onAdd, onEdit, onDelete }: any) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredData = Array.isArray(data) ? data.filter((item: any) =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">
|
||||
{filteredData.length} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
||||
<button
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700"
|
||||
onClick={onAdd}
|
||||
>
|
||||
+ Add {title.slice(0, -1)}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((col: any) => (
|
||||
<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredData.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
{columns.map((col: any) => (
|
||||
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{col.render ? col.render(item[col.key], item) : item[col.key]}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button
|
||||
className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs mr-2 hover:bg-yellow-600"
|
||||
onClick={() => onEdit(item)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="bg-red-600 text-white px-3 py-1 rounded text-xs hover:bg-red-700"
|
||||
onClick={() => onDelete(item)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredData.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">No data found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Main Component ====================
|
||||
const ContractTypePage = () => {
|
||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
// State for each entity
|
||||
const [cargoTypes, setCargoTypes] = useState(mockCargoTypes);
|
||||
const [containerTypes, setContainerTypes] = useState(mockContainerTypes);
|
||||
const [priorityRules, setPriorityRules] = useState(mockPriorityRules);
|
||||
const [serviceTypes, setServiceTypes] = useState(mockServiceTypes);
|
||||
const [surchargeTypes, setSurchargeTypes] = useState(mockSurchargeTypes);
|
||||
const [surcharges, setSurcharges] = useState(mockSurcharges);
|
||||
const [weightLimitRules, setWeightLimitRules] = useState(mockWeightLimitRules);
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error') => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleAdd = (entity: string) => {
|
||||
const newId = String(Date.now());
|
||||
let newItem;
|
||||
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
newItem = { id: newId, code: 'NEW', cargoTypeName: 'New Type', displayOrder: cargoTypes.length + 1, isActive: true };
|
||||
setCargoTypes([...cargoTypes, newItem]);
|
||||
break;
|
||||
case 'container-types':
|
||||
newItem = { id: newId, sizeCode: 'NEW', description: 'New Container', containersPerWagon: 1, isActive: true };
|
||||
setContainerTypes([...containerTypes, newItem]);
|
||||
break;
|
||||
case 'priority-rules':
|
||||
newItem = { id: newId, priorityType: 'MEDIUM', ruleName: 'New Rule', bonusPoints: 0, isActive: true };
|
||||
setPriorityRules([...priorityRules, newItem]);
|
||||
break;
|
||||
case 'service-types':
|
||||
newItem = { id: newId, code: 'NEW', serviceName: 'New Service', displayOrder: serviceTypes.length + 1, isActive: true };
|
||||
setServiceTypes([...serviceTypes, newItem]);
|
||||
break;
|
||||
case 'surcharge-types':
|
||||
newItem = { id: newId, code: 'NEW', name: 'New Surcharge Type', isActive: true };
|
||||
setSurchargeTypes([...surchargeTypes, newItem]);
|
||||
break;
|
||||
case 'surcharges':
|
||||
newItem = { id: newId, feeName: 'New Fee', calculationMethod: 'FLAT', rate: 0, currency: 'USD', isActive: true };
|
||||
setSurcharges([...surcharges, newItem]);
|
||||
break;
|
||||
case 'weight-limit-rules':
|
||||
newItem = { id: newId, tradeDirection: 'IMPORT', maxWeightTons: 20, exceededAction: 'WARNING_ONLY', isActive: true };
|
||||
setWeightLimitRules([...weightLimitRules, newItem]);
|
||||
break;
|
||||
}
|
||||
showToast(`${entity} added successfully`, 'success');
|
||||
};
|
||||
|
||||
const handleEdit = (entity: string, item: any) => {
|
||||
showToast(`Edit ${item.code || item.sizeCode || item.ruleName || item.serviceName || item.name || item.feeName}`, 'success');
|
||||
};
|
||||
|
||||
const handleDelete = (entity: string, item: any) => {
|
||||
if (confirm('Are you sure you want to delete this item?')) {
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
setCargoTypes(cargoTypes.filter(c => c.id !== item.id));
|
||||
break;
|
||||
case 'container-types':
|
||||
setContainerTypes(containerTypes.filter(c => c.id !== item.id));
|
||||
break;
|
||||
case 'priority-rules':
|
||||
setPriorityRules(priorityRules.filter(p => p.id !== item.id));
|
||||
break;
|
||||
case 'service-types':
|
||||
setServiceTypes(serviceTypes.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'surcharge-types':
|
||||
setSurchargeTypes(surchargeTypes.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'surcharges':
|
||||
setSurcharges(surcharges.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'weight-limit-rules':
|
||||
setWeightLimitRules(weightLimitRules.filter(w => w.id !== item.id));
|
||||
break;
|
||||
}
|
||||
showToast(`${entity} deleted successfully`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const getColumns = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
return [
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'cargoTypeName', label: 'Name' },
|
||||
{ key: 'displayOrder', label: 'Order' },
|
||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
||||
];
|
||||
case 'container-types':
|
||||
return [
|
||||
{ key: 'sizeCode', label: 'Size Code' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'containersPerWagon', label: 'Containers/Wagon' },
|
||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
||||
];
|
||||
case 'priority-rules':
|
||||
return [
|
||||
{ key: 'priorityType', label: 'Priority Type' },
|
||||
{ key: 'ruleName', label: 'Rule Name' },
|
||||
{ key: 'bonusPoints', label: 'Bonus Points' },
|
||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
||||
];
|
||||
case 'service-types':
|
||||
return [
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'serviceName', label: 'Service Name' },
|
||||
{ key: 'displayOrder', label: 'Order' },
|
||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
||||
];
|
||||
case 'surcharge-types':
|
||||
return [
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
||||
];
|
||||
case 'surcharges':
|
||||
return [
|
||||
{ key: 'feeName', label: 'Fee Name' },
|
||||
{ key: 'calculationMethod', label: 'Method' },
|
||||
{ key: 'rate', label: 'Rate', render: (val: number, item: any) => `${val} ${item.currency}` },
|
||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
||||
];
|
||||
case 'weight-limit-rules':
|
||||
return [
|
||||
{ key: 'tradeDirection', label: 'Direction' },
|
||||
{ key: 'maxWeightTons', label: 'Max Weight', render: (val: number) => `${val} tons` },
|
||||
{ key: 'exceededAction', label: 'Action' },
|
||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getEntityData = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types': return cargoTypes;
|
||||
case 'container-types': return containerTypes;
|
||||
case 'priority-rules': return priorityRules;
|
||||
case 'service-types': return serviceTypes;
|
||||
case 'surcharge-types': return surchargeTypes;
|
||||
case 'surcharges': return surcharges;
|
||||
case 'weight-limit-rules': return weightLimitRules;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'cargo-types', label: 'Cargo Types' },
|
||||
{ id: 'container-types', label: 'Container Types' },
|
||||
{ id: 'priority-rules', label: 'Priority Rules' },
|
||||
{ id: 'service-types', label: 'Service Types' },
|
||||
{ id: 'surcharge-types', label: 'Surcharge Types' },
|
||||
{ id: 'surcharges', label: 'Surcharges' },
|
||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="contract-type-page">
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Rule Engine - Master Data</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage cargo types, container types, priority rules, and more</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="flex space-x-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'text-green-600 border-b-2 border-green-600'
|
||||
: 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
||||
<EntityTable
|
||||
title={tab.label}
|
||||
data={getEntityData(tab.id)}
|
||||
columns={getColumns(tab.id)}
|
||||
onAdd={() => handleAdd(tab.id)}
|
||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractTypePage;
|
||||
@@ -1,874 +0,0 @@
|
||||
// src/components/ruleEngine/ContractType.tsx
|
||||
import { createCargoType } from '@/services/rule.engine/cargoType';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ==================== API Service ====================
|
||||
const API_BASE_URL = 'http://localhost:3001/api';
|
||||
|
||||
const apiFetch = async (endpoint: string, options?: RequestInit): Promise<any> => {
|
||||
try {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText || response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const apiService = {
|
||||
getCargoTypes: (): Promise<any[]> => apiFetch('/cargo-types'),
|
||||
createCargoType: (data: any): Promise<any> => apiFetch('/cargo-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateCargoType: (id: string, data: any): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteCargoType: (id: string): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getContainerTypes: (): Promise<any[]> => apiFetch('/container-types'),
|
||||
createContainerType: (data: any): Promise<any> => apiFetch('/container-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateContainerType: (id: string, data: any): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteContainerType: (id: string): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getPriorityRules: (): Promise<any[]> => apiFetch('/priority-rules'),
|
||||
createPriorityRule: (data: any): Promise<any> => apiFetch('/priority-rules', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updatePriorityRule: (id: string, data: any): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deletePriorityRule: (id: string): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getServiceTypes: (): Promise<any[]> => apiFetch('/service-types'),
|
||||
createServiceType: (data: any): Promise<any> => apiFetch('/service-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateServiceType: (id: string, data: any): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteServiceType: (id: string): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getSurchargeTypes: (): Promise<any[]> => apiFetch('/surcharge-types'),
|
||||
createSurchargeType: (data: any): Promise<any> => apiFetch('/surcharge-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateSurchargeType: (id: string, data: any): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteSurchargeType: (id: string): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getSurcharges: (): Promise<any[]> => apiFetch('/surcharges'),
|
||||
createSurcharge: (data: any): Promise<any> => apiFetch('/surcharges', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateSurcharge: (id: string, data: any): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteSurcharge: (id: string): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getWeightLimitRules: (): Promise<any[]> => apiFetch('/weight-limit-rules'),
|
||||
createWeightLimitRule: (data: any): Promise<any> => apiFetch('/weight-limit-rules', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateWeightLimitRule: (id: string, data: any): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteWeightLimitRule: (id: string): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ==================== Toast Component ====================
|
||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Modal Component ====================
|
||||
const Modal = ({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center px-6 py-4 border-b border-gray-200 sticky top-0 bg-white">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Form Components ====================
|
||||
|
||||
// 1. Cargo Type Form
|
||||
const CargoTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
code: initialData?.code || '',
|
||||
cargoTypeName: initialData?.cargoTypeName || '',
|
||||
parentGroupId: initialData?.parentGroupId || '',
|
||||
showFreeTextBox: initialData?.showFreeTextBox || false,
|
||||
requiresDirectorApproval: initialData?.requiresDirectorApproval || false,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
displayOrder: initialData?.displayOrder || 1,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const submitData = {
|
||||
code: formData.code.toUpperCase(),
|
||||
cargoTypeName: formData.cargoTypeName,
|
||||
parentGroupId: formData.parentGroupId || undefined,
|
||||
showFreeTextBox: formData.showFreeTextBox,
|
||||
requiresDirectorApproval: formData.requiresDirectorApproval,
|
||||
isActive: formData.isActive,
|
||||
displayOrder: Number(formData.displayOrder),
|
||||
};
|
||||
onSubmit(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.cargoTypeName} onChange={(e) => setFormData({...formData, cargoTypeName: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Parent Group ID</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.parentGroupId} onChange={(e) => setFormData({...formData, parentGroupId: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.showFreeTextBox} onChange={(e) => setFormData({...formData, showFreeTextBox: e.target.checked})} /> Show Free Text Box</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.requiresDirectorApproval} onChange={(e) => setFormData({...formData, requiresDirectorApproval: e.target.checked})} /> Requires Director Approval</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 2. Container Type Form
|
||||
const ContainerTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
sizeCode: initialData?.sizeCode || '',
|
||||
description: initialData?.description || '',
|
||||
containersPerWagon: initialData?.containersPerWagon || 1,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Size Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.sizeCode} onChange={(e) => setFormData({...formData, sizeCode: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Containers Per Wagon *</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containersPerWagon} onChange={(e) => setFormData({...formData, containersPerWagon: parseInt(e.target.value)})} required />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 3. Priority Rule Form
|
||||
const PriorityRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
priorityType: initialData?.priorityType || 'MEDIUM',
|
||||
ruleName: initialData?.ruleName || '',
|
||||
description: initialData?.description || '',
|
||||
activationCondition: initialData?.activationCondition || '',
|
||||
bonusPoints: initialData?.bonusPoints || 0,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Type *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityType} onChange={(e) => setFormData({...formData, priorityType: e.target.value})}>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="LOW">LOW</option>
|
||||
<option value="URGENT">URGENT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Bonus Points</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.bonusPoints} onChange={(e) => setFormData({...formData, bonusPoints: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rule Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.ruleName} onChange={(e) => setFormData({...formData, ruleName: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Activation Condition</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.activationCondition} onChange={(e) => setFormData({...formData, activationCondition: e.target.value})} placeholder="e.g., weight > 1000" />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 4. Service Type Form
|
||||
const ServiceTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
code: initialData?.code || '',
|
||||
serviceName: initialData?.serviceName || '',
|
||||
description: initialData?.description || '',
|
||||
canBeBookedAlone: initialData?.canBeBookedAlone !== undefined ? initialData.canBeBookedAlone : true,
|
||||
includesFirstMile: initialData?.includesFirstMile || false,
|
||||
includesLastMile: initialData?.includesLastMile || false,
|
||||
includesCustoms: initialData?.includesCustoms || false,
|
||||
priorityBonusPoints: initialData?.priorityBonusPoints || 0,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
displayOrder: initialData?.displayOrder || 1,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Service Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.serviceName} onChange={(e) => setFormData({...formData, serviceName: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Bonus Points</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityBonusPoints} onChange={(e) => setFormData({...formData, priorityBonusPoints: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.canBeBookedAlone} onChange={(e) => setFormData({...formData, canBeBookedAlone: e.target.checked})} /> Can Be Booked Alone</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesFirstMile} onChange={(e) => setFormData({...formData, includesFirstMile: e.target.checked})} /> Includes First Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesLastMile} onChange={(e) => setFormData({...formData, includesLastMile: e.target.checked})} /> Includes Last Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesCustoms} onChange={(e) => setFormData({...formData, includesCustoms: e.target.checked})} /> Includes Customs</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 5. Surcharge Type Form
|
||||
const SurchargeTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
code: initialData?.code || '',
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.name} onChange={(e) => setFormData({...formData, name: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 6. Surcharge Form
|
||||
const SurchargeForm = ({ initialData, onSubmit, onCancel, isSubmitting, surchargeTypes }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
surchargeTypeId: initialData?.surchargeTypeId || '',
|
||||
feeName: initialData?.feeName || '',
|
||||
triggerDescription: initialData?.triggerDescription || '',
|
||||
calculationMethod: initialData?.calculationMethod || 'FLAT',
|
||||
rate: initialData?.rate || 0,
|
||||
currency: initialData?.currency || 'USD',
|
||||
applyToRail: initialData?.applyToRail || false,
|
||||
applyToFirstMile: initialData?.applyToFirstMile || false,
|
||||
applyToLastMile: initialData?.applyToLastMile || false,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge Type *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeTypeId} onChange={(e) => setFormData({...formData, surchargeTypeId: e.target.value})} required>
|
||||
<option value="">Select Surcharge Type</option>
|
||||
{surchargeTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.name}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Fee Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.feeName} onChange={(e) => setFormData({...formData, feeName: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Calculation Method *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.calculationMethod} onChange={(e) => setFormData({...formData, calculationMethod: e.target.value})}>
|
||||
<option value="PER_TON">Per Ton</option>
|
||||
<option value="FLAT">Flat</option>
|
||||
<option value="PERCENTAGE">Percentage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rate *</label>
|
||||
<input type="number" step="0.01" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.rate} onChange={(e) => setFormData({...formData, rate: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Currency *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.currency} onChange={(e) => setFormData({...formData, currency: e.target.value.toUpperCase()})} maxLength={3} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trigger Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.triggerDescription} onChange={(e) => setFormData({...formData, triggerDescription: e.target.value})} />
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToRail} onChange={(e) => setFormData({...formData, applyToRail: e.target.checked})} /> Apply to Rail</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToFirstMile} onChange={(e) => setFormData({...formData, applyToFirstMile: e.target.checked})} /> Apply to First Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToLastMile} onChange={(e) => setFormData({...formData, applyToLastMile: e.target.checked})} /> Apply to Last Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 7. Weight Limit Rule Form
|
||||
const WeightLimitRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting, containerTypes, surcharges }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
containerTypeId: initialData?.containerTypeId || '',
|
||||
tradeDirection: initialData?.tradeDirection || 'IMPORT',
|
||||
maxWeightTons: initialData?.maxWeightTons || 20,
|
||||
warningThresholdTons: initialData?.warningThresholdTons || 18,
|
||||
exceededAction: initialData?.exceededAction || 'WARNING_ONLY',
|
||||
surchargeId: initialData?.surchargeId || '',
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Container Type *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containerTypeId} onChange={(e) => setFormData({...formData, containerTypeId: e.target.value})} required>
|
||||
<option value="">Select Container Type</option>
|
||||
{containerTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.sizeCode}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trade Direction *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.tradeDirection} onChange={(e) => setFormData({...formData, tradeDirection: e.target.value})}>
|
||||
<option value="IMPORT">Import</option>
|
||||
<option value="EXPORT">Export</option>
|
||||
<option value="DOMESTIC">Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Exceeded Action</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.exceededAction} onChange={(e) => setFormData({...formData, exceededAction: e.target.value})}>
|
||||
<option value="WARNING_ONLY">Warning Only</option>
|
||||
<option value="BLOCK">Block</option>
|
||||
<option value="SURCHARGE">Surcharge</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Weight (Tons) *</label>
|
||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.maxWeightTons} onChange={(e) => setFormData({...formData, maxWeightTons: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Warning Threshold (Tons) *</label>
|
||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.warningThresholdTons} onChange={(e) => setFormData({...formData, warningThresholdTons: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
</div>
|
||||
{formData.exceededAction === 'SURCHARGE' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeId} onChange={(e) => setFormData({...formData, surchargeId: e.target.value})}>
|
||||
<option value="">Select Surcharge</option>
|
||||
{surcharges?.map((surcharge: any) => (<option key={surcharge.id} value={surcharge.id}>{surcharge.feeName}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Entity Table Component ====================
|
||||
const EntityTable = ({ title, data, columns, onAdd, onEdit, onDelete, isLoading }: any) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredData = Array.isArray(data) ? data.filter((item: any) =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
) : [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-green-600">▼</span>
|
||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-green-500 border-t-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100" onClick={() => setExpanded(!expanded)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">{filteredData.length} items</span>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
||||
<button className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700" onClick={onAdd}>+ Add {title.slice(0, -1)}</button>
|
||||
<div className="relative">
|
||||
<input type="text" placeholder="Search..." className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0118 0z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((col: any) => (<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>))}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredData.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
{columns.map((col: any) => (<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{col.render ? col.render(item[col.key], item) : item[col.key]}</td>))}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs mr-2 hover:bg-yellow-600" onClick={() => onEdit(item)}>Edit</button>
|
||||
<button className="bg-red-600 text-white px-3 py-1 rounded text-xs hover:bg-red-700" onClick={() => onDelete(item)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredData.length === 0 && (<div className="text-center py-12 text-gray-500">No data found. Click "Add" to create one.</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Main Component ====================
|
||||
const ContractTypePage = () => {
|
||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [currentEntity, setCurrentEntity] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [cargoTypes, setCargoTypes] = useState<any[]>([]);
|
||||
const [containerTypes, setContainerTypes] = useState<any[]>([]);
|
||||
const [priorityRules, setPriorityRules] = useState<any[]>([]);
|
||||
const [serviceTypes, setServiceTypes] = useState<any[]>([]);
|
||||
const [surchargeTypes, setSurchargeTypes] = useState<any[]>([]);
|
||||
const [surcharges, setSurcharges] = useState<any[]>([]);
|
||||
const [weightLimitRules, setWeightLimitRules] = useState<any[]>([]);
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error') => setToast({ message, type });
|
||||
|
||||
useEffect(() => { loadAllData(); }, []);
|
||||
|
||||
const loadAllData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [cargo, container, priority, service, surchargeType, surcharge, weight] = await Promise.all([
|
||||
apiService.getCargoTypes().catch(() => []),
|
||||
apiService.getContainerTypes().catch(() => []),
|
||||
apiService.getPriorityRules().catch(() => []),
|
||||
apiService.getServiceTypes().catch(() => []),
|
||||
apiService.getSurchargeTypes().catch(() => []),
|
||||
apiService.getSurcharges().catch(() => []),
|
||||
apiService.getWeightLimitRules().catch(() => []),
|
||||
]);
|
||||
setCargoTypes(cargo);
|
||||
setContainerTypes(container);
|
||||
setPriorityRules(priority);
|
||||
setServiceTypes(service);
|
||||
setSurchargeTypes(surchargeType);
|
||||
setSurcharges(surcharge);
|
||||
setWeightLimitRules(weight);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = (entity: string) => {
|
||||
setCurrentEntity(entity);
|
||||
setEditingItem(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (entity: string, item: any) => {
|
||||
setCurrentEntity(entity);
|
||||
setEditingItem(item);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (formData: any) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let result: any;
|
||||
|
||||
switch(currentEntity) {
|
||||
case 'cargo-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateCargoType(editingItem.id, formData);
|
||||
setCargoTypes(cargoTypes.map(c => c.id === editingItem.id ? result : c));
|
||||
} else {
|
||||
result = await createCargoType(formData);
|
||||
setCargoTypes([...cargoTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'container-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateContainerType(editingItem.id, formData);
|
||||
setContainerTypes(containerTypes.map(c => c.id === editingItem.id ? result : c));
|
||||
} else {
|
||||
result = await apiService.createContainerType(formData);
|
||||
setContainerTypes([...containerTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'priority-rules':
|
||||
if (editingItem) {
|
||||
result = await apiService.updatePriorityRule(editingItem.id, formData);
|
||||
setPriorityRules(priorityRules.map(p => p.id === editingItem.id ? result : p));
|
||||
} else {
|
||||
result = await apiService.createPriorityRule(formData);
|
||||
setPriorityRules([...priorityRules, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'service-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateServiceType(editingItem.id, formData);
|
||||
setServiceTypes(serviceTypes.map(s => s.id === editingItem.id ? result : s));
|
||||
} else {
|
||||
result = await apiService.createServiceType(formData);
|
||||
setServiceTypes([...serviceTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'surcharge-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateSurchargeType(editingItem.id, formData);
|
||||
setSurchargeTypes(surchargeTypes.map(s => s.id === editingItem.id ? result : s));
|
||||
} else {
|
||||
result = await apiService.createSurchargeType(formData);
|
||||
setSurchargeTypes([...surchargeTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'surcharges':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateSurcharge(editingItem.id, formData);
|
||||
setSurcharges(surcharges.map(s => s.id === editingItem.id ? result : s));
|
||||
} else {
|
||||
result = await apiService.createSurcharge(formData);
|
||||
setSurcharges([...surcharges, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'weight-limit-rules':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateWeightLimitRule(editingItem.id, formData);
|
||||
setWeightLimitRules(weightLimitRules.map(w => w.id === editingItem.id ? result : w));
|
||||
} else {
|
||||
result = await apiService.createWeightLimitRule(formData);
|
||||
setWeightLimitRules([...weightLimitRules, result]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
showToast(`${currentEntity} ${editingItem ? 'updated' : 'created'} successfully!`, 'success');
|
||||
setModalOpen(false);
|
||||
setEditingItem(null);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Submit error:', error);
|
||||
showToast(error.message || `Failed to ${editingItem ? 'update' : 'create'}`, 'error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entity: string, item: any) => {
|
||||
if (!confirm(`Delete this ${entity}?`)) return;
|
||||
try {
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
await apiService.deleteCargoType(item.id);
|
||||
setCargoTypes(cargoTypes.filter(c => c.id !== item.id));
|
||||
break;
|
||||
case 'container-types':
|
||||
await apiService.deleteContainerType(item.id);
|
||||
setContainerTypes(containerTypes.filter(c => c.id !== item.id));
|
||||
break;
|
||||
case 'priority-rules':
|
||||
await apiService.deletePriorityRule(item.id);
|
||||
setPriorityRules(priorityRules.filter(p => p.id !== item.id));
|
||||
break;
|
||||
case 'service-types':
|
||||
await apiService.deleteServiceType(item.id);
|
||||
setServiceTypes(serviceTypes.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'surcharge-types':
|
||||
await apiService.deleteSurchargeType(item.id);
|
||||
setSurchargeTypes(surchargeTypes.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'surcharges':
|
||||
await apiService.deleteSurcharge(item.id);
|
||||
setSurcharges(surcharges.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'weight-limit-rules':
|
||||
await apiService.deleteWeightLimitRule(item.id);
|
||||
setWeightLimitRules(weightLimitRules.filter(w => w.id !== item.id));
|
||||
break;
|
||||
}
|
||||
showToast(`${entity} deleted successfully!`, 'success');
|
||||
} catch (error: any) {
|
||||
showToast(error.message || `Failed to delete`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getColumns = (entity: string) => {
|
||||
const baseStatus = { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' };
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
return [{ key: 'code', label: 'Code' }, { key: 'cargoTypeName', label: 'Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
||||
case 'container-types':
|
||||
return [{ key: 'sizeCode', label: 'Size Code' }, { key: 'description', label: 'Description' }, { key: 'containersPerWagon', label: 'Containers/Wagon' }, baseStatus];
|
||||
case 'priority-rules':
|
||||
return [{ key: 'priorityType', label: 'Priority Type' }, { key: 'ruleName', label: 'Rule Name' }, { key: 'bonusPoints', label: 'Bonus Points' }, baseStatus];
|
||||
case 'service-types':
|
||||
return [{ key: 'code', label: 'Code' }, { key: 'serviceName', label: 'Service Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
||||
case 'surcharge-types':
|
||||
return [{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, baseStatus];
|
||||
case 'surcharges':
|
||||
return [{ key: 'feeName', label: 'Fee Name' }, { key: 'calculationMethod', label: 'Method' }, { key: 'rate', label: 'Rate', render: (val: number, item: any) => `${val} ${item.currency}` }, baseStatus];
|
||||
case 'weight-limit-rules':
|
||||
return [{ key: 'tradeDirection', label: 'Direction' }, { key: 'maxWeightTons', label: 'Max Weight', render: (val: number) => `${val} tons` }, { key: 'exceededAction', label: 'Action' }, baseStatus];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getEntityData = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types': return cargoTypes;
|
||||
case 'container-types': return containerTypes;
|
||||
case 'priority-rules': return priorityRules;
|
||||
case 'service-types': return serviceTypes;
|
||||
case 'surcharge-types': return surchargeTypes;
|
||||
case 'surcharges': return surcharges;
|
||||
case 'weight-limit-rules': return weightLimitRules;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'cargo-types', label: 'Cargo Types', Form: CargoTypeForm },
|
||||
{ id: 'container-types', label: 'Container Types', Form: ContainerTypeForm },
|
||||
{ id: 'priority-rules', label: 'Priority Rules', Form: PriorityRuleForm },
|
||||
{ id: 'service-types', label: 'Service Types', Form: ServiceTypeForm },
|
||||
{ id: 'surcharge-types', label: 'Surcharge Types', Form: SurchargeTypeForm },
|
||||
{ id: 'surcharges', label: 'Surcharges', Form: SurchargeForm },
|
||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules', Form: WeightLimitRuleForm },
|
||||
];
|
||||
|
||||
const currentTab = tabs.find(t => t.id === currentEntity);
|
||||
const FormComponent = currentTab?.Form;
|
||||
|
||||
return (
|
||||
<div className="contract-type-page">
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Rule Engine - Master Data</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage cargo types, container types, priority rules, and more</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="flex space-x-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${activeTab === tab.id ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
||||
<EntityTable
|
||||
title={tab.label}
|
||||
data={getEntityData(tab.id)}
|
||||
columns={getColumns(tab.id)}
|
||||
onAdd={() => handleAdd(tab.id)}
|
||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => { setModalOpen(false); setEditingItem(null); }}
|
||||
title={editingItem ? `Edit ${currentEntity?.replace('-', ' ')}` : `Add ${currentEntity?.replace('-', ' ')}`}
|
||||
>
|
||||
{FormComponent && (
|
||||
<FormComponent
|
||||
initialData={editingItem}
|
||||
onSubmit={handleSubmitForm}
|
||||
onCancel={() => { setModalOpen(false); setEditingItem(null); }}
|
||||
isSubmitting={isSubmitting}
|
||||
surchargeTypes={surchargeTypes}
|
||||
containerTypes={containerTypes}
|
||||
surcharges={surcharges}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractTypePage;
|
||||
@@ -1,221 +0,0 @@
|
||||
import { JSXElementConstructor, MouseEventHandler, ReactElement, ReactNode, SetStateAction, useState } from 'react';
|
||||
|
||||
export const ContractTypePage = () => {
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
contractType: true,
|
||||
serviceType: false,
|
||||
cargoType: false
|
||||
});
|
||||
|
||||
const [contractTypes, setContractTypes] = useState([
|
||||
{ id: 1, name: 'Shipper', description: 'Company that sends the freight' },
|
||||
{ id: 2, name: 'Consignee', description: 'Company that receives the freight' },
|
||||
{ id: 3, name: 'Third Party', description: 'Company that is neither the shipper nor the consignee but is involved in the freight process' }
|
||||
]);
|
||||
|
||||
const [serviceTypes, setServiceTypes] = useState([
|
||||
{ id: 1, name: 'Standard', description: 'Regular shipping service' },
|
||||
{ id: 2, name: 'Express', description: 'Fast delivery service' },
|
||||
{ id: 3, name: 'Economy', description: 'Cost-effective shipping option' }
|
||||
]);
|
||||
|
||||
const [cargoTypes, setCargoTypes] = useState([
|
||||
{ id: 1, name: 'General Cargo', description: 'Standard packaged goods' },
|
||||
{ id: 2, name: 'Temperature Controlled', description: 'Goods requiring specific temperature' },
|
||||
{ id: 3, name: 'Hazardous Materials', description: 'Dangerous goods requiring special handling' }
|
||||
]);
|
||||
|
||||
const [newContractType, setNewContractType] = useState({ name: '', description: '' });
|
||||
const [newServiceType, setNewServiceType] = useState({ name: '', description: '' });
|
||||
const [newCargoType, setNewCargoType] = useState({ name: '', description: '' });
|
||||
const [showAddForms, setShowAddForms] = useState({
|
||||
contractType: false,
|
||||
serviceType: false,
|
||||
|
||||
cargoType: false
|
||||
});
|
||||
|
||||
type SectionKey = 'contractType' | 'serviceType' | 'cargoType';
|
||||
|
||||
const toggleSection = (section: SectionKey) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAddForm = (section: SectionKey) => {
|
||||
setShowAddForms(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddContractType = () => {
|
||||
if (newContractType.name && newContractType.description) {
|
||||
setContractTypes([
|
||||
...contractTypes,
|
||||
{ id: Date.now(), ...newContractType }
|
||||
]);
|
||||
setNewContractType({ name: '', description: '' });
|
||||
toggleAddForm('contractType');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddServiceType = () => {
|
||||
if (newServiceType.name && newServiceType.description) {
|
||||
setServiceTypes([
|
||||
...serviceTypes,
|
||||
{ id: Date.now(), ...newServiceType }
|
||||
]);
|
||||
setNewServiceType({ name: '', description: '' });
|
||||
toggleAddForm('serviceType');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCargoType = () => {
|
||||
if (newCargoType.name && newCargoType.description) {
|
||||
setCargoTypes([
|
||||
...cargoTypes,
|
||||
{ id: Date.now(), ...newCargoType }
|
||||
]);
|
||||
setNewCargoType({ name: '', description: '' });
|
||||
toggleAddForm('cargoType');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (type: string, id: number) => {
|
||||
if (type === 'contract') {
|
||||
setContractTypes(contractTypes.filter(item => item.id !== id));
|
||||
} else if (type === 'service') {
|
||||
setServiceTypes(serviceTypes.filter(item => item.id !== id));
|
||||
} else if (type === 'cargo') {
|
||||
setCargoTypes(cargoTypes.filter(item => item.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (type: any, id: any) => {
|
||||
// Implement edit functionality as needed
|
||||
alert(`Edit ${type} type with id: ${id}`);
|
||||
};
|
||||
|
||||
const renderTable = (title: string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined, types: any[], onAdd: { (): void; (): void; (): void; }, newItem: { name: any; description: any; }, setNewItem: { (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (arg0: any): void; }, showAddForm: boolean, typeKey: string, addHandler: MouseEventHandler<HTMLButtonElement> | undefined) => (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
marginBottom: '10px'
|
||||
}}
|
||||
onClick={() => toggleSection(typeKey)}
|
||||
>
|
||||
<span style={{ marginRight: '10px', fontSize: '20px', color: '#138a49' }}>
|
||||
{expandedSections[typeKey] ? '▼' : '▶'}
|
||||
</span>
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
</div>
|
||||
|
||||
{expandedSections[typeKey] && (
|
||||
<div style={{ marginLeft: '20px' }}>
|
||||
<button onClick={() => toggleAddForm(typeKey)}>
|
||||
Add {title.replace(' Types', ' type')}
|
||||
</button>
|
||||
|
||||
{showAddForm && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
marginBottom: '10px',
|
||||
padding: '10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<h4>Add New {title.replace(' Types', '')}</h4>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={newItem.name}
|
||||
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
|
||||
style={{ marginRight: '10px', padding: '5px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={newItem.description}
|
||||
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
|
||||
style={{ marginRight: '10px', padding: '5px' }}
|
||||
/>
|
||||
<button onClick={addHandler}>Save</button>
|
||||
<button onClick={() => toggleAddForm(typeKey)} style={{ marginLeft: '5px' }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '10px' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f2f2f2' }}>
|
||||
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>ID</th>
|
||||
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Name</th>
|
||||
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Description</th>
|
||||
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{types.map((type) => (
|
||||
<tr key={type.id}>
|
||||
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.id}</td>
|
||||
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.name}</td>
|
||||
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.description}</td>
|
||||
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
|
||||
<button onClick={() => handleEdit(typeKey, type.id)} style={{ marginRight: '5px' }}>Edit</button>
|
||||
<button onClick={() => handleDelete(typeKey === 'contractType' ? 'contract' : typeKey === 'serviceType' ? 'service' : 'cargo', type.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderTable(
|
||||
'Contract Types',
|
||||
contractTypes,
|
||||
handleAddContractType,
|
||||
newContractType,
|
||||
setNewContractType,
|
||||
showAddForms.contractType,
|
||||
'contractType',
|
||||
handleAddContractType
|
||||
)}
|
||||
|
||||
{renderTable(
|
||||
'Service Types',
|
||||
serviceTypes,
|
||||
handleAddServiceType,
|
||||
newServiceType,
|
||||
setNewServiceType,
|
||||
showAddForms.serviceType,
|
||||
'serviceType',
|
||||
handleAddServiceType
|
||||
)}
|
||||
|
||||
{renderTable(
|
||||
'Cargo Types',
|
||||
cargoTypes,
|
||||
handleAddCargoType,
|
||||
newCargoType,
|
||||
setNewCargoType,
|
||||
showAddForms.cargoType,
|
||||
'cargoType',
|
||||
handleAddCargoType
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,874 +0,0 @@
|
||||
// src/components/ruleEngine/ContractType.tsx
|
||||
import { createCargoType } from '@/services/rule.engine/cargoType';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ==================== API Service ====================
|
||||
const API_BASE_URL = 'http://localhost:3001/api';
|
||||
|
||||
const apiFetch = async (endpoint: string, options?: RequestInit): Promise<any> => {
|
||||
try {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText || response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const apiService = {
|
||||
getCargoTypes: (): Promise<any[]> => apiFetch('/cargo-types'),
|
||||
createCargoType: (data: any): Promise<any> => apiFetch('/cargo-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateCargoType: (id: string, data: any): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteCargoType: (id: string): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getContainerTypes: (): Promise<any[]> => apiFetch('/container-types'),
|
||||
createContainerType: (data: any): Promise<any> => apiFetch('/container-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateContainerType: (id: string, data: any): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteContainerType: (id: string): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getPriorityRules: (): Promise<any[]> => apiFetch('/priority-rules'),
|
||||
createPriorityRule: (data: any): Promise<any> => apiFetch('/priority-rules', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updatePriorityRule: (id: string, data: any): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deletePriorityRule: (id: string): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getServiceTypes: (): Promise<any[]> => apiFetch('/service-types'),
|
||||
createServiceType: (data: any): Promise<any> => apiFetch('/service-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateServiceType: (id: string, data: any): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteServiceType: (id: string): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getSurchargeTypes: (): Promise<any[]> => apiFetch('/surcharge-types'),
|
||||
createSurchargeType: (data: any): Promise<any> => apiFetch('/surcharge-types', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateSurchargeType: (id: string, data: any): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteSurchargeType: (id: string): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getSurcharges: (): Promise<any[]> => apiFetch('/surcharges'),
|
||||
createSurcharge: (data: any): Promise<any> => apiFetch('/surcharges', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateSurcharge: (id: string, data: any): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteSurcharge: (id: string): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getWeightLimitRules: (): Promise<any[]> => apiFetch('/weight-limit-rules'),
|
||||
createWeightLimitRule: (data: any): Promise<any> => apiFetch('/weight-limit-rules', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateWeightLimitRule: (id: string, data: any): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteWeightLimitRule: (id: string): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ==================== Toast Component ====================
|
||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Modal Component ====================
|
||||
const Modal = ({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center px-6 py-4 border-b border-gray-200 sticky top-0 bg-white">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Form Components ====================
|
||||
|
||||
// 1. Cargo Type Form
|
||||
const CargoTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
code: initialData?.code || '',
|
||||
cargoTypeName: initialData?.cargoTypeName || '',
|
||||
parentGroupId: initialData?.parentGroupId || '',
|
||||
showFreeTextBox: initialData?.showFreeTextBox || false,
|
||||
requiresDirectorApproval: initialData?.requiresDirectorApproval || false,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
displayOrder: initialData?.displayOrder || 1,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const submitData = {
|
||||
code: formData.code.toUpperCase(),
|
||||
cargoTypeName: formData.cargoTypeName,
|
||||
parentGroupId: formData.parentGroupId || undefined,
|
||||
showFreeTextBox: formData.showFreeTextBox,
|
||||
requiresDirectorApproval: formData.requiresDirectorApproval,
|
||||
isActive: formData.isActive,
|
||||
displayOrder: Number(formData.displayOrder),
|
||||
};
|
||||
onSubmit(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.cargoTypeName} onChange={(e) => setFormData({...formData, cargoTypeName: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Parent Group ID</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.parentGroupId} onChange={(e) => setFormData({...formData, parentGroupId: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.showFreeTextBox} onChange={(e) => setFormData({...formData, showFreeTextBox: e.target.checked})} /> Show Free Text Box</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.requiresDirectorApproval} onChange={(e) => setFormData({...formData, requiresDirectorApproval: e.target.checked})} /> Requires Director Approval</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 2. Container Type Form
|
||||
const ContainerTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
sizeCode: initialData?.sizeCode || '',
|
||||
description: initialData?.description || '',
|
||||
containersPerWagon: initialData?.containersPerWagon || 1,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Size Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.sizeCode} onChange={(e) => setFormData({...formData, sizeCode: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Containers Per Wagon *</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containersPerWagon} onChange={(e) => setFormData({...formData, containersPerWagon: parseInt(e.target.value)})} required />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 3. Priority Rule Form
|
||||
const PriorityRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
priorityType: initialData?.priorityType || 'MEDIUM',
|
||||
ruleName: initialData?.ruleName || '',
|
||||
description: initialData?.description || '',
|
||||
activationCondition: initialData?.activationCondition || '',
|
||||
bonusPoints: initialData?.bonusPoints || 0,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Type *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityType} onChange={(e) => setFormData({...formData, priorityType: e.target.value})}>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="LOW">LOW</option>
|
||||
<option value="URGENT">URGENT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Bonus Points</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.bonusPoints} onChange={(e) => setFormData({...formData, bonusPoints: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rule Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.ruleName} onChange={(e) => setFormData({...formData, ruleName: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Activation Condition</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.activationCondition} onChange={(e) => setFormData({...formData, activationCondition: e.target.value})} placeholder="e.g., weight > 1000" />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 4. Service Type Form
|
||||
const ServiceTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
code: initialData?.code || '',
|
||||
serviceName: initialData?.serviceName || '',
|
||||
description: initialData?.description || '',
|
||||
canBeBookedAlone: initialData?.canBeBookedAlone !== undefined ? initialData.canBeBookedAlone : true,
|
||||
includesFirstMile: initialData?.includesFirstMile || false,
|
||||
includesLastMile: initialData?.includesLastMile || false,
|
||||
includesCustoms: initialData?.includesCustoms || false,
|
||||
priorityBonusPoints: initialData?.priorityBonusPoints || 0,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
displayOrder: initialData?.displayOrder || 1,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Service Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.serviceName} onChange={(e) => setFormData({...formData, serviceName: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Bonus Points</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityBonusPoints} onChange={(e) => setFormData({...formData, priorityBonusPoints: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.canBeBookedAlone} onChange={(e) => setFormData({...formData, canBeBookedAlone: e.target.checked})} /> Can Be Booked Alone</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesFirstMile} onChange={(e) => setFormData({...formData, includesFirstMile: e.target.checked})} /> Includes First Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesLastMile} onChange={(e) => setFormData({...formData, includesLastMile: e.target.checked})} /> Includes Last Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesCustoms} onChange={(e) => setFormData({...formData, includesCustoms: e.target.checked})} /> Includes Customs</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 5. Surcharge Type Form
|
||||
const SurchargeTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
code: initialData?.code || '',
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.name} onChange={(e) => setFormData({...formData, name: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 6. Surcharge Form
|
||||
const SurchargeForm = ({ initialData, onSubmit, onCancel, isSubmitting, surchargeTypes }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
surchargeTypeId: initialData?.surchargeTypeId || '',
|
||||
feeName: initialData?.feeName || '',
|
||||
triggerDescription: initialData?.triggerDescription || '',
|
||||
calculationMethod: initialData?.calculationMethod || 'FLAT',
|
||||
rate: initialData?.rate || 0,
|
||||
currency: initialData?.currency || 'USD',
|
||||
applyToRail: initialData?.applyToRail || false,
|
||||
applyToFirstMile: initialData?.applyToFirstMile || false,
|
||||
applyToLastMile: initialData?.applyToLastMile || false,
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge Type *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeTypeId} onChange={(e) => setFormData({...formData, surchargeTypeId: e.target.value})} required>
|
||||
<option value="">Select Surcharge Type</option>
|
||||
{surchargeTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.name}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Fee Name *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.feeName} onChange={(e) => setFormData({...formData, feeName: e.target.value})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Calculation Method *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.calculationMethod} onChange={(e) => setFormData({...formData, calculationMethod: e.target.value})}>
|
||||
<option value="PER_TON">Per Ton</option>
|
||||
<option value="FLAT">Flat</option>
|
||||
<option value="PERCENTAGE">Percentage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rate *</label>
|
||||
<input type="number" step="0.01" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.rate} onChange={(e) => setFormData({...formData, rate: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Currency *</label>
|
||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.currency} onChange={(e) => setFormData({...formData, currency: e.target.value.toUpperCase()})} maxLength={3} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trigger Description</label>
|
||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.triggerDescription} onChange={(e) => setFormData({...formData, triggerDescription: e.target.value})} />
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToRail} onChange={(e) => setFormData({...formData, applyToRail: e.target.checked})} /> Apply to Rail</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToFirstMile} onChange={(e) => setFormData({...formData, applyToFirstMile: e.target.checked})} /> Apply to First Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToLastMile} onChange={(e) => setFormData({...formData, applyToLastMile: e.target.checked})} /> Apply to Last Mile</label>
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// 7. Weight Limit Rule Form
|
||||
const WeightLimitRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting, containerTypes, surcharges }: any) => {
|
||||
const [formData, setFormData] = useState({
|
||||
containerTypeId: initialData?.containerTypeId || '',
|
||||
tradeDirection: initialData?.tradeDirection || 'IMPORT',
|
||||
maxWeightTons: initialData?.maxWeightTons || 20,
|
||||
warningThresholdTons: initialData?.warningThresholdTons || 18,
|
||||
exceededAction: initialData?.exceededAction || 'WARNING_ONLY',
|
||||
surchargeId: initialData?.surchargeId || '',
|
||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Container Type *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containerTypeId} onChange={(e) => setFormData({...formData, containerTypeId: e.target.value})} required>
|
||||
<option value="">Select Container Type</option>
|
||||
{containerTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.sizeCode}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trade Direction *</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.tradeDirection} onChange={(e) => setFormData({...formData, tradeDirection: e.target.value})}>
|
||||
<option value="IMPORT">Import</option>
|
||||
<option value="EXPORT">Export</option>
|
||||
<option value="DOMESTIC">Domestic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Exceeded Action</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.exceededAction} onChange={(e) => setFormData({...formData, exceededAction: e.target.value})}>
|
||||
<option value="WARNING_ONLY">Warning Only</option>
|
||||
<option value="BLOCK">Block</option>
|
||||
<option value="SURCHARGE">Surcharge</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Weight (Tons) *</label>
|
||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.maxWeightTons} onChange={(e) => setFormData({...formData, maxWeightTons: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Warning Threshold (Tons) *</label>
|
||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.warningThresholdTons} onChange={(e) => setFormData({...formData, warningThresholdTons: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
</div>
|
||||
{formData.exceededAction === 'SURCHARGE' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeId} onChange={(e) => setFormData({...formData, surchargeId: e.target.value})}>
|
||||
<option value="">Select Surcharge</option>
|
||||
{surcharges?.map((surcharge: any) => (<option key={surcharge.id} value={surcharge.id}>{surcharge.feeName}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Entity Table Component ====================
|
||||
const EntityTable = ({ title, data, columns, onAdd, onEdit, onDelete, isLoading }: any) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredData = Array.isArray(data) ? data.filter((item: any) =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
) : [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-green-600">▼</span>
|
||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-green-500 border-t-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100" onClick={() => setExpanded(!expanded)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">{filteredData.length} items</span>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
||||
<button className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700" onClick={onAdd}>+ Add {title.slice(0, -1)}</button>
|
||||
<div className="relative">
|
||||
<input type="text" placeholder="Search..." className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0118 0z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((col: any) => (<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>))}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredData.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
{columns.map((col: any) => (<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{col.render ? col.render(item[col.key], item) : item[col.key]}</td>))}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs mr-2 hover:bg-yellow-600" onClick={() => onEdit(item)}>Edit</button>
|
||||
<button className="bg-red-600 text-white px-3 py-1 rounded text-xs hover:bg-red-700" onClick={() => onDelete(item)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredData.length === 0 && (<div className="text-center py-12 text-gray-500">No data found. Click "Add" to create one.</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Main Component ====================
|
||||
const ContractTypePage = () => {
|
||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [currentEntity, setCurrentEntity] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [cargoTypes, setCargoTypes] = useState<any[]>([]);
|
||||
const [containerTypes, setContainerTypes] = useState<any[]>([]);
|
||||
const [priorityRules, setPriorityRules] = useState<any[]>([]);
|
||||
const [serviceTypes, setServiceTypes] = useState<any[]>([]);
|
||||
const [surchargeTypes, setSurchargeTypes] = useState<any[]>([]);
|
||||
const [surcharges, setSurcharges] = useState<any[]>([]);
|
||||
const [weightLimitRules, setWeightLimitRules] = useState<any[]>([]);
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error') => setToast({ message, type });
|
||||
|
||||
useEffect(() => { loadAllData(); }, []);
|
||||
|
||||
const loadAllData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [cargo, container, priority, service, surchargeType, surcharge, weight] = await Promise.all([
|
||||
apiService.getCargoTypes().catch(() => []),
|
||||
apiService.getContainerTypes().catch(() => []),
|
||||
apiService.getPriorityRules().catch(() => []),
|
||||
apiService.getServiceTypes().catch(() => []),
|
||||
apiService.getSurchargeTypes().catch(() => []),
|
||||
apiService.getSurcharges().catch(() => []),
|
||||
apiService.getWeightLimitRules().catch(() => []),
|
||||
]);
|
||||
setCargoTypes(cargo);
|
||||
setContainerTypes(container);
|
||||
setPriorityRules(priority);
|
||||
setServiceTypes(service);
|
||||
setSurchargeTypes(surchargeType);
|
||||
setSurcharges(surcharge);
|
||||
setWeightLimitRules(weight);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = (entity: string) => {
|
||||
setCurrentEntity(entity);
|
||||
setEditingItem(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (entity: string, item: any) => {
|
||||
setCurrentEntity(entity);
|
||||
setEditingItem(item);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (formData: any) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let result: any;
|
||||
|
||||
switch(currentEntity) {
|
||||
case 'cargo-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateCargoType(editingItem.id, formData);
|
||||
setCargoTypes(cargoTypes.map(c => c.id === editingItem.id ? result : c));
|
||||
} else {
|
||||
result = await createCargoType(formData);
|
||||
setCargoTypes([...cargoTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'container-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateContainerType(editingItem.id, formData);
|
||||
setContainerTypes(containerTypes.map(c => c.id === editingItem.id ? result : c));
|
||||
} else {
|
||||
result = await apiService.createContainerType(formData);
|
||||
setContainerTypes([...containerTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'priority-rules':
|
||||
if (editingItem) {
|
||||
result = await apiService.updatePriorityRule(editingItem.id, formData);
|
||||
setPriorityRules(priorityRules.map(p => p.id === editingItem.id ? result : p));
|
||||
} else {
|
||||
result = await apiService.createPriorityRule(formData);
|
||||
setPriorityRules([...priorityRules, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'service-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateServiceType(editingItem.id, formData);
|
||||
setServiceTypes(serviceTypes.map(s => s.id === editingItem.id ? result : s));
|
||||
} else {
|
||||
result = await apiService.createServiceType(formData);
|
||||
setServiceTypes([...serviceTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'surcharge-types':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateSurchargeType(editingItem.id, formData);
|
||||
setSurchargeTypes(surchargeTypes.map(s => s.id === editingItem.id ? result : s));
|
||||
} else {
|
||||
result = await apiService.createSurchargeType(formData);
|
||||
setSurchargeTypes([...surchargeTypes, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'surcharges':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateSurcharge(editingItem.id, formData);
|
||||
setSurcharges(surcharges.map(s => s.id === editingItem.id ? result : s));
|
||||
} else {
|
||||
result = await apiService.createSurcharge(formData);
|
||||
setSurcharges([...surcharges, result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'weight-limit-rules':
|
||||
if (editingItem) {
|
||||
result = await apiService.updateWeightLimitRule(editingItem.id, formData);
|
||||
setWeightLimitRules(weightLimitRules.map(w => w.id === editingItem.id ? result : w));
|
||||
} else {
|
||||
result = await apiService.createWeightLimitRule(formData);
|
||||
setWeightLimitRules([...weightLimitRules, result]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
showToast(`${currentEntity} ${editingItem ? 'updated' : 'created'} successfully!`, 'success');
|
||||
setModalOpen(false);
|
||||
setEditingItem(null);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Submit error:', error);
|
||||
showToast(error.message || `Failed to ${editingItem ? 'update' : 'create'}`, 'error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entity: string, item: any) => {
|
||||
if (!confirm(`Delete this ${entity}?`)) return;
|
||||
try {
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
await apiService.deleteCargoType(item.id);
|
||||
setCargoTypes(cargoTypes.filter(c => c.id !== item.id));
|
||||
break;
|
||||
case 'container-types':
|
||||
await apiService.deleteContainerType(item.id);
|
||||
setContainerTypes(containerTypes.filter(c => c.id !== item.id));
|
||||
break;
|
||||
case 'priority-rules':
|
||||
await apiService.deletePriorityRule(item.id);
|
||||
setPriorityRules(priorityRules.filter(p => p.id !== item.id));
|
||||
break;
|
||||
case 'service-types':
|
||||
await apiService.deleteServiceType(item.id);
|
||||
setServiceTypes(serviceTypes.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'surcharge-types':
|
||||
await apiService.deleteSurchargeType(item.id);
|
||||
setSurchargeTypes(surchargeTypes.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'surcharges':
|
||||
await apiService.deleteSurcharge(item.id);
|
||||
setSurcharges(surcharges.filter(s => s.id !== item.id));
|
||||
break;
|
||||
case 'weight-limit-rules':
|
||||
await apiService.deleteWeightLimitRule(item.id);
|
||||
setWeightLimitRules(weightLimitRules.filter(w => w.id !== item.id));
|
||||
break;
|
||||
}
|
||||
showToast(`${entity} deleted successfully!`, 'success');
|
||||
} catch (error: any) {
|
||||
showToast(error.message || `Failed to delete`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getColumns = (entity: string) => {
|
||||
const baseStatus = { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' };
|
||||
switch(entity) {
|
||||
case 'cargo-types':
|
||||
return [{ key: 'code', label: 'Code' }, { key: 'cargoTypeName', label: 'Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
||||
case 'container-types':
|
||||
return [{ key: 'sizeCode', label: 'Size Code' }, { key: 'description', label: 'Description' }, { key: 'containersPerWagon', label: 'Containers/Wagon' }, baseStatus];
|
||||
case 'priority-rules':
|
||||
return [{ key: 'priorityType', label: 'Priority Type' }, { key: 'ruleName', label: 'Rule Name' }, { key: 'bonusPoints', label: 'Bonus Points' }, baseStatus];
|
||||
case 'service-types':
|
||||
return [{ key: 'code', label: 'Code' }, { key: 'serviceName', label: 'Service Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
||||
case 'surcharge-types':
|
||||
return [{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, baseStatus];
|
||||
case 'surcharges':
|
||||
return [{ key: 'feeName', label: 'Fee Name' }, { key: 'calculationMethod', label: 'Method' }, { key: 'rate', label: 'Rate', render: (val: number, item: any) => `${val} ${item.currency}` }, baseStatus];
|
||||
case 'weight-limit-rules':
|
||||
return [{ key: 'tradeDirection', label: 'Direction' }, { key: 'maxWeightTons', label: 'Max Weight', render: (val: number) => `${val} tons` }, { key: 'exceededAction', label: 'Action' }, baseStatus];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getEntityData = (entity: string) => {
|
||||
switch(entity) {
|
||||
case 'cargo-types': return cargoTypes;
|
||||
case 'container-types': return containerTypes;
|
||||
case 'priority-rules': return priorityRules;
|
||||
case 'service-types': return serviceTypes;
|
||||
case 'surcharge-types': return surchargeTypes;
|
||||
case 'surcharges': return surcharges;
|
||||
case 'weight-limit-rules': return weightLimitRules;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'cargo-types', label: 'Cargo Types', Form: CargoTypeForm },
|
||||
{ id: 'container-types', label: 'Container Types', Form: ContainerTypeForm },
|
||||
{ id: 'priority-rules', label: 'Priority Rules', Form: PriorityRuleForm },
|
||||
{ id: 'service-types', label: 'Service Types', Form: ServiceTypeForm },
|
||||
{ id: 'surcharge-types', label: 'Surcharge Types', Form: SurchargeTypeForm },
|
||||
{ id: 'surcharges', label: 'Surcharges', Form: SurchargeForm },
|
||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules', Form: WeightLimitRuleForm },
|
||||
];
|
||||
|
||||
const currentTab = tabs.find(t => t.id === currentEntity);
|
||||
const FormComponent = currentTab?.Form;
|
||||
|
||||
return (
|
||||
<div className="contract-type-page">
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Rule Engine - Master Data</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage cargo types, container types, priority rules, and more</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="flex space-x-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${activeTab === tab.id ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
||||
<EntityTable
|
||||
title={tab.label}
|
||||
data={getEntityData(tab.id)}
|
||||
columns={getColumns(tab.id)}
|
||||
onAdd={() => handleAdd(tab.id)}
|
||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => { setModalOpen(false); setEditingItem(null); }}
|
||||
title={editingItem ? `Edit ${currentEntity?.replace('-', ' ')}` : `Add ${currentEntity?.replace('-', ' ')}`}
|
||||
>
|
||||
{FormComponent && (
|
||||
<FormComponent
|
||||
initialData={editingItem}
|
||||
onSubmit={handleSubmitForm}
|
||||
onCancel={() => { setModalOpen(false); setEditingItem(null); }}
|
||||
isSubmitting={isSubmitting}
|
||||
surchargeTypes={surchargeTypes}
|
||||
containerTypes={containerTypes}
|
||||
surcharges={surcharges}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractTypePage;
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import { DataTableFooter } from "@edr/ui-common";
|
||||
import type { Table } from "@edr/ui-common";
|
||||
|
||||
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
||||
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
||||
import { formatCell } from "./ruleEngineFormat";
|
||||
import { ruleEngineCard } from "./ruleEngineStyles";
|
||||
|
||||
export interface RuleEngineCardGridProps {
|
||||
config: RuleEngineResourceConfig;
|
||||
rows: RuleEngineRecord[];
|
||||
status: "loading" | "error" | "success";
|
||||
emptyMessage: string;
|
||||
itemLabel: string;
|
||||
table: Table<RuleEngineRecord>;
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
onEdit: (record: RuleEngineRecord) => void;
|
||||
onDelete: (record: RuleEngineRecord) => void;
|
||||
onViewChain?: () => void;
|
||||
onSubmitRate?: (id: string) => void;
|
||||
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||
}
|
||||
|
||||
const RuleEngineCardGrid = ({
|
||||
config,
|
||||
rows,
|
||||
status,
|
||||
emptyMessage,
|
||||
itemLabel,
|
||||
table,
|
||||
pagination,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewChain,
|
||||
onSubmitRate,
|
||||
onApproveRate,
|
||||
}: RuleEngineCardGridProps) => {
|
||||
const presentation = resolveCardPresentation(config);
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<p className="text-sm font-medium text-foreground">Failed to load data</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Please refresh the page or try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className={ruleEngineCard.skeleton}>
|
||||
<div className="flex gap-3">
|
||||
<div className="h-10 w-10 rounded-md bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-2/3 rounded-sm bg-muted" />
|
||||
<div className="h-3 w-1/3 rounded-sm bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="h-3 w-full rounded-sm bg-muted" />
|
||||
<div className="h-3 w-4/5 rounded-sm bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "success" && rows.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<p className="text-sm font-medium text-foreground">{emptyMessage}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Try adjusting your search or add a new record.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
|
||||
{rows.map((record) => {
|
||||
const title = String(record[presentation.titleKey] ?? "Untitled");
|
||||
const subtitle = presentation.subtitleKey
|
||||
? String(record[presentation.subtitleKey] ?? "")
|
||||
: "";
|
||||
const code = presentation.codeKey
|
||||
? String(record[presentation.codeKey] ?? "")
|
||||
: "";
|
||||
const statusValue = presentation.statusKey
|
||||
? record[presentation.statusKey]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<article key={record.id} className={ruleEngineCard.article}>
|
||||
<div className={ruleEngineCard.header}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={ruleEngineCard.avatar} aria-hidden>
|
||||
{cardInitials(title)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className={ruleEngineCard.title}>{title}</h3>
|
||||
{presentation.statusKey
|
||||
? formatCell(statusValue, "activeBadge")
|
||||
: null}
|
||||
</div>
|
||||
{(code || subtitle) && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
{code ? formatCell(code, "code") : null}
|
||||
{subtitle ? (
|
||||
<span className={ruleEngineCard.meta}>
|
||||
{presentation.subtitleKey === "stepOrder"
|
||||
? `Step ${subtitle}`
|
||||
: subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{presentation.detailColumns.length > 0 ? (
|
||||
<dl className="grid flex-1 gap-x-4 gap-y-3 px-4 py-3.5 sm:grid-cols-2">
|
||||
{presentation.detailColumns.map((col) => (
|
||||
<div key={col.id} className="min-w-0">
|
||||
<dt className={ruleEngineCard.detailLabel}>{col.header}</dt>
|
||||
<dd className={ruleEngineCard.detailValue}>
|
||||
{formatCell(record[col.accessorKey], col.format)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<div className="flex-1 px-4 py-2" />
|
||||
)}
|
||||
|
||||
<div className={ruleEngineCard.footer}>
|
||||
<RuleEngineRecordActions
|
||||
record={record}
|
||||
config={config}
|
||||
layout="compact"
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewChain={onViewChain}
|
||||
onSubmitRate={onSubmitRate}
|
||||
onApproveRate={onApproveRate}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border bg-card">
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={pagination}
|
||||
options={{
|
||||
labels: {
|
||||
showing: "Showing",
|
||||
ofLabel: "of",
|
||||
items: itemLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleEngineCardGrid;
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldLabel,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Separator,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import {
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
type FormFieldDef,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
import { ruleEngineField, ruleEngineSurface } from "./ruleEngineStyles";
|
||||
|
||||
export interface RuleEngineFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
fields: FormFieldDef[];
|
||||
initialRecord?: RuleEngineRecord | null;
|
||||
isSubmitting: boolean;
|
||||
selectOptionsLoading?: boolean;
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = (
|
||||
fields: FormFieldDef[],
|
||||
record?: RuleEngineRecord | null,
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const raw = record?.[field.name];
|
||||
if (raw !== undefined && raw !== null) {
|
||||
if (field.type === "date" && typeof raw === "string") {
|
||||
values[field.name] = raw.slice(0, 10);
|
||||
} else {
|
||||
values[field.name] = raw;
|
||||
}
|
||||
} else if (field.type === "boolean") {
|
||||
values[field.name] = false;
|
||||
} else if (field.type === "number") {
|
||||
values[field.name] = "";
|
||||
} else {
|
||||
values[field.name] = "";
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const resolveSelectValue = (
|
||||
field: FormFieldDef,
|
||||
values: Record<string, unknown>,
|
||||
): string | undefined => {
|
||||
const raw = values[field.name];
|
||||
const isEmpty = raw === "" || raw === null || raw === undefined;
|
||||
|
||||
if (field.optional && isEmpty) {
|
||||
return RULE_ENGINE_SELECT_NONE;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return String(raw);
|
||||
};
|
||||
|
||||
const RuleEngineFormDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
fields,
|
||||
initialRecord,
|
||||
isSubmitting,
|
||||
selectOptionsLoading = false,
|
||||
onSubmit,
|
||||
}: RuleEngineFormDialogProps) => {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||
buildInitialValues(fields, initialRecord),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, initialRecord));
|
||||
}
|
||||
}, [open, fields, initialRecord]);
|
||||
|
||||
const setField = (name: string, value: unknown) => {
|
||||
setValues((current) => ({ ...current, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const raw = values[field.name];
|
||||
if (field.type === "number") {
|
||||
if (raw === "" || raw === undefined) continue;
|
||||
payload[field.name] = Number(raw);
|
||||
} else if (field.type === "boolean") {
|
||||
payload[field.name] = Boolean(raw);
|
||||
} else if (
|
||||
field.type === "select" &&
|
||||
(raw === "" || raw === RULE_ENGINE_SELECT_NONE)
|
||||
) {
|
||||
if (!field.required) continue;
|
||||
} else if (raw === "" || raw === undefined) {
|
||||
if (!field.required) continue;
|
||||
payload[field.name] = raw;
|
||||
} else {
|
||||
payload[field.name] = raw;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) {
|
||||
payload.code = String(payload.code).toUpperCase();
|
||||
}
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={ruleEngineSurface.dialog}>
|
||||
<DialogHeader className="space-y-1">
|
||||
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-1">
|
||||
<div className="max-h-[min(60vh,28rem)] space-y-4 overflow-y-auto pr-1">
|
||||
{fields.map((field) => (
|
||||
<Field key={field.name} orientation="vertical" className="gap-1.5">
|
||||
{field.type === "boolean" ? (
|
||||
<div className={ruleEngineField.switchRow}>
|
||||
<div className="min-w-0">
|
||||
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
|
||||
{field.label}
|
||||
</FieldLabel>
|
||||
<p className={ruleEngineField.switchHint}>
|
||||
{Boolean(values[field.name]) ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={field.name}
|
||||
checked={Boolean(values[field.name])}
|
||||
onCheckedChange={(checked) => setField(field.name, checked)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
|
||||
{field.label}
|
||||
{field.required ? (
|
||||
<span className={ruleEngineField.requiredMark}> *</span>
|
||||
) : null}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
{field.type === "select" ? (
|
||||
<Select
|
||||
value={resolveSelectValue(field, values)}
|
||||
onValueChange={(v) =>
|
||||
setField(
|
||||
field.name,
|
||||
v === RULE_ENGINE_SELECT_NONE ? "" : v,
|
||||
)
|
||||
}
|
||||
disabled={selectOptionsLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={field.name}
|
||||
className={ruleEngineField.selectTrigger}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
selectOptionsLoading
|
||||
? "Loading options..."
|
||||
: (field.placeholder ?? "Select an option")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className={ruleEngineField.selectContent}>
|
||||
{(field.options ?? [])
|
||||
.filter((opt) => opt.value !== "")
|
||||
.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={ruleEngineField.textarea}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: "text"
|
||||
}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={ruleEngineField.input}
|
||||
required={field.required}
|
||||
/>
|
||||
)}
|
||||
</FieldContent>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-md"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="rounded-md" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleEngineFormDialog;
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
export interface RuleEngineRecordActionsProps {
|
||||
record: RuleEngineRecord;
|
||||
config: RuleEngineResourceConfig;
|
||||
onEdit: (record: RuleEngineRecord) => void;
|
||||
onDelete: (record: RuleEngineRecord) => void;
|
||||
onViewChain?: () => void;
|
||||
onSubmitRate?: (id: string) => void;
|
||||
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||
layout?: "row" | "compact";
|
||||
}
|
||||
|
||||
const RuleEngineRecordActions = ({
|
||||
record,
|
||||
config,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewChain,
|
||||
onSubmitRate,
|
||||
onApproveRate,
|
||||
layout = "row",
|
||||
}: RuleEngineRecordActionsProps) => {
|
||||
const status = String(record.status ?? "");
|
||||
const hasRateActions =
|
||||
config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL");
|
||||
|
||||
const iconBtnClass =
|
||||
layout === "compact"
|
||||
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={iconBtnClass}
|
||||
onClick={() => onEdit(record)}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{config.slug === "approval-rules" && onViewChain ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={iconBtnClass}
|
||||
onClick={onViewChain}
|
||||
aria-label="View approval chain"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{hasRateActions ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={iconBtnClass}
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{status === "DRAFT" && onSubmitRate ? (
|
||||
<DropdownMenuItem onSelect={() => onSubmitRate(record.id)}>
|
||||
<Send />
|
||||
Submit for approval
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{status === "PENDING_APPROVAL" && onApproveRate ? (
|
||||
<DropdownMenuItem onSelect={() => onApproveRate(record)}>
|
||||
<CheckCircle2 />
|
||||
Approve
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${iconBtnClass} hover:bg-red-50 hover:text-red-600`}
|
||||
onClick={() => onDelete(record)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleEngineRecordActions;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Filter, LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Input } from "@edr/ui-common";
|
||||
|
||||
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
||||
import { ruleEngineToolbar } from "./ruleEngineStyles";
|
||||
|
||||
export interface RuleEngineToolbarProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
onAdd: () => void;
|
||||
addLabel?: string;
|
||||
viewMode: RuleEngineViewMode;
|
||||
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
||||
}
|
||||
|
||||
const RuleEngineToolbar = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
onAdd,
|
||||
addLabel = "Add",
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: RuleEngineToolbarProps) => (
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative min-w-0 flex-1 lg:max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className={ruleEngineToolbar.search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<div
|
||||
className={ruleEngineToolbar.viewToggleGroup}
|
||||
role="group"
|
||||
aria-label="View mode"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewModeChange("table")}
|
||||
className={cn(
|
||||
ruleEngineToolbar.viewToggleBtn,
|
||||
viewMode === "table"
|
||||
? ruleEngineToolbar.viewToggleActive
|
||||
: ruleEngineToolbar.viewToggleIdle,
|
||||
)}
|
||||
aria-pressed={viewMode === "table"}
|
||||
>
|
||||
<Table2 className="h-4 w-4" />
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewModeChange("cards")}
|
||||
className={cn(
|
||||
ruleEngineToolbar.viewToggleBtn,
|
||||
viewMode === "cards"
|
||||
? ruleEngineToolbar.viewToggleActive
|
||||
: ruleEngineToolbar.viewToggleIdle,
|
||||
)}
|
||||
aria-pressed={viewMode === "cards"}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Cards
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(ruleEngineToolbar.actionBtn, "gap-2 px-3")}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
</Button>
|
||||
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RuleEngineToolbar;
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
ResourceColumn,
|
||||
RuleEngineResourceConfig,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
|
||||
const TITLE_KEY_PRIORITY = [
|
||||
"cargoTypeName",
|
||||
"serviceName",
|
||||
"label",
|
||||
"actionLabel",
|
||||
"rateType",
|
||||
"containerTypeId",
|
||||
"requiredRole",
|
||||
] as const;
|
||||
|
||||
const SUBTITLE_KEY_PRIORITY = [
|
||||
"code",
|
||||
"stepOrder",
|
||||
"currency",
|
||||
"tradeDirection",
|
||||
"requiredRole",
|
||||
] as const;
|
||||
|
||||
export interface RuleEngineCardPresentation {
|
||||
titleKey: string;
|
||||
subtitleKey?: string;
|
||||
codeKey?: string;
|
||||
statusKey?: string;
|
||||
detailColumns: ResourceColumn[];
|
||||
}
|
||||
|
||||
export const resolveCardPresentation = (
|
||||
config: RuleEngineResourceConfig,
|
||||
): RuleEngineCardPresentation => {
|
||||
const titleKey =
|
||||
config.cardTitleKey ??
|
||||
TITLE_KEY_PRIORITY.find((key) =>
|
||||
config.columns.some((col) => col.accessorKey === key),
|
||||
) ??
|
||||
config.columns.find((col) => col.format !== "code" && col.accessorKey !== "isActive")
|
||||
?.accessorKey ??
|
||||
"id";
|
||||
|
||||
const codeKey =
|
||||
config.cardCodeKey ??
|
||||
config.columns.find((col) => col.format === "code")?.accessorKey;
|
||||
|
||||
const statusKey = config.columns.find((col) => col.format === "activeBadge")?.accessorKey;
|
||||
|
||||
const subtitleKey =
|
||||
config.cardSubtitleKey ??
|
||||
SUBTITLE_KEY_PRIORITY.find(
|
||||
(key) =>
|
||||
key !== titleKey &&
|
||||
key !== codeKey &&
|
||||
config.columns.some((col) => col.accessorKey === key),
|
||||
);
|
||||
|
||||
const detailColumns = config.columns.filter((col) => {
|
||||
if (col.accessorKey === titleKey) return false;
|
||||
if (codeKey && col.accessorKey === codeKey) return false;
|
||||
if (subtitleKey && col.accessorKey === subtitleKey) return false;
|
||||
if (statusKey && col.accessorKey === statusKey) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return { titleKey, subtitleKey, codeKey, statusKey, detailColumns };
|
||||
};
|
||||
|
||||
export const cardInitials = (title: string): string => {
|
||||
const words = title.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return "?";
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return `${words[0][0] ?? ""}${words[1][0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||
import { Badge } from "@edr/ui-common";
|
||||
|
||||
const statusBadgeClass = (active: boolean) =>
|
||||
cn(
|
||||
"rounded-sm px-2 py-0.5 text-xs font-medium",
|
||||
active
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-border bg-muted text-muted-foreground",
|
||||
);
|
||||
|
||||
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
if (format === "boolean") {
|
||||
return value ? "Yes" : "No";
|
||||
}
|
||||
|
||||
if (format === "activeBadge") {
|
||||
const active = Boolean(value);
|
||||
return (
|
||||
<Badge variant="outline" className={statusBadgeClass(active)}>
|
||||
{active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "rateStatus") {
|
||||
const status = String(value);
|
||||
const tone =
|
||||
status === "LIVE"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: status === "DRAFT"
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-sky-200 bg-sky-50 text-sky-800";
|
||||
return (
|
||||
<Badge variant="outline" className={cn("rounded-sm font-medium", tone)}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "code") {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm border border-border bg-muted/80 font-mono text-[11px] font-medium text-foreground"
|
||||
>
|
||||
{String(value)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "date") {
|
||||
const d = new Date(String(value));
|
||||
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Material-inspired surfaces using shadcn tokens — modest radius, outlined fields. */
|
||||
|
||||
export const ruleEngineSurface = {
|
||||
pageCard:
|
||||
"overflow-hidden rounded-lg border border-border bg-card shadow-sm",
|
||||
pageCardToolbar: "border-b border-border bg-muted/30 px-4 py-3 sm:px-5 sm:py-3.5",
|
||||
dialog: "max-h-[90vh] overflow-y-auto rounded-lg border-border sm:max-w-lg",
|
||||
dialogSm: "rounded-lg border-border sm:max-w-md",
|
||||
} as const;
|
||||
|
||||
export const ruleEngineField = {
|
||||
label: "text-sm font-medium text-foreground",
|
||||
requiredMark: "text-destructive",
|
||||
input:
|
||||
"h-10 w-full rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
|
||||
textarea:
|
||||
"min-h-[96px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
|
||||
selectTrigger:
|
||||
"h-10 w-full rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 data-[placeholder]:text-muted-foreground",
|
||||
selectContent: "rounded-md border-border shadow-md",
|
||||
switchRow: "flex min-h-10 items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3",
|
||||
switchHint: "text-xs text-muted-foreground",
|
||||
} as const;
|
||||
|
||||
export const ruleEngineToolbar = {
|
||||
search:
|
||||
"h-10 rounded-md border border-input bg-background pl-10 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
|
||||
viewToggleGroup:
|
||||
"flex h-10 items-center rounded-md border border-border bg-muted/40 p-0.5",
|
||||
viewToggleBtn:
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-sm px-3 text-sm font-medium transition-colors",
|
||||
viewToggleActive: "bg-background text-foreground shadow-sm",
|
||||
viewToggleIdle: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
||||
actionBtn: "h-10 rounded-md shadow-xs",
|
||||
primaryBtn: "h-10 gap-2 rounded-md px-4 text-sm font-medium shadow-xs",
|
||||
} as const;
|
||||
|
||||
export const ruleEngineCard = {
|
||||
article:
|
||||
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
|
||||
header: "border-b border-border bg-muted/25 px-4 py-3.5",
|
||||
avatar:
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/12 text-sm font-semibold text-primary",
|
||||
title: "truncate text-[15px] font-semibold text-foreground",
|
||||
meta: "text-xs text-muted-foreground",
|
||||
detailLabel:
|
||||
"text-[11px] font-medium uppercase tracking-wide text-muted-foreground",
|
||||
detailValue: "mt-0.5 text-sm text-foreground",
|
||||
footer: "mt-auto border-t border-border bg-muted/20 px-3 py-2.5",
|
||||
skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-4",
|
||||
} as const;
|
||||
|
||||
export const ruleEngineTable = {
|
||||
headerCell:
|
||||
"h-11 bg-muted/50 text-xs font-semibold uppercase tracking-wide text-muted-foreground",
|
||||
bodyCell: "py-3.5 text-sm text-foreground",
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
|
||||
export type RuleEngineViewMode = "table" | "cards";
|
||||
|
||||
const STORAGE_PREFIX = "edr-freight-rule-engine-view:";
|
||||
|
||||
const readStored = (slug: RuleEngineResourceSlug): RuleEngineViewMode => {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${slug}`);
|
||||
return raw === "cards" ? "cards" : "table";
|
||||
} catch {
|
||||
return "table";
|
||||
}
|
||||
};
|
||||
|
||||
export const useRuleEngineViewMode = (slug: RuleEngineResourceSlug) => {
|
||||
const [viewMode, setViewModeState] = useState<RuleEngineViewMode>(() => readStored(slug));
|
||||
|
||||
useEffect(() => {
|
||||
setViewModeState(readStored(slug));
|
||||
}, [slug]);
|
||||
|
||||
const setViewMode = useCallback(
|
||||
(mode: RuleEngineViewMode) => {
|
||||
setViewModeState(mode);
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${slug}`, mode);
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
},
|
||||
[slug],
|
||||
);
|
||||
|
||||
return { viewMode, setViewMode };
|
||||
};
|
||||
Reference in New Issue
Block a user