rule engine UI, services and API integration

This commit is contained in:
hagiye
2026-06-01 16:01:51 +03:00
45 changed files with 6785 additions and 78 deletions

View File

@@ -3,7 +3,7 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
// import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@@ -39,7 +39,8 @@ export class CargoTypesController {
@Post()
@ApiOperation({ summary: 'Create a cargo type' })
create(@Body() dto: CreateCargoTypeDto) {
create(@Body() dto: any) {
return dto;
return this.service.create(dto);
}

View File

@@ -14,7 +14,7 @@
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query": "^5.100.11",
"@tria-plc/iamui-common": "1.1.1",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",

View File

@@ -1,8 +1,7 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import { LayoutDashboard, Network, Settings } from "lucide-react";
import { LayoutDashboard, Network, Paperclip, Settings } from "lucide-react";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
import OverviewPage from "./pages/dashboard/OverviewPage";
@@ -11,6 +10,8 @@ import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import LoadingScreen from "./components/LoadingScreen";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine";
@@ -26,6 +27,38 @@ const queryClient = new QueryClient({
},
});
// const sidebarItems: SidebarItem[] = [
// {
// label: "Overview",
// href: "/dashboard/overview",
// icon: <LayoutDashboard />,
// },
// {
// label: "User management",
// href: "/dashboard/user-management",
// icon: <Network />,
// children: [
// {
// label: "Employees",
// href: "/dashboard/user-management/employees",
// },
// {
// label: "Permissions",
// href: "/dashboard/user-management/permissions",
// },
// {
// label: "Roles",
// href: "/dashboard/user-management/roles",
// },
// ],
// },
// {
// label: "Rule Engine",
// href: "/dashboard/rule-engine",
// icon: <Settings />,
// },
// ];
const sidebarItems: SidebarItem[] = [
{
label: "Overview",
@@ -51,11 +84,21 @@ const sidebarItems: SidebarItem[] = [
},
],
},
{
label: "File Settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown Settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
{
label: "Rule Engine",
href: "/dashboard/rule-engine",
icon: <Settings />,
},
}
];
const hasPermission = (
@@ -70,62 +113,10 @@ const hasPermission = (
),
);
};
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const sidebarItems: SidebarItem[] = [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "Rule Engine",
href: "/dashboard/rule-engine",
icon: <Settings />,
},
...(hasPermission(user, "can:demo:user1")
? ([
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
] as SidebarItem[])
: []),
...(hasPermission(user, "can:demo:user2")
? ([
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
] as SidebarItem[])
: []),
];
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
@@ -164,22 +155,6 @@ const App = () => {
return (
<QueryClientProvider client={queryClient}>
{/* <Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="rule-engine" element={<RuleEnginePage />} />
<Route path="user-management/employees" element={<EmployeesPage />} />
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
======= */}
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
@@ -192,6 +167,8 @@ const App = () => {
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
</Route>

View File

@@ -0,0 +1,874 @@
// 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;

View File

@@ -1,4 +1,5 @@
// src/components/ruleEngine/ContractType.tsx
import { createCargoType } from '@/services/rule.engine/cargoType';
import { useState, useEffect } from 'react';
// ==================== API Service ====================
@@ -645,7 +646,7 @@ const ContractTypePage = () => {
result = await apiService.updateCargoType(editingItem.id, formData);
setCargoTypes(cargoTypes.map(c => c.id === editingItem.id ? result : c));
} else {
result = await apiService.createCargoType(formData);
result = await createCargoType(formData);
setCargoTypes([...cargoTypes, result]);
}
break;

View File

@@ -0,0 +1,56 @@
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { ChevronRight, Home } from "lucide-react";
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface BreadcrumbsProps {
items: BreadcrumbItem[];
}
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav
aria-label="Breadcrumb"
className="flex items-center text-sm text-slate-500"
>
<Link
to="/"
aria-label="Home"
className="flex items-center transition hover:text-[#10B981]"
>
{/* <Home className="h-4 w-4" /> */}
Dashboard
</Link>
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[#10B981]"
>
{item.label}
</Link>
) : (
<span
aria-current={isLast ? "page" : undefined}
className="font-medium text-slate-900"
>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,58 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,141 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-10 w-full min-w-0 rounded-md border border-slate-200 bg-white px-3 py-1 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"hover:border-slate-300",
"focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20",
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"field-sizing-content flex min-h-16 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
"hover:border-slate-300",
"focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20",
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,4 @@
export const FILE_SETTINGS = {
CUSTOMER_REGISTRATION: "customer_registration",
}

View File

@@ -0,0 +1,20 @@
export const QUERY_KEYS = {
USERS: "users",
ADD_USER: "add_user",
CUSTOMER: "Customers",
FILES: {
FILE_UPLOAD_SETTINGS: "file-upload-settings",
BY_CODE: "by-code"
},
DROPDOWN_SETTINGS: {
ROOT: "dropdown-settings",
LIST: "list",
BY_ID: "by-id",
BY_CODE: "by-code"
},
CUSTOMERS: {
ROOT: "customers",
LIST: "list",
BY_ID: "by-id"
}
}

View File

@@ -0,0 +1,124 @@
export const URL_CONSTANTS = {
AUTH: {
LOGIN: "/auth/login",
REGISTER: "/auth/register",
REFRESH_TOKEN: "/auth/refresh-token",
LOGOUT: "/auth/logout",
PROFILE: "/auth/profile",
},
USERS: {
SIGN_UP: "/api/auth/signup",
GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code",
BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`,
SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me"
},
ROLES: {
BASE: "/roles",
BY_ID: (id: string | number) => `/roles/${id}`,
},
PERMISSIONS: {
BASE: "/permissions",
BY_ID: (id: string | number) => `/permissions/${id}`,
},
PRODUCTS: {
BASE: "/products",
BY_ID: (id: string | number) => `/products/${id}`,
},
ORDERS: {
BASE: "/orders",
BY_ID: (id: string | number) => `/orders/${id}`,
},
FILES: {
BASE: "/files",
UPLOAD: "/files/upload",
FILE_UPLOAD_SETTINGS: "/files/upload",
FILE_UPLOAD_SETTINGS_BY_CODE: "/file-upload-settings/by-code",
DOWNLOAD: (id: string | number) => `/files/${id}/download`,
DELETE: (id: string | number) => `/files/${id}`,
BY_ID: (id: string | number) => `/files/${id}`,
},
SETTINGS: {
BASE: "/settings",
GENERAL: "/settings/general",
SECURITY: "/settings/security",
NOTIFICATIONS: "/settings/notifications",
},
DROPDOWN_SETTINGS: {
BASE: "/dropdown-settings",
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
BY_CODE: (code: string) =>
`/dropdown-settings/by-code/${encodeURIComponent(code)}`,
OPTIONS: (id: string) => `/dropdown-settings/${id}/options`,
OPTION_BY_ID: (optionId: string) =>
`/api/dropdown-settings/options/${optionId}`,
},
CUSTOMERS: {
BASE: "/customers",
BY_ID: (id: string | number) => `/customers/${id}`,
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
},
CUSTOMERS_API: {
BASE: "/api/customers",
BY_ID: (id: string) => `/api/customers/${id}`,
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
},
BOOKINGS: {
BASE: "/bookings",
BY_ID: (id: string | number) => `/bookings/${id}`,
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
},
RULE_ENGINE: {
// Cargo Types
CARGO_TYPES: "/cargo-types",
CARGO_TYPE_BY_ID: (id: string | number) => `/cargo-types/${id}`,
// Container Types
CONTAINER_TYPES: "/container-types",
CONTAINER_TYPE_BY_ID: (id: string | number) =>
`/container-types/${id}`,
// Priority Rules
PRIORITY_RULES: "/priority-rules",
PRIORITY_RULE_BY_ID: (id: string | number) =>
`/priority-rules/${id}`,
// Service Types
SERVICE_TYPES: "/service-types",
SERVICE_TYPE_BY_ID: (id: string | number) =>
`/service-types/${id}`,
// Surcharge Types
SURCHARGE_TYPES: "/surcharge-types",
SURCHARGE_TYPE_BY_ID: (id: string | number) =>
`/surcharge-types/${id}`,
// Surcharges
SURCHARGES: "/surcharges",
SURCHARGE_BY_ID: (id: string | number) =>
`/surcharges/${id}`,
// Weight Limit Rules
WEIGHT_LIMIT_RULES: "/weight-limit-rules",
WEIGHT_LIMIT_RULE_BY_ID: (id: string | number) =>
`/weight-limit-rules/${id}`,
},
};

View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { bookingsService } from "../services/bookings.service";
export const useBookings = () =>
useQuery({
queryKey: ["bookings"],
queryFn: bookingsService.list,
});
export const useBooking = (id: string) =>
useQuery({
queryKey: ["bookings", id],
queryFn: () => bookingsService.get(id),
enabled: Boolean(id),
});

View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { consignmentsService } from "../services/consignments.service";
export const useConsignments = () =>
useQuery({
queryKey: ["consignments"],
queryFn: consignmentsService.list,
});
export const useConsignment = (id: string) =>
useQuery({
queryKey: ["consignments", id],
queryFn: () => consignmentsService.get(id),
enabled: Boolean(id),
});

View File

@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { customersService } from "@/services/customers.service";
import type {
CreateCustomerDto,
UpdateCustomerDto,
} from "@/types/customers";
const KEY = ["customers"] as const;
export const useCustomers = () =>
useQuery({
queryKey: KEY,
queryFn: customersService.list,
});
export const useCustomer = (id: string | undefined) =>
useQuery({
queryKey: [...KEY, "id", id],
queryFn: () => customersService.getById(id!),
enabled: Boolean(id),
});
export const useCreateCustomer = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useUpdateCustomer = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
customersService.update(id, dto),
onSuccess: (_data, { id }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
},
});
};
export const useDeleteCustomer = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => customersService.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};

View File

@@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { trackingService } from "../services/tracking.service";
export const useTracking = (consignmentId: string) =>
useQuery({
queryKey: ["tracking", consignmentId],
queryFn: () => trackingService.forConsignment(consignmentId),
enabled: Boolean(consignmentId),
});

View File

@@ -0,0 +1,126 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { dropdownSettingsService } from "@/services/dropdownSettings.service";
import type {
CreateDropdownOptionDto,
CreateDropdownSettingDto,
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
const KEY = ["dropdown-settings"] as const;
/* ------------------------------ Queries ------------------------------ */
export const useDropdownSettings = () =>
useQuery({
queryKey: KEY,
queryFn: dropdownSettingsService.list,
});
export const useDropdownSetting = (id: string) =>
useQuery({
queryKey: [...KEY, "id", id],
queryFn: () => dropdownSettingsService.getById(id),
enabled: Boolean(id),
});
export const useDropdownSettingByCode = (code: string) =>
useQuery({
queryKey: [...KEY, "code", code],
queryFn: () => dropdownSettingsService.getByCode(code),
enabled: Boolean(code),
});
/* ----------------------------- Mutations ----------------------------- */
export const useCreateDropdownSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateDropdownSettingDto) =>
dropdownSettingsService.create(dto),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useUpdateDropdownSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
dto,
}: {
id: string;
dto: UpdateDropdownSettingDto;
}) => dropdownSettingsService.update(id, dto),
onSuccess: (_data, { id }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
},
});
};
export const useDeleteDropdownSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => dropdownSettingsService.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useReplaceDropdownOptions = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
settingId,
options,
}: {
settingId: string;
options: CreateDropdownOptionDto[];
}) => dropdownSettingsService.replaceOptions(settingId, options),
onSuccess: (_data, { settingId }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
},
});
};
export const useAddDropdownOption = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
settingId,
dto,
}: {
settingId: string;
dto: CreateDropdownOptionDto;
}) => dropdownSettingsService.addOption(settingId, dto),
onSuccess: (_data, { settingId }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
},
});
};
export const useUpdateDropdownOption = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
optionId,
dto,
}: {
optionId: string;
dto: UpdateDropdownOptionDto;
}) => dropdownSettingsService.updateOption(optionId, dto),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useRemoveDropdownOption = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (optionId: string) =>
dropdownSettingsService.removeOption(optionId),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};

View File

@@ -0,0 +1,126 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
import type {
CreateFileUploadFieldDto,
CreateFileUploadSettingDto,
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
const KEY = ["file-upload-settings"] as const;
/* ------------------------------ Queries ------------------------------ */
export const useFileUploadSettings = () =>
useQuery({
queryKey: KEY,
queryFn: fileUploadSettingsService.list,
});
export const useFileUploadSetting = (id: string) =>
useQuery({
queryKey: [...KEY, "id", id],
queryFn: () => fileUploadSettingsService.getById(id),
enabled: Boolean(id),
});
export const useFileUploadSettingByCode = (code: string) =>
useQuery({
queryKey: [...KEY, "code", code],
queryFn: () => fileUploadSettingsService.getByCode(code),
enabled: Boolean(code),
});
/* ----------------------------- Mutations ----------------------------- */
export const useCreateFileUploadSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateFileUploadSettingDto) =>
fileUploadSettingsService.create(dto),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useUpdateFileUploadSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
dto,
}: {
id: string;
dto: UpdateFileUploadSettingDto;
}) => fileUploadSettingsService.update(id, dto),
onSuccess: (_data, { id }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
},
});
};
export const useDeleteFileUploadSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => fileUploadSettingsService.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useReplaceFileUploadFields = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
settingId,
fields,
}: {
settingId: string;
fields: CreateFileUploadFieldDto[];
}) => fileUploadSettingsService.replaceFields(settingId, fields),
onSuccess: (_data, { settingId }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
},
});
};
export const useAddFileUploadField = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
settingId,
dto,
}: {
settingId: string;
dto: CreateFileUploadFieldDto;
}) => fileUploadSettingsService.addField(settingId, dto),
onSuccess: (_data, { settingId }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
},
});
};
export const useUpdateFileUploadField = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
fieldId,
dto,
}: {
fieldId: string;
dto: UpdateFileUploadFieldDto;
}) => fileUploadSettingsService.updateField(fieldId, dto),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useRemoveFileUploadField = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (fieldId: string) =>
fileUploadSettingsService.removeField(fieldId),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};

View File

@@ -0,0 +1,45 @@
import { customers, type Customer } from "@/pages/customers/customers.mock";
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
import {
consignments,
type Consignment,
} from "@/pages/consignments/consignments.mock";
import {
shipments,
type Shipment,
} from "@/pages/tracking/shipments.mock";
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
/**
* Mock "logged-in customer". When auth integrates, replace this with the value
* pulled from `@edr/iamui-common` / the JWT context.
*/
const CURRENT_CUSTOMER_ID = 1;
export function getCurrentCustomer(): Customer {
return (
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
(customers[0] as Customer)
);
}
export function getMyBookings(): Booking[] {
const me = getCurrentCustomer();
return bookings.filter((b) => b.customerId === me.id);
}
export function getMyConsignments(): Consignment[] {
const me = getCurrentCustomer();
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
return consignments.filter((c) => myBookingIds.has(c.bookingId));
}
export function getMyShipments(): Shipment[] {
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
return shipments.filter((s) => myBookingIds.has(s.bookingId));
}
export function getMyInvoices(): Invoice[] {
const me = getCurrentCustomer();
return invoices.filter((inv) => inv.customerId === me.id);
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -7,6 +7,7 @@ import "@edr/ui-common/theme.css";
import App from "./App";
import { AuthProvider } from "./auth/AuthProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const THEME_STORAGE_KEY = "edr-theme";
@@ -35,7 +36,11 @@ if (!rootElement) {
throw new Error("Root element not found");
}
const queryClient = new QueryClient();
createRoot(rootElement).render(
<QueryClientProvider client={queryClient}>
<StrictMode>
<BrowserRouter>
<AuthProvider>
@@ -43,4 +48,5 @@ createRoot(rootElement).render(
</AuthProvider>
</BrowserRouter>
</StrictMode>,
</QueryClientProvider>
);

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteFileUploadSettingDialogProps {
settingLabel: string;
settingCode: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteFileUploadSettingDialog({
settingLabel,
settingCode,
onConfirm,
children,
}: DeleteFileUploadSettingDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete file upload setting?
</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
(<span className="font-mono text-xs">{settingCode}</span>) and all
of its fields. Forms referencing this code will fall back to no
uploads.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,231 @@
import { useState, type ReactNode } from "react";
import { Hash, Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { FileUploadEntity } from "@edr/types/freight";
import { useCreateFileUploadSetting, useUpdateFileUploadSetting } from "@/hooks/useFileUploadSettings";
// import type {
// FileUploadEntity,
// FileUploadSetting,
// } from "@/types/fileUploadSettings";
// import {
// useCreateFileUploadSetting,
// useUpdateFileUploadSetting,
// } from "@/hooks/useFileUploadSettings";
export interface EditFileUploadSettingDialogProps {
mode?: "create" | "edit";
setting?: FileUploadSetting;
children: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
// const ENTITIES: FileUploadEntity[] = [
// "customer",
// "booking",
// "consignment",
// "shipment",
// "invoice",
// "train",
// "other",
// ];
export default function EditFileUploadSettingDialog({
mode = "create",
setting,
children,
}: EditFileUploadSettingDialogProps) {
const isEdit = mode === "edit";
const [open, setOpen] = useState(false);
const [code, setCode] = useState(setting?.code ?? "");
const [label, setLabel] = useState(setting?.label ?? "");
const [entity, setEntity] = useState<FileUploadEntity>(
setting?.entity ?? "other",
);
const [description, setDescription] = useState(setting?.description ?? "");
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateFileUploadSetting();
const updateMutation = useUpdateFileUploadSetting();
const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => {
setCode(setting?.code ?? "");
setLabel(setting?.label ?? "");
setEntity(setting?.entity ?? "other");
setDescription(setting?.description ?? "");
setError(null);
};
const handleSubmit = () => {
setError(null);
if (!code.trim() || !label.trim()) {
setError("Code and label are required.");
return;
}
const payload = {
code: code.trim(),
label: label.trim(),
entity,
description: description.trim() || undefined,
};
const onDone = () => {
setOpen(false);
if (!isEdit) reset();
};
const onError = (err: unknown) => {
setError(
err instanceof Error ? err.message : "Something went wrong. Try again.",
);
};
if (isEdit && setting) {
updateMutation.mutate(
{ id: setting.id, dto: payload },
{ onSuccess: onDone, onError },
);
} else {
createMutation.mutate(payload, { onSuccess: onDone, onError });
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isEdit ? "Edit File Upload Setting" : "New File Upload Setting"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update the metadata for this file upload group."
: "Define a new file upload group that a form can reference by code."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Code *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. customer_registration"
className="pl-10 font-mono"
disabled={isEdit}
/>
</div>
<p className="text-xs text-slate-500">
{isEdit
? "Code is immutable after creation."
: "Stable identifier used in code. Use snake_case."}
</p>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Customer Registration"
/>
</div>
{/* <div className="space-y-2">
<Label>Entity</Label>
<select
value={entity}
onChange={(e) => setEntity(e.target.value as FileUploadEntity)}
className={selectClass}
>
{ENTITIES.map((e) => (
<option key={e} value={e} className="capitalize">
{e[0]!.toUpperCase() + e.slice(1)}
</option>
))}
</select>
<p className="text-xs text-slate-500">
Domain the upload group applies to.
</p>
</div> */}
<div className="space-y-2">
<Label>Field Count</Label>
<Input
disabled
value={String(setting?.fields.length ?? 0)}
className="bg-slate-50 text-slate-600"
/>
<p className="text-xs text-slate-500">
Manage fields from the "Fields" action on the list.
</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this upload group represents and where it's used..."
/>
</div>
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="mt-2 flex justify-end gap-3">
<DialogClose asChild>
<Button variant="outline" disabled={pending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSubmit}
disabled={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save Changes"
) : (
"Create Setting"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,458 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
Filter,
FileUp,
HardDrive,
Layers,
Loader2,
Paperclip,
Pencil,
Plus,
Search,
Settings,
Trash2,
} from "lucide-react";
// import Breadcrumbs from "@/components/Breadcrumbs";
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { getMinFiles } from "@/types/fileUploadSettings";
import { useDeleteFileUploadSetting, useFileUploadSettings } from "@/hooks/useFileUploadSettings";
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useFileUploadSettings();
const deleteMutation = useDeleteFileUploadSetting();
const fileUploadSettings = useMemo(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return fileUploadSettings;
return fileUploadSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q) ||
s.fields.some(
(f) =>
f.fileKey.toLowerCase().includes(q) ||
f.fileLabel.toLowerCase().includes(q),
),
);
}, [fileUploadSettings, query]);
const totalFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.length,
0,
);
const requiredFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.filter((f) => f.isRequired).length,
0,
);
const multiFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.filter((f) => f.isMultiple).length,
0,
);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "File Upload Settings" },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
File Upload Settings
</h1>
<p className="mt-1 text-sm text-slate-500">
Define the file inputs every form in the platform should render
required/optional, single/multiple, allowed types and size.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by code, label, or file key..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
/>
</div>
<EditFileUploadSettingDialog mode="create">
<button
type="button"
className="inline-flex w-35 items-center justify-center gap-2 rounded-md bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Setting
</button>
</EditFileUploadSettingDialog>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Settings"
value={String(fileUploadSettings.length)}
icon={<Settings className="h-5 w-5" />}
/>
<StatCard
title="Total Fields"
value={String(totalFields)}
icon={<Paperclip className="h-5 w-5" />}
/>
<StatCard
title="Required"
value={String(requiredFields)}
icon={<FileUp className="h-5 w-5" />}
/>
<StatCard
title="Multi-file"
value={String(multiFields)}
icon={<Layers className="h-5 w-5" />}
/>
</div>
{/* Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Registered File Upload Groups
</h2>
<p className="text-sm text-slate-500">
Every group a form can reference by code.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1100px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Setting</th>
<th className="px-6 py-4 font-medium">Code</th>
<th className="px-6 py-4 font-medium">Entity</th>
<th className="px-6 py-4 font-medium">Fields</th>
<th className="px-6 py-4 font-medium">Required / Multi</th>
<th className="px-6 py-4 font-medium">Max Size</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" />
<p className="mt-2 text-sm text-slate-500">
Loading file upload settings
</p>
</td>
</tr>
) : isError ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-red-500" />
<p className="mt-2 text-sm text-red-600">
Failed to load settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</p>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-6 py-12 text-center text-sm text-slate-500"
>
{fileUploadSettings.length === 0
? "No file upload settings yet. Click \"New Setting\" to add one."
: "No file upload settings match your search."}
</td>
</tr>
) : (
filtered.map((setting) => {
const required = setting.fields.filter(
(f: any) => f.isRequired,
).length;
const multi = setting.fields.filter(
(f: any) => f.isMultiple,
).length;
const maxSize = Math.max(
0,
...setting.fields.map((f) => f.maxSizeMb),
);
return (
<tr
key={setting.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
<FileUp className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{setting.label}
</p>
<p className="text-xs text-slate-500">
{setting.description ?? "No description"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{setting.code}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
{setting.entity ?? "—"}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-slate-700">
<Paperclip className="h-4 w-4 text-[#10B981]" />
<span className="font-medium">
{setting.fields.length}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Chip>{required} required</Chip>
<Chip muted>{multi} multi</Chip>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-1.5">
<HardDrive className="h-4 w-4 text-slate-400" />
{maxSize ? `${maxSize} MB` : "—"}
</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<ManageFileUploadFieldsDialog setting={setting}>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Paperclip className="h-3.5 w-3.5" />
Fields
</button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog
mode="edit"
setting={setting}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Pencil className="h-4 w-4" />
</button>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() =>
deleteMutation.mutate(setting.id)
}
>
<button
type="button"
disabled={deleteMutation.isPending}
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteFileUploadSettingDialog>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Behavior reference card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Required × Multiple behavior
</h2>
<p className="mt-1 text-sm text-slate-500">
Min and max file counts are derived from these two toggles. The
"Max Files" you set on a field is only used when{" "}
<span className="font-medium">Multiple</span> is on.
</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Required</th>
<th className="py-2 font-medium">Multiple</th>
<th className="py-2 font-medium">min_files</th>
<th className="py-2 font-medium">max_files</th>
</tr>
</thead>
<tbody>
<BehaviorRow
required={false}
multiple={false}
min="0"
max="1"
/>
<BehaviorRow
required={true}
multiple={false}
min="1"
max="1"
/>
<BehaviorRow
required={false}
multiple={true}
min="0"
max="field.maxFiles"
/>
<BehaviorRow
required={true}
multiple={true}
min="1"
max="field.maxFiles"
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-500">
Helpers <span className="font-mono">getMinFiles</span> and{" "}
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
<span className="font-mono">@/types/fileUploadSettings</span> use
them when wiring real uploaders. Example: a field with{" "}
<span className="font-mono">isRequired=false</span>,{" "}
<span className="font-mono">isMultiple=true</span>,{" "}
<span className="font-mono">maxFiles=5</span> gives{" "}
<span className="font-mono">{getMinFiles({
id: "demo",
fileKey: "demo",
fileLabel: "demo",
isRequired: false,
isMultiple: true,
maxFiles: 5,
allowedExtensions: [],
maxSizeMb: 1,
})}</span>
5.
</p>
</div>
</div>
</div>
);
}
function BehaviorRow({
required,
multiple,
min,
max,
}: {
required: boolean;
multiple: boolean;
min: string;
max: string;
}) {
return (
<tr className="border-t border-slate-100">
<td className="py-2.5">
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
</td>
<td className="py-2.5">
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
</td>
<td className="py-2.5 font-mono text-slate-700">{min}</td>
<td className="py-2.5 font-mono text-slate-700">{max}</td>
</tr>
);
}
function Chip({
children,
muted = false,
}: {
children: React.ReactNode;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
}
>
{children}
</span>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,416 @@
import { useState, type ReactNode } from "react";
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import type {
CreateFileUploadFieldDto,
FileUploadSetting,
} from "@/types/fileUploadSettings";
import { getMinFiles } from "@/types/fileUploadSettings";
import { useReplaceFileUploadFields } from "@/hooks/useFileUploadSettings";
export interface ManageFileUploadFieldsDialogProps {
setting: FileUploadSetting;
children: ReactNode;
}
/**
* Local draft used by the editor — does NOT need to satisfy IFileUploadField
* (which carries server-only props like createdAt). On save, we strip the
* client-only `key` and post the rest as CreateFileUploadFieldDto[].
*/
interface DraftField extends CreateFileUploadFieldDto {
key: string;
}
let draftCounter = 0;
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
function makeEmptyDraft(idx: number): DraftField {
return {
key: nextKey(),
fileKey: "",
fileLabel: "",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
order: idx + 1,
};
}
export default function ManageFileUploadFieldsDialog({
setting,
children,
}: ManageFileUploadFieldsDialogProps) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const seed = (): DraftField[] =>
[...setting.fields]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f, idx) => ({
key: f.id,
fileKey: f.fileKey,
fileLabel: f.fileLabel,
helpText: f.helpText ?? undefined,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.maxFiles,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
order: f.order ?? idx + 1,
}));
const [fields, setFields] = useState<DraftField[]>(seed);
const replaceMutation = useReplaceFileUploadFields();
const update = (i: number, patch: Partial<DraftField>) =>
setFields((prev) =>
prev.map((f, idx) => {
if (idx !== i) return f;
const next = { ...f, ...patch };
if (patch.isMultiple === false) next.maxFiles = 1;
if (patch.isMultiple === true && next.maxFiles <= 1) next.maxFiles = 5;
return next;
}),
);
const updateExtensions = (i: number, raw: string) => {
const list = raw
.split(",")
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
update(i, { allowedExtensions: list });
};
const remove = (i: number) =>
setFields((prev) => prev.filter((_, idx) => idx !== i));
const add = () =>
setFields((prev) => [...prev, makeEmptyDraft(prev.length)]);
const move = (i: number, dir: -1 | 1) =>
setFields((prev) => {
const next = [...prev];
const target = i + dir;
if (target < 0 || target >= next.length) return prev;
const a = next[i] as DraftField;
const b = next[target] as DraftField;
next[i] = { ...b, order: i + 1 };
next[target] = { ...a, order: target + 1 };
return next;
});
const handleSave = () => {
setError(null);
const invalid = fields.findIndex(
(f) =>
!f.fileKey.trim() ||
!f.fileLabel.trim() ||
f.allowedExtensions.length === 0,
);
if (invalid >= 0) {
setError(
`Field ${invalid + 1} is missing file key, label, or extensions.`,
);
return;
}
const payload: CreateFileUploadFieldDto[] = fields.map((f, idx) => ({
fileKey: f.fileKey.trim(),
fileLabel: f.fileLabel.trim(),
helpText: f.helpText?.trim() || undefined,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.isMultiple ? Math.max(1, f.maxFiles) : 1,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
order: idx + 1,
}));
replaceMutation.mutate(
{ settingId: setting.id, fields: payload },
{
onSuccess: () => setOpen(false),
onError: (err) =>
setError(
err instanceof Error
? err.message
: "Failed to save fields. Try again.",
),
},
);
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setFields(seed());
setError(null);
}
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Manage Fields · {setting.label}
</DialogTitle>
<DialogDescription>
Add, edit, reorder, or remove upload fields for{" "}
<span className="font-mono text-slate-700">{setting.code}</span>.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
{fields.length} field{fields.length === 1 ? "" : "s"}
</p>
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-3.5 w-3.5" />
Add Field
</button>
</div>
{fields.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No fields yet. Click{" "}
<span className="font-medium">Add Field</span> to start.
</div>
) : (
<div className="space-y-3">
{fields.map((f, i) => (
<FieldEditor
key={f.key}
field={f}
index={i}
total={fields.length}
onChange={(patch) => update(i, patch)}
onChangeExtensions={(raw) => updateExtensions(i, raw)}
onMove={(dir) => move(i, dir)}
onRemove={() => remove(i)}
/>
))}
</div>
)}
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="mt-2 flex justify-end gap-3 border-t border-slate-100 pt-3">
<DialogClose asChild>
<Button variant="outline" disabled={replaceMutation.isPending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSave}
disabled={replaceMutation.isPending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{replaceMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Save Fields"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function FieldEditor({
field,
index,
total,
onChange,
onChangeExtensions,
onMove,
onRemove,
}: {
field: DraftField;
index: number;
total: number;
onChange: (patch: Partial<DraftField>) => void;
onChangeExtensions: (raw: string) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const minFiles = getMinFiles(field);
const effectiveMax = field.isMultiple ? field.maxFiles : 1;
return (
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-400">
<GripVertical className="h-4 w-4" />
<div className="flex flex-col">
<button
type="button"
onClick={() => onMove(-1)}
aria-label="Move up"
disabled={index === 0}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => onMove(1)}
aria-label="Move down"
disabled={index === total - 1}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
</div>
<span className="text-xs font-semibold uppercase tracking-wide text-[#10B981]">
Field {index + 1}
</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
min {minFiles} · max {effectiveMax}
</span>
</div>
<button
type="button"
onClick={onRemove}
aria-label="Remove field"
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs">File Key *</Label>
<Input
value={field.fileKey}
onChange={(e) => onChange({ fileKey: e.target.value })}
placeholder="supporting_doc"
className="font-mono"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">File Label *</Label>
<Input
value={field.fileLabel}
onChange={(e) => onChange({ fileLabel: e.target.value })}
placeholder="Supporting Document"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Max Size (MB)</Label>
<Input
type="number"
min={1}
value={field.maxSizeMb}
onChange={(e) =>
onChange({ maxSizeMb: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Allowed Extensions</Label>
<Input
value={field.allowedExtensions.join(", ")}
onChange={(e) => onChangeExtensions(e.target.value)}
placeholder="pdf, docx, jpg"
className="font-mono"
/>
<p className="text-xs text-slate-500">
Comma-separated, no leading dot.
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Max Files</Label>
<Input
type="number"
min={1}
max={50}
value={field.maxFiles}
disabled={!field.isMultiple}
onChange={(e) =>
onChange({ maxFiles: Number(e.target.value) })
}
className={!field.isMultiple ? "bg-slate-50 text-slate-400" : ""}
/>
{!field.isMultiple ? (
<p className="text-xs text-slate-400">
Locked to 1 when single-file.
</p>
) : null}
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-4">
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={field.isRequired}
onChange={(e) =>
onChange({ isRequired: e.target.checked })
}
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={field.isMultiple}
onChange={(e) =>
onChange({ isMultiple: e.target.checked })
}
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
Multiple
</label>
</div>
<div className="space-y-1.5 md:col-span-4">
<Label className="text-xs">Help Text (optional)</Label>
<Input
value={field.helpText ?? ""}
onChange={(e) => onChange({ helpText: e.target.value })}
placeholder="e.g. PDF or photo of the original document."
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteDropdownSettingDialogProps {
settingLabel: string;
settingCode: string;
onConfirm?: () => void;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteDropdownSettingDialog({
settingLabel,
settingCode,
onConfirm,
children,
open: openProp,
onOpenChange,
}: DeleteDropdownSettingDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete dropdown setting?
</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
(<span className="font-mono text-xs">{settingCode}</span>) and all
of its options. Forms referencing this code will fall back to
empty options.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,468 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
Boxes,
CheckCircle2,
Eye,
Filter,
ListOrdered,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Settings,
Shield,
Sparkles,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
import {
useDeleteDropdownSetting,
useDropdownSettings,
} from "@/hooks/useDropdownSettings";
import type { DropdownSetting } from "@/types/dropdownSettings";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "options" | "delete";
export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
null,
);
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
// `pointer-events: none` on <body> when a menu closes and a dialog opens
// in the same frame — wait two RAFs and then explicitly reset the body
// style so the dialog interior is interactive.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveSetting(setting);
setActiveDialog(dialog);
});
});
};
const closeDialog = () => {
setActiveDialog(null);
// Keep activeSetting briefly so dialog content doesn't flash empty during
// the close animation; cleared on next open.
};
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
// dialog changes, schedule a body-style cleanup after the next paint.
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const { data, isLoading, isError, error } = useDropdownSettings();
const deleteMutation = useDeleteDropdownSetting();
const dropdownSettings = useMemo<DropdownSetting[]>(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return dropdownSettings;
return dropdownSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q),
);
}, [dropdownSettings, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + (s.children?.length ?? 0),
0,
);
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
const searchableCount = dropdownSettings.filter(
(s) => s.meta?.searchable,
).length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownSetting>[] = [
{
id: "setting",
header: "Setting",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Settings />
</div>
<div>
<p className="font-medium text-slate-900">{s.label}</p>
<p className="text-xs text-slate-500">
{s.description ?? "No description"}
</p>
</div>
</div>
);
},
},
{
id: "code",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.code}
</span>
),
},
{
id: "options",
header: "Options",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes />
<span className="font-medium">{s.children?.length ?? 0}</span>
</div>
);
},
},
{
id: "behavior",
header: "Behavior",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap gap-1">
{s.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
</div>
);
},
},
{
id: "permissions",
header: "Permissions",
cell: ({ row }) => {
const s = row.original;
const perms = s.meta?.permissions ?? [];
return (
<div className="flex flex-wrap items-center gap-1">
{perms.length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
perms.map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Shield />
{p}
</span>
))
)}
</div>
);
},
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const setting = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("options", setting)}
>
<CheckCircle2 />
Options
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("edit", setting)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", setting)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "Dropdown Settings" },
]}
/>
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage every dynamic dropdown across the platform labels,
options, ordering, and permissions.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search by code, label, description..."
className="pl-8!"
/>
</div>
<EditDropdownSettingDialog mode="create">
<Button>
<Plus />
New Setting
</Button>
</EditDropdownSettingDialog>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Settings"
value={dropdownSettings.length}
icon={<Settings />}
/>
<StatCard
label="Total Options"
value={totalOptions}
icon={<Boxes />}
/>
<StatCard
label="Multi-select"
value={multipleCount}
icon={<ListOrdered />}
/>
<StatCard
label="Searchable"
value={searchableCount}
icon={<Sparkles />}
/>
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load dropdown settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Registered Dropdowns</CardTitle>
<CardDescription>
Every dynamic dropdown the platform reads from.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading dropdown settings
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
reliably after a menu item is selected. */}
{activeSetting ? (
<>
<EditDropdownSettingDialog
key={`edit-${activeSetting.id}`}
mode="edit"
setting={activeSetting}
open={activeDialog === "edit"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<ManageDropdownOptionsDialog
key={`options-${activeSetting.id}`}
setting={activeSetting}
open={activeDialog === "options"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<DeleteDropdownSettingDialog
key={`delete-${activeSetting.id}`}
settingLabel={activeSetting.label}
settingCode={activeSetting.code}
onConfirm={() => deleteMutation.mutate(activeSetting.id)}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}
function BehaviorChip({
label,
muted = false,
}: {
label: string;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,336 @@
import { useState, type ReactNode } from "react";
import { Hash, Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type {
CreateDropdownSettingDto,
DropdownSetting,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
useCreateDropdownSetting,
useUpdateDropdownSetting,
} from "@/hooks/useDropdownSettings";
export interface EditDropdownSettingDialogProps {
mode?: "create" | "edit";
setting?: DropdownSetting;
/** Optional trigger element. When omitted, the dialog renders content only and is fully controlled. */
children?: ReactNode;
/** Controlled open state. When provided, internal state is ignored. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function parsePermissions(raw: string): string[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
export default function EditDropdownSettingDialog({
mode = "create",
setting,
children,
open: openProp,
onOpenChange,
}: EditDropdownSettingDialogProps) {
const isEdit = mode === "edit";
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
const [code, setCode] = useState(setting?.code ?? "");
const [label, setLabel] = useState(setting?.label ?? "");
const [description, setDescription] = useState(setting?.description ?? "");
const [icon, setIcon] = useState(setting?.meta?.icon ?? "");
const [color, setColor] = useState(setting?.meta?.color ?? "");
const [permissions, setPermissions] = useState(
setting?.meta?.permissions?.join(", ") ?? "",
);
const [version, setVersion] = useState(setting?.meta?.version ?? "1.0");
const [multiple, setMultiple] = useState<boolean>(setting?.multiple ?? false);
const [searchable, setSearchable] = useState<boolean>(
setting?.meta?.searchable ?? false,
);
const [clearable, setClearable] = useState<boolean>(
setting?.meta?.clearable ?? false,
);
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateDropdownSetting();
const updateMutation = useUpdateDropdownSetting();
const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => {
setCode(setting?.code ?? "");
setLabel(setting?.label ?? "");
setDescription(setting?.description ?? "");
setIcon(setting?.meta?.icon ?? "");
setColor(setting?.meta?.color ?? "");
setPermissions(setting?.meta?.permissions?.join(", ") ?? "");
setVersion(setting?.meta?.version ?? "1.0");
setMultiple(setting?.multiple ?? false);
setSearchable(setting?.meta?.searchable ?? false);
setClearable(setting?.meta?.clearable ?? false);
setError(null);
};
const buildPayload = (): CreateDropdownSettingDto => ({
code: code.trim(),
label: label.trim(),
description: description.trim() || undefined,
multiple,
meta: {
...(icon.trim() ? { icon: icon.trim() } : {}),
...(color.trim() ? { color: color.trim() } : {}),
searchable,
clearable,
...(version.trim() ? { version: version.trim() } : {}),
permissions: parsePermissions(permissions),
},
});
const handleSubmit = () => {
setError(null);
if (!code.trim() || !label.trim()) {
setError("Code and label are required.");
return;
}
if (!/^[a-z][a-z0-9_]*$/i.test(code.trim())) {
setError(
"Code must start with a letter and contain only letters, digits, or underscores.",
);
return;
}
const payload = buildPayload();
const onDone = () => {
setOpen(false);
if (!isEdit) reset();
};
const onError = (err: unknown) => {
setError(
err instanceof Error
? err.message
: "Something went wrong. Try again.",
);
};
if (isEdit && setting) {
// Update DTO omits `code` (immutable); strip it before sending.
const { code: _unused, ...updateDto } = payload;
void _unused;
updateMutation.mutate(
{ id: setting.id, dto: updateDto as UpdateDropdownSettingDto },
{ onSuccess: onDone, onError },
);
} else {
createMutation.mutate(payload, { onSuccess: onDone, onError });
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isEdit ? "Edit Dropdown Setting" : "New Dropdown Setting"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update the metadata for this dropdown setting."
: "Define a new dynamic dropdown that admins can manage."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Code *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. cargo_type"
className="pl-10 font-mono"
disabled={isEdit}
/>
</div>
<p className="text-xs text-slate-500">
{isEdit
? "Code is immutable after creation."
: "Stable identifier used in code. Use snake_case."}
</p>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Cargo Type"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this dropdown represents and where it's used..."
/>
</div>
<div className="space-y-2">
<Label>Icon (meta.icon)</Label>
<Input
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="lucide icon name, e.g. package"
/>
</div>
<div className="space-y-2">
<Label>Color (meta.color)</Label>
<Input
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#10B981"
/>
</div>
<div className="space-y-2">
<Label>Permissions (comma-separated)</Label>
<Input
value={permissions}
onChange={(e) => setPermissions(e.target.value)}
placeholder="admin, ops"
/>
</div>
<div className="space-y-2">
<Label>Version (meta.version)</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder="1.0"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Behavior</Label>
<div className="flex flex-wrap gap-3">
<ToggleChip
checked={multiple}
onChange={setMultiple}
label="Multi-select"
description="Users can pick more than one option"
/>
<ToggleChip
checked={searchable}
onChange={setSearchable}
label="Searchable"
description="Show a search input in the dropdown"
/>
<ToggleChip
checked={clearable}
onChange={setClearable}
label="Clearable"
description="Allow users to clear the selection"
/>
</div>
</div>
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="mt-2 flex justify-end gap-3">
<DialogClose asChild>
<Button variant="outline" disabled={pending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSubmit}
disabled={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save Changes"
) : (
"Create Setting"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function ToggleChip({
checked,
onChange,
label,
description,
}: {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
description: string;
}) {
return (
<label
className={
checked
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#10B981]/40 bg-[#10B981]/10 px-3 py-2 text-sm"
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#10B981]/30 hover:bg-[#10B981]/5"
}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
<div>
<p className="font-medium text-slate-900">{label}</p>
<p className="text-xs text-slate-500">{description}</p>
</div>
</label>
);
}

View File

@@ -0,0 +1,339 @@
import { useState, type ReactNode } from "react";
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import type {
CreateDropdownOptionDto,
DropdownSetting,
} from "@/types/dropdownSettings";
import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
export interface ManageDropdownOptionsDialogProps {
setting: DropdownSetting;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* Local draft used by the editor — uses a stable client-only `key` so React
* keys remain stable across reorders. On save we strip `key` and POST the
* remainder as CreateDropdownOptionDto[].
*/
interface DraftOption extends CreateDropdownOptionDto {
key: string;
}
let draftCounter = 0;
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
function makeEmptyDraft(idx: number): DraftOption {
return {
key: nextKey(),
value: "",
label: "",
disabled: false,
order: idx + 1,
meta: {},
};
}
export default function ManageDropdownOptionsDialog({
setting,
children,
open: openProp,
onOpenChange,
}: ManageDropdownOptionsDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
const [error, setError] = useState<string | null>(null);
const seed = (): DraftOption[] =>
[...(setting.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o, idx) => ({
key: o.id,
value: o.value,
label: o.label,
note: o.note ?? undefined,
disabled: o.disabled,
order: o.order ?? idx + 1,
meta: {
...(o.meta?.icon ? { icon: o.meta.icon } : {}),
...(o.meta?.color ? { color: o.meta.color } : {}),
...(o.meta?.badge ? { badge: o.meta.badge } : {}),
},
}));
const [options, setOptions] = useState<DraftOption[]>(seed);
const replaceMutation = useReplaceDropdownOptions();
const update = (i: number, patch: Partial<DraftOption>) =>
setOptions((prev) =>
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
);
const updateMeta = (
i: number,
patch: Partial<NonNullable<DraftOption["meta"]>>,
) =>
setOptions((prev) =>
prev.map((o, idx) =>
idx === i ? { ...o, meta: { ...(o.meta ?? {}), ...patch } } : o,
),
);
const remove = (i: number) =>
setOptions((prev) => prev.filter((_, idx) => idx !== i));
const add = () =>
setOptions((prev) => [...prev, makeEmptyDraft(prev.length)]);
const move = (i: number, dir: -1 | 1) =>
setOptions((prev) => {
const next = [...prev];
const target = i + dir;
if (target < 0 || target >= next.length) return prev;
const a = next[i] as DraftOption;
const b = next[target] as DraftOption;
next[i] = { ...b, order: i + 1 };
next[target] = { ...a, order: target + 1 };
return next;
});
const handleSave = () => {
setError(null);
const invalid = options.findIndex(
(o) => !o.label.trim() || !o.value.trim(),
);
if (invalid >= 0) {
setError(`Option ${invalid + 1} is missing a label or value.`);
return;
}
const payload: CreateDropdownOptionDto[] = options.map((o, idx) => {
const meta: NonNullable<CreateDropdownOptionDto["meta"]> = {};
if (o.meta?.icon?.trim()) meta.icon = o.meta.icon.trim();
if (o.meta?.color?.trim()) meta.color = o.meta.color.trim();
if (o.meta?.badge?.trim()) meta.badge = o.meta.badge.trim();
return {
value: o.value.trim(),
label: o.label.trim(),
note: o.note?.trim() || undefined,
disabled: o.disabled ?? false,
order: idx + 1,
...(Object.keys(meta).length > 0 ? { meta } : {}),
};
});
replaceMutation.mutate(
{ settingId: setting.id, options: payload },
{
onSuccess: () => setOpen(false),
onError: (err) =>
setError(
err instanceof Error
? err.message
: "Failed to save options. Try again.",
),
},
);
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setOptions(seed());
if (!next) setError(null);
}}
>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Manage Options · {setting.label}
</DialogTitle>
<DialogDescription>
Add, edit, reorder, or remove options for{" "}
<span className="font-mono text-slate-700">{setting.code}</span>.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
{options.length} option{options.length === 1 ? "" : "s"}
</p>
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-3.5 w-3.5" />
Add Option
</button>
</div>
{options.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No options yet. Click{" "}
<span className="font-medium">Add Option</span> to start.
</div>
) : (
<div className="space-y-2">
{options.map((opt, i) => (
<div
key={opt.key}
className="grid gap-2 rounded-2xl border border-slate-200 bg-white p-3 md:grid-cols-[auto_1fr_1fr_1fr_auto_auto_auto]"
>
<div className="flex items-center gap-1 text-slate-400">
<GripVertical className="h-4 w-4" />
<div className="flex flex-col">
<button
type="button"
onClick={() => move(i, -1)}
aria-label="Move up"
disabled={i === 0}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => move(i, 1)}
aria-label="Move down"
disabled={i === options.length - 1}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Label *</Label>
<Input
value={opt.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Display label"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Value *</Label>
<Input
value={opt.value}
onChange={(e) => update(i, { value: e.target.value })}
placeholder="Stored value"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Note</Label>
<Input
value={opt.note ?? ""}
onChange={(e) => update(i, { note: e.target.value })}
placeholder="Helper text"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Badge</Label>
<Input
value={opt.meta?.badge ?? ""}
onChange={(e) => updateMeta(i, { badge: e.target.value })}
placeholder="—"
className="w-20"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Color</Label>
<Input
value={opt.meta?.color ?? ""}
onChange={(e) => updateMeta(i, { color: e.target.value })}
placeholder="#…"
className="w-24 font-mono"
/>
</div>
<div className="flex flex-col items-center justify-between gap-2">
<label className="flex items-center gap-1 text-xs text-slate-600">
<input
type="checkbox"
checked={opt.disabled ?? false}
onChange={(e) =>
update(i, { disabled: e.target.checked })
}
className="h-3.5 w-3.5 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
Off
</label>
<button
type="button"
onClick={() => remove(i)}
aria-label={`Remove ${opt.label || "option"}`}
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
<DialogClose asChild>
<Button variant="outline" disabled={replaceMutation.isPending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSave}
disabled={replaceMutation.isPending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{replaceMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Save Options"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,97 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type {
CreateDropdownOptionDto,
CreateDropdownSettingDto,
DropdownOption,
DropdownSetting,
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
const BASE = URL_CONSTANTS.DROPDOWN_SETTINGS.BASE;
export const dropdownSettingsService = {
list: async (): Promise<DropdownSetting[]> => {
const response = await client.get<ApiResponse<DropdownSetting[]>>(BASE);
return unwrap(response.data);
},
getById: async (id: string): Promise<DropdownSetting> => {
const response = await client.get<ApiResponse<DropdownSetting>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id),
);
return unwrap(response.data);
},
getByCode: async (code: string): Promise<DropdownSetting> => {
const response = await client.get<ApiResponse<DropdownSetting>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_CODE(code),
);
return unwrap(response.data);
},
create: async (
payload: CreateDropdownSettingDto,
): Promise<DropdownSetting> => {
const response = await client.post<ApiResponse<DropdownSetting>>(
BASE,
payload,
);
return unwrap(response.data);
},
update: async (
id: string,
payload: UpdateDropdownSettingDto,
): Promise<DropdownSetting> => {
const response = await client.patch<ApiResponse<DropdownSetting>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id),
payload,
);
return unwrap(response.data);
},
remove: async (id: string): Promise<void> => {
await client.delete(URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id));
},
replaceOptions: async (
id: string,
options: CreateDropdownOptionDto[],
): Promise<DropdownOption[]> => {
const response = await client.put<ApiResponse<DropdownOption[]>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.OPTIONS(id),
options,
);
return unwrap(response.data);
},
addOption: async (
id: string,
payload: CreateDropdownOptionDto,
): Promise<DropdownOption> => {
const response = await client.post<ApiResponse<DropdownOption>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.OPTIONS(id),
payload,
);
return unwrap(response.data);
},
updateOption: async (
optionId: string,
payload: UpdateDropdownOptionDto,
): Promise<DropdownOption> => {
const response = await client.patch<ApiResponse<DropdownOption>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.OPTION_BY_ID(optionId),
payload,
);
return unwrap(response.data);
},
removeOption: async (optionId: string): Promise<void> => {
await client.delete(URL_CONSTANTS.DROPDOWN_SETTINGS.OPTION_BY_ID(optionId));
},
};

View File

@@ -0,0 +1,137 @@
import { api as client } from "../auth/http";
import type {
CreateFileUploadFieldDto,
CreateFileUploadSettingDto,
FileUploadField,
FileUploadSetting,
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import { URL_CONSTANTS } from "@/constants/URLS";
import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
import { ApiResponse } from "@/types/apiResponse";
import { endpoint, unwrap } from "@/utils/endpoint";
const BASE = "/file-upload-settings";
// function unwrap<T>(payload: Envelope<T>): T {
// if (
// payload &&
// typeof payload === "object" &&
// "data" in (payload as object)
// ) {
// return (payload as { data: T }).data;
// }
// return payload as T;
// }
export const fileUploadSettingsService = {
// GET /file-upload-settings
list: async (): Promise<FileUploadSetting[]> => {
const response = await client.get<ApiResponse<FileUploadSetting[]>>(BASE);
return unwrap(response.data);
},
// GET /file-upload-settings/:id
getById: async (id: string): Promise<FileUploadSetting> => {
const response = await client.get<ApiResponse<FileUploadSetting>>(
`${BASE}/${id}`,
);
return unwrap(response.data);
},
// GET /file-upload-settings/by-code/:code
getByCode: async (code: string): Promise<FileUploadSetting> => {
const response = await client.get<ApiResponse<FileUploadSetting>>(
`${BASE}/by-code/${encodeURIComponent(code)}`,
);
return unwrap(response.data);
},
// POST /file-upload-settings
create: async (
payload: CreateFileUploadSettingDto,
): Promise<FileUploadSetting> => {
const response = await client.post<ApiResponse<FileUploadSetting>>(
BASE,
payload,
);
return unwrap(response.data);
},
// PATCH /file-upload-settings/:id
update: async (
id: string,
payload: UpdateFileUploadSettingDto,
): Promise<FileUploadSetting> => {
const response = await client.patch<ApiResponse<FileUploadSetting>>(
`${BASE}/${id}`,
payload,
);
return unwrap(response.data);
},
// DELETE /file-upload-settings/:id
remove: async (id: string): Promise<void> => {
await client.delete(`${BASE}/${id}`);
},
// PUT /file-upload-settings/:id/fields
replaceFields: async (
id: string,
fields: CreateFileUploadFieldDto[],
): Promise<FileUploadField[]> => {
const response = await client.put<ApiResponse<FileUploadField[]>>(
`${BASE}/${id}/fields`,
fields,
);
return unwrap(response.data);
},
// POST /file-upload-settings/:id/fields
addField: async (
id: string,
payload: CreateFileUploadFieldDto,
): Promise<FileUploadField> => {
const response = await client.post<ApiResponse<FileUploadField>>(
`${BASE}/${id}/fields`,
payload,
);
return unwrap(response.data);
},
// PATCH /file-upload-settings/fields/:fieldId
updateField: async (
fieldId: string,
payload: UpdateFileUploadFieldDto,
): Promise<FileUploadField> => {
const response = await client.patch<ApiResponse<FileUploadField>>(
`${BASE}/fields/${fieldId}`,
payload,
);
return unwrap(response.data);
},
// DELETE /file-upload-settings/fields/:fieldId
removeField: async (fieldId: string): Promise<void> => {
await client.delete(`${BASE}/fields/${fieldId}`);
},
};
export const getFileUploadSettingByCode = endpoint<string, FileUploadSetting>(
QUERY_KEYS.FILES.FILE_UPLOAD_SETTINGS,
QUERY_KEYS.FILES.BY_CODE,
(code: any) =>
client
.get<
ApiResponse<FileUploadSetting>
>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
.then((res: any) => res.data.data),
);

View File

@@ -1,5 +1,25 @@
// import { CargoType } from "@edr-freight-web/types";
// import { api as client } from "@edr-freight-web/utils/api/";
import { api as client } from "../../auth/http";
import { URL_CONSTANTS } from "../../constants/URLS";
export const createCargoType = (data: any) => {
return client.post(URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES, { data });
};
export const getCargoType = () => {
return client.get(URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES);
};
export const getCargoTypeById = (id: string) => {
return client.get(`${URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES}/${id}`);
};
export const updateCargoType = (id: string, data: any) => {
return client.put(`${URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES}/${id}`, { data });
};
export const deleteCargoType = (id: string) => {
return client.delete(`${URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES}/${id}`);
};

View File

@@ -0,0 +1,19 @@
import { api as client } from "../../auth/http";
import { URL_CONSTANTS } from "../../constants/URLS";
export const createContainerType = (data: any) =>
client.post(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES, { data });
export const getContainerTypes = () =>
client.get(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES);
export const getContainerTypeById = (id: string) =>
client.get(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id));
export const updateContainerType = (id: string, data: any) =>
client.put(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id), { data });
export const deleteContainerType = (id: string) =>
client.delete(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id));

View File

@@ -0,0 +1,5 @@
export type ApiResponse<T> = {
success: boolean;
data: T;
timestamp: string;
};

View File

@@ -0,0 +1,12 @@
// Re-export the shared types from @edr/types so existing local imports keep
// working. Canonical source: packages/types/src/freight/dropdown_settings.ts
import type { Freight } from "@edr/types";
export type DropdownOptionMeta = Freight.IDropdownOptionMeta;
export type DropdownOption = Freight.IDropdownOption;
export type DropdownSettingMeta = Freight.IDropdownSettingMeta;
export type DropdownSetting = Freight.IDropdownSetting;
export type CreateDropdownOptionDto = Freight.CreateDropdownOptionDto;
export type CreateDropdownSettingDto = Freight.CreateDropdownSettingDto;
export type UpdateDropdownOptionDto = Freight.UpdateDropdownOptionDto;
export type UpdateDropdownSettingDto = Freight.UpdateDropdownSettingDto;

View File

@@ -0,0 +1,39 @@
// Re-export the shared types from @edr/types so existing local imports keep
// working. Canonical source: packages/types/src/freight/file_upload_settings.ts
//
// NOTE: @edr/types is compiled to CommonJS (see packages/types/tsconfig.json),
// so its dist/index.js uses `Object.defineProperty(exports, ...)` instead of
// real ESM exports. Vite can't pull runtime values out of it — only TypeScript
// type-only imports (which are erased at build time) work cleanly. That's why
// `getMinFiles` / `getEffectiveMaxFiles` are defined locally below instead of
// re-exported from the package. They mirror the canonical logic in
// packages/types/src/freight/file_upload_settings.ts exactly.
import type { Freight } from "@edr/types";
export type FileUploadEntity = Freight.FileUploadEntity;
export type FileUploadField = Freight.IFileUploadField;
export type FileUploadSetting = Freight.IFileUploadSetting;
export type CreateFileUploadFieldDto = Freight.CreateFileUploadFieldDto;
export type CreateFileUploadSettingDto = Freight.CreateFileUploadSettingDto;
export type UpdateFileUploadFieldDto = Freight.UpdateFileUploadFieldDto;
export type UpdateFileUploadSettingDto = Freight.UpdateFileUploadSettingDto;
/**
* required | multiple | min | max
* ---------|----------|-----|-----------------
* no | no | 0 | 1
* yes | no | 1 | 1
* no | yes | 0 | field.maxFiles
* yes | yes | 1 | field.maxFiles
*/
export function getMinFiles(
field: Pick<FileUploadField, "isRequired">,
): number {
return field.isRequired ? 1 : 0;
}
export function getEffectiveMaxFiles(
field: Pick<FileUploadField, "isMultiple" | "maxFiles">,
): number {
return field.isMultiple ? Math.max(1, field.maxFiles) : 1;
}

View File

@@ -0,0 +1,119 @@
import {
UseQueryOptions,
UseMutationOptions
} from "@tanstack/react-query";
// ---------------------------------------------------------------------------
// React Query shared types
// ---------------------------------------------------------------------------
export type QueryConfig<T> = Omit<
UseQueryOptions<T, Error, T, readonly unknown[]>,
"queryKey" | "queryFn"
>;
// ---------------------------------------------------------------------------
// Endpoint interfaces
// ---------------------------------------------------------------------------
export interface EndpointWithInput<TInput, TResponse> {
call(input: TInput): Promise<TResponse>;
queryKey(input: TInput): readonly unknown[];
queryOptions(
config: { input: TInput } & QueryConfig<TResponse>,
): UseQueryOptions<TResponse, Error, TResponse, readonly unknown[]>;
}
export interface EndpointWithoutInput<TResponse> {
call(): Promise<TResponse>;
queryKey(): readonly unknown[];
queryOptions(
config?: QueryConfig<TResponse>,
): UseQueryOptions<TResponse, Error, TResponse, readonly unknown[]>;
}
export type Endpoint<TInput, TResponse> = TInput extends void
? EndpointWithoutInput<TResponse>
: EndpointWithInput<TInput, TResponse>;
// ---------------------------------------------------------------------------
// Endpoint builder
// ---------------------------------------------------------------------------
export function endpoint<TInput, TResponse>(
service: string,
action: string,
execute: (input: TInput) => Promise<TResponse>,
) {
const buildKey = (input?: TInput): readonly unknown[] =>
input === undefined
? [service, action]
: [service, action, input];
const call = (input: TInput) => execute(input);
const queryKey = (input?: TInput) => buildKey(input);
const queryOptions = (
config?: { input?: TInput } & QueryConfig<TResponse>,
): UseQueryOptions<
TResponse,
Error,
TResponse,
readonly unknown[]
> => {
const { input, ...rest } = config ?? {};
return {
...rest,
queryKey: buildKey(input),
queryFn: () => execute(input as TInput),
};
};
const mutationOptions = (
config?: Omit<
UseMutationOptions<
TResponse,
Error,
TInput
>,
"mutationFn"
>,
): UseMutationOptions<
TResponse,
Error,
TInput
> => {
return {
...config,
mutationFn: (
variables: TInput,
): Promise<TResponse> =>
execute(variables),
};
};
return {
call,
queryKey,
queryOptions,
mutationOptions
};
}
// ---------------------------------------------------------------------------
// Helper utilities
// ---------------------------------------------------------------------------
export function unwrap<T>(response: { data: T } | T): T {
if (
response &&
typeof response === "object" &&
"data" in (response as object)
) {
return (response as { data: T }).data;
}
return response as T;
}

View File

@@ -32,6 +32,8 @@ import NewBookingPage from "./pages/bookings/NewBookingPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import { useEffect } from "react";
import CustomerOnBoarding from "./pages/customers/on_boarding/TransportrOnBoarding";
import CustomerOnboardingPage from "./pages/customers/on_boarding/CustomerOnboardingPage";
const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/", icon: <Home /> },
@@ -77,6 +79,8 @@ const App = () => {
return <OnboardingPage />;
}
return <CustomerOnboardingPage />
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;

View File

@@ -0,0 +1,527 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowRight,
ArrowLeft,
Building2,
User,
FileText,
CheckCircle2,
Loader2,
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CreateCustomerDto } from "@/types/customers";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
import TransporterOnboarding from "./TransportrOnBoarding";
import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
import ImportExportOnBoarding from "./ImportExportOnBoarding";
type OnboardingStep = "company" | "personnel" | "poa";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z
.string()
.min(1, "VAT number is required")
.length(10, "VAT number must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"),
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
poaName: z.string().optional(),
poaPhone: z.string().optional(),
poaPhoneCountryCode: z.string().optional(),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
],
personnel: [
"contactPersonName",
"contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
"generalManagerPhoneCountryCode",
],
poa: [],
};
export default function CustomerOnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [step, setStep] = useState<OnboardingStep>("company");
const {
register,
handleSubmit,
trigger,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyName: "",
companyEmail: "",
companyPhone: "",
companyPhoneCountryCode: "+251",
companyLocation: "",
companyAddress: "",
tinNumber: "",
vatNumber: "",
fanNumber: "",
contactPersonName: "",
contactPersonPhone: "",
contactPersonPhoneCountryCode: "+251",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
generalManagerPhoneCountryCode: "+251",
poaName: "",
poaPhone: "",
poaPhoneCountryCode: "+251",
poaAddress: "",
poaEmail: "",
poaLocation: "",
},
});
const createCustomerMutation = useMutation({
mutationFn: (payload: CreateCustomerDto) =>
api.customers.create.call(payload),
onSuccess: () => {
if (user)
queryClient.invalidateQueries({
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
});
},
});
const nextStep = async () => {
if (step === "poa") {
handleSubmit(onSubmit)();
return;
}
const fields = stepFields[step];
const isValid = await trigger(fields);
if (!isValid) return;
setStep(step === "company" ? "personnel" : "poa");
};
const prevStep = () => {
if (step === "personnel") setStep("company");
else if (step === "poa") setStep("personnel");
};
const onSubmit = async (data: FormData) => {
const nameParts = (user?.name?.en ?? "").split(" ");
const payload: CreateCustomerDto = {
userId: user!.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user!.email,
phone: user!.phoneNumber,
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
tinNumber: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
poaName: data.poaName || undefined,
poaPhone:
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
};
createCustomerMutation.mutate(payload);
};
return (
<AuthLayout
left={{
badge: "Complete Your Profile",
title: "Set up your company profile",
description:
"Provide your business details to start using EDR Freight for managing shipments, tracking consignments, and streamlining logistics operations across Ethiopia and Djibouti.",
features: [
"Company registration details",
"Contact and management personnel",
"Power of Attorney (optional)",
],
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
}}
>
<TransporterOnboarding />
{/* <DjiboutiForwardingAgentForm /> */}
{/* <ImportExportOnBoarding /> */}
{/* <div className="mb-8 lg:col-span-2">
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step !== "company"}
/>
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && "Step 1 of 3 — Company Information"}
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
</p>
</div> */}
{/* <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
{step === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location</FieldLabel>
<Input
placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Bole Subcity, Woreda 03"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.vatNumber)}>
<FieldLabel>VAT Number</FieldLabel>
<Input
placeholder="VAT-12345"
aria-invalid={Boolean(errors.vatNumber)}
maxLength={10}
{...register("vatNumber")}
/>
<FieldError errors={[errors.vatNumber]} />
</Field>
</div>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</>
)}
{step === "personnel" && (
<>
<p className="text-sm text-muted-foreground">
Personal details are pulled from your account. Contact and
management info is collected below.
</p>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
</h3>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<PhoneInput
countryCode={{
...register("contactPersonPhoneCountryCode"),
}}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
/>
</div>
</div>
<hr className="border-border" />
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
General Manager
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
className="col-span-2"
data-invalid={Boolean(errors.generalManagerName)}
>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Abebe Bikila"
aria-invalid={Boolean(errors.generalManagerName)}
{...register("generalManagerName")}
/>
<FieldError errors={[errors.generalManagerName]} />
</Field>
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput
countryCode={{
...register("generalManagerPhoneCountryCode"),
}}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
/>
</div>
</div>
</>
)}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Skip if not applicable.
</p>
<Field>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
{...register("poaName")}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
{...register("poaEmail")}
/>
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
{...register("poaLocation")}
/>
</Field>
<Field>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
{...register("poaAddress")}
/>
</Field>
</div>
</>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "company"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={createCustomerMutation.isPending}
>
{createCustomerMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form> */}
</AuthLayout>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
);
}

View File

@@ -0,0 +1,558 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
Building2,
User,
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type OnboardingStep =
| "personal"
| "company"
| "representative";
const schema = z.object({
// PERSONAL
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phoneNumber: z
.string()
.min(1, "Phone number is required"),
phoneCountryCode: z.string().min(1),
// COMPANY
companyName: z
.string()
.min(1, "Company name is required"),
companyEmail: z
.string()
.email("Invalid company email"),
companyPhone: z
.string()
.min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1),
companyLocation: z
.string()
.min(1, "Company location is required"),
companyAddress: z
.string()
.min(1, "Company address is required"),
// REPRESENTATIVE
representativeName: z
.string()
.min(1, "Representative name is required"),
representativeEmail: z
.string()
.email("Invalid representative email"),
representativePhone: z
.string()
.min(1, "Representative phone is required"),
representativePhoneCountryCode: z.string().min(1),
});
type FormData = z.infer<typeof schema>;
const stepFields: Record<
OnboardingStep,
(keyof FormData)[]
> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
],
representative: [
"representativeName",
"representativeEmail",
"representativePhone",
"representativePhoneCountryCode",
],
};
export default function DjiboutiForwardingAgentForm() {
const [step, setStep] =
useState<OnboardingStep>("personal");
const {
register,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phoneCountryCode: "+253",
companyPhoneCountryCode: "+253",
representativePhoneCountryCode:
"+253",
},
});
const nextStep = async () => {
if (step === "representative") {
handleSubmit(onSubmit)();
return;
}
const isValid = await trigger(
stepFields[step]
);
if (!isValid) return;
if (step === "personal") {
setStep("company");
} else {
setStep("representative");
}
};
const prevStep = () => {
if (step === "company") {
setStep("personal");
} else if (
step === "representative"
) {
setStep("company");
}
};
const onSubmit = async (
data: FormData
) => {
console.log(data);
};
return (
<>
{/* STEPPER */}
<div className="mb-8">
<div className="flex items-center justify-between max-w-xl mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<User className="size-5" />}
active={step === "personal"}
completed={
step !== "personal"
}
/>
<StepIcon
icon={
<Building2 className="size-5" />
}
active={step === "company"}
completed={
step === "representative"
}
/>
<StepIcon
icon={<User className="size-5" />}
active={
step === "representative"
}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" &&
"Step 1 of 3 — Personal Information"}
{step === "company" &&
"Step 2 of 3 — Company Information"}
{step === "representative" &&
"Step 3 of 3 — Representative Information"}
</p>
</div>
{/* FORM */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.firstName
)}
>
<FieldLabel>
First Name
</FieldLabel>
<Input
placeholder="Ahmed"
{...register("firstName")}
/>
<FieldError
errors={[errors.firstName]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.lastName
)}
>
<FieldLabel>
Last Name
</FieldLabel>
<Input
placeholder="Ali"
{...register("lastName")}
/>
<FieldError
errors={[errors.lastName]}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.email
)}
>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="agent@company.com"
{...register("email")}
/>
<FieldError
errors={[errors.email]}
/>
</Field>
<PhoneInput
label="Phone Number"
countryCode={{
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "77123456",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
/>
</div>
</>
)}
{/* COMPANY */}
{step === "company" && (
<>
<Field
data-invalid={Boolean(
errors.companyName
)}
>
<FieldLabel>
Company Name
</FieldLabel>
<Input
placeholder="Djibouti Freight Co."
{...register("companyName")}
/>
<FieldError
errors={[errors.companyName]}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyEmail
)}
>
<FieldLabel>
Company Email
</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
{...register(
"companyEmail"
)}
/>
<FieldError
errors={[errors.companyEmail]}
/>
</Field>
<PhoneInput
label="Company Phone"
countryCode={{
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyLocation
)}
>
<FieldLabel>
Company Location / Country
</FieldLabel>
<Input
placeholder="Djibouti"
{...register(
"companyLocation"
)}
/>
<FieldError
errors={[
errors.companyLocation,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.companyAddress
)}
>
<FieldLabel>
Company Address
</FieldLabel>
<Input
placeholder="Rue de Venise"
{...register(
"companyAddress"
)}
/>
<FieldError
errors={[
errors.companyAddress,
]}
/>
</Field>
</div>
</>
)}
{/* REPRESENTATIVE */}
{step ===
"representative" && (
<>
<Field
data-invalid={Boolean(
errors.representativeName
)}
>
<FieldLabel>
Company Representative Person
Name
</FieldLabel>
<Input
placeholder="Mohamed Hassan"
{...register(
"representativeName"
)}
/>
<FieldError
errors={[
errors.representativeName,
]}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.representativeEmail
)}
>
<FieldLabel>
Representative Email
</FieldLabel>
<Input
type="email"
placeholder="rep@company.com"
{...register(
"representativeEmail"
)}
/>
<FieldError
errors={[
errors.representativeEmail,
]}
/>
</Field>
<PhoneInput
label="Representative Phone"
countryCode={{
...register(
"representativePhoneCountryCode"
),
}}
phone={{
...register(
"representativePhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.representativePhoneCountryCode
}
phoneError={
errors.representativePhone
}
/>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "personal"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting}
>
{step === "representative" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? (
<CheckCircle2 className="size-5" />
) : (
icon
)}
</div>
);
}

View File

@@ -0,0 +1,771 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
Building2,
User,
FileText,
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type OnboardingStep =
| "personal"
| "company"
| "personnel"
| "poa";
const onboardingSchema = z.object({
// PERSONAL
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phoneNumber: z.string().min(1, "Phone number is required"),
phoneCountryCode: z.string().min(1),
// COMPANY
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
// LEGAL
tinNumber: z.string().regex(/^\d{10}$/, {
message: "TIN must be exactly 10 digits",
}),
vatNumber: z.string().min(1, "VAT number is required"),
fanNumber: z.string().regex(/^\d{16}$/, {
message: "FAN must be exactly 16 digits",
}),
// CONTACT PERSON
contactPersonName: z
.string()
.min(1, "Contact person name is required"),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required"),
contactPersonPhoneCountryCode: z.string().min(1),
// GENERAL MANAGER
generalManagerName: z
.string()
.min(1, "General manager name is required"),
generalManagerEmail: z
.string()
.email("Invalid email"),
generalManagerPhone: z
.string()
.min(1, "General manager phone is required"),
generalManagerPhoneCountryCode: z.string().min(1),
// OPTIONAL POA
poaName: z.string().optional(),
poaPhone: z.string().optional(),
poaPhoneCountryCode: z.string().optional(),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<
OnboardingStep,
(keyof FormData)[]
> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
],
personnel: [
"contactPersonName",
"contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
"generalManagerPhoneCountryCode",
],
poa: [],
};
export default function ImportExportOnBoarding() {
const [step, setStep] =
useState<OnboardingStep>("personal");
const {
register,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
phoneCountryCode: "+251",
companyPhoneCountryCode: "+251",
contactPersonPhoneCountryCode: "+251",
generalManagerPhoneCountryCode: "+251",
poaPhoneCountryCode: "+251",
},
});
const nextStep = async () => {
if (step === "poa") {
handleSubmit(onSubmit)();
return;
}
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
if (step === "personal") {
setStep("company");
} else if (step === "company") {
setStep("personnel");
} else {
setStep("poa");
}
};
const prevStep = () => {
if (step === "company") {
setStep("personal");
} else if (step === "personnel") {
setStep("company");
} else if (step === "poa") {
setStep("personnel");
}
};
const onSubmit = async (data: FormData) => {
console.log(data);
};
return (
<>
{/* STEP HEADER */}
<div className="mb-8">
<div className="flex items-center justify-between max-w-2xl mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<User className="size-5" />}
active={step === "personal"}
completed={
step !== "personal"
}
/>
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={
step === "personnel" ||
step === "poa"
}
/>
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" &&
"Step 1 of 4 — Personal Information"}
{step === "company" &&
"Step 2 of 4 — Company Information"}
{step === "personnel" &&
"Step 3 of 4 — Personnel Information"}
{step === "poa" &&
"Step 4 of 4 — Power of Attorney"}
</p>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.firstName
)}
>
<FieldLabel>
First Name
</FieldLabel>
<Input
placeholder="John"
{...register("firstName")}
/>
<FieldError
errors={[errors.firstName]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.lastName
)}
>
<FieldLabel>
Last Name
</FieldLabel>
<Input
placeholder="Doe"
{...register("lastName")}
/>
<FieldError
errors={[errors.lastName]}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.email
)}
>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="john@example.com"
{...register("email")}
/>
<FieldError
errors={[errors.email]}
/>
</Field>
<PhoneInput
label="Phone Number"
countryCode={{
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "912345678",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
/>
</div>
</>
)}
{/* COMPANY */}
{step === "company" && (
<>
<Field
data-invalid={Boolean(
errors.companyName
)}
>
<FieldLabel>
Company Name
</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
{...register("companyName")}
/>
<FieldError
errors={[errors.companyName]}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyEmail
)}
>
<FieldLabel>
Company Email
</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
{...register(
"companyEmail"
)}
/>
<FieldError
errors={[errors.companyEmail]}
/>
</Field>
<PhoneInput
label="Company Phone"
countryCode={{
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyLocation
)}
>
<FieldLabel>
Company Location
</FieldLabel>
<Input
placeholder="Addis Ababa"
{...register(
"companyLocation"
)}
/>
<FieldError
errors={[
errors.companyLocation,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.companyAddress
)}
>
<FieldLabel>
Company Address
</FieldLabel>
<Input
placeholder="Bole, Woreda 03"
{...register(
"companyAddress"
)}
/>
<FieldError
errors={[
errors.companyAddress,
]}
/>
</Field>
</div>
<div className="grid grid-cols-3 gap-4">
<Field
data-invalid={Boolean(
errors.tinNumber
)}
>
<FieldLabel>
TIN Number
</FieldLabel>
<Input
maxLength={10}
placeholder="1234567890"
{...register("tinNumber")}
/>
<FieldError
errors={[errors.tinNumber]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.vatNumber
)}
>
<FieldLabel>
VAT Number
</FieldLabel>
<Input
placeholder="VAT123456"
{...register("vatNumber")}
/>
<FieldError
errors={[errors.vatNumber]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.fanNumber
)}
>
<FieldLabel>
FAN Number
</FieldLabel>
<Input
maxLength={16}
placeholder="1234567890123456"
{...register("fanNumber")}
/>
<FieldError
errors={[errors.fanNumber]}
/>
</Field>
</div>
</>
)}
{/* PERSONNEL */}
{step === "personnel" && (
<>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.contactPersonName
)}
>
<FieldLabel>
Contact Person Name
</FieldLabel>
<Input
placeholder="Jane Smith"
{...register(
"contactPersonName"
)}
/>
<FieldError
errors={[
errors.contactPersonName,
]}
/>
</Field>
<PhoneInput
label="Contact Person Phone"
countryCode={{
...register(
"contactPersonPhoneCountryCode"
),
}}
phone={{
...register(
"contactPersonPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.contactPersonPhoneCountryCode
}
phoneError={
errors.contactPersonPhone
}
/>
</div>
</div>
<hr className="border-border" />
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
General Manager
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
className="col-span-2"
data-invalid={Boolean(
errors.generalManagerName
)}
>
<FieldLabel>
General Manager Name
</FieldLabel>
<Input
placeholder="Abebe Bikila"
{...register(
"generalManagerName"
)}
/>
<FieldError
errors={[
errors.generalManagerName,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.generalManagerEmail
)}
>
<FieldLabel>
General Manager Email
</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
{...register(
"generalManagerEmail"
)}
/>
<FieldError
errors={[
errors.generalManagerEmail,
]}
/>
</Field>
<PhoneInput
label="General Manager Phone"
countryCode={{
...register(
"generalManagerPhoneCountryCode"
),
}}
phone={{
...register(
"generalManagerPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.generalManagerPhoneCountryCode
}
phoneError={
errors.generalManagerPhone
}
/>
</div>
</div>
</>
)}
{/* POA */}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are
optional.
</p>
<Field>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative"
{...register("poaName")}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>
PoA Email
</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
{...register("poaEmail")}
/>
</Field>
<PhoneInput
label="PoA Phone"
countryCode={{
...register(
"poaPhoneCountryCode"
),
}}
phone={{
...register("poaPhone"),
placeholder: "912345678",
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>
PoA Location
</FieldLabel>
<Input
placeholder="City, Country"
{...register("poaLocation")}
/>
</Field>
<Field>
<FieldLabel>
PoA Address
</FieldLabel>
<Input
placeholder="Full Address"
{...register("poaAddress")}
/>
</Field>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "personal"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting}
>
{step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? (
<CheckCircle2 className="size-5" />
) : (
icon
)}
</div>
);
}

View File

@@ -0,0 +1,287 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
User,
Truck,
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type Step = "personal" | "transport";
const schema = z.object({
// PERSONAL
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phoneNumber: z.string().min(1),
phoneCountryCode: z.string().min(1),
// TRANSPORT
fanNumber: z.string().min(1),
tinNumber: z.string().min(1),
truckType: z.enum([
"Casoni",
"Truck Trailer",
"High Bed",
"Low Bed",
"Others",
]),
plateNumber: z.string().min(1),
plateNumber2: z.string().optional(),
vehicleModel: z.string().min(1),
yearOfManufacturing: z.string().min(1),
});
type FormData = z.infer<typeof schema>;
const stepFields: Record<Step, (keyof FormData)[]> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
transport: [
"fanNumber",
"tinNumber",
"truckType",
"plateNumber",
"plateNumber2",
"vehicleModel",
"yearOfManufacturing",
],
};
export default function TransporterOnboarding() {
const [step, setStep] = useState<Step>("personal");
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phoneCountryCode: "+251",
},
});
const truckType = watch("truckType");
const nextStep = async () => {
const valid = await trigger(stepFields[step]);
if (!valid) return;
if (step === "personal") setStep("transport");
else handleSubmit(onSubmit)();
};
const prevStep = () => {
if (step === "transport") setStep("personal");
};
const onSubmit = (data: FormData) => {
console.log("TRANSPORTER:", data);
};
return (
<>
{/* STEPPER */}
<div className="mb-8 lg:col-span-2">
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<User className="size-5" />}
active={step === "personal"}
completed={step !== "personal"}
/>
<StepIcon
icon={<Truck className="size-5" />}
active={step === "transport"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" && "Step 1 of 2 — Personal Information"}
{step === "transport" && "Step 2 of 2 — Transport Information"}
</p>
</div>
{/* FORM */}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.firstName}>
<FieldLabel>First Name</FieldLabel>
<Input {...register("firstName")} />
<FieldError errors={[errors.firstName]} />
</Field>
<Field data-invalid={!!errors.lastName}>
<FieldLabel>Last Name</FieldLabel>
<Input {...register("lastName")} />
<FieldError errors={[errors.lastName]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.email}>
<FieldLabel>Email</FieldLabel>
<Input type="email" {...register("email")} />
<FieldError errors={[errors.email]} />
</Field>
<PhoneInput
label="Phone Number"
countryCode={{ ...register("phoneCountryCode") }}
phone={{ ...register("phoneNumber") }}
countryCodeError={errors.phoneCountryCode}
phoneError={errors.phoneNumber}
/>
</div>
</>
)}
{/* TRANSPORT */}
{step === "transport" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.fanNumber}>
<FieldLabel>FAN Number</FieldLabel>
<Input {...register("fanNumber")} />
<FieldError errors={[errors.fanNumber]} />
</Field>
<Field data-invalid={!!errors.tinNumber}>
<FieldLabel>TIN Number</FieldLabel>
<Input {...register("tinNumber")} />
<FieldError errors={[errors.tinNumber]} />
</Field>
</div>
<Field data-invalid={!!errors.truckType}>
<FieldLabel>Truck Type</FieldLabel>
<select
className="w-full border rounded-md p-2 bg-background"
{...register("truckType")}
>
<option value="">Select type</option>
<option value="Casoni">Casoni</option>
<option value="Truck Trailer">Truck Trailer</option>
<option value="High Bed">High Bed</option>
<option value="Low Bed">Low Bed</option>
<option value="Others">Others</option>
</select>
<FieldError errors={[errors.truckType]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.plateNumber}>
<FieldLabel>Plate Number</FieldLabel>
<Input {...register("plateNumber")} />
<FieldError errors={[errors.plateNumber]} />
</Field>
{truckType === "Casoni" && (
<Field data-invalid={!!errors.plateNumber2}>
<FieldLabel>Second Plate Number (Casoni)</FieldLabel>
<Input {...register("plateNumber2")} />
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.vehicleModel}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input {...register("vehicleModel")} />
<FieldError errors={[errors.vehicleModel]} />
</Field>
<Field data-invalid={!!errors.yearOfManufacturing}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input {...register("yearOfManufacturing")} />
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep} disabled={step === "personal"}>
<ArrowLeft />
Back
</Button>
<Button type="button" onClick={nextStep} disabled={isSubmitting}>
{step === "transport" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
);
}

2
pnpm-lock.yaml generated
View File

@@ -172,7 +172,7 @@ importers:
specifier: workspace:*
version: link:../../../packages/ui-common
'@tanstack/react-query':
specifier: ^5.59.0
specifier: ^5.100.11
version: 5.100.11(react@19.2.6)
'@tria-plc/iamui-common':
specifier: 1.1.1