mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
rule engine UI, services and API integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
58
apps/edr-freight-web/backoffice/src/components/ui/button.tsx
Normal file
58
apps/edr-freight-web/backoffice/src/components/ui/button.tsx
Normal 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 };
|
||||
141
apps/edr-freight-web/backoffice/src/components/ui/dialog.tsx
Normal file
141
apps/edr-freight-web/backoffice/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
22
apps/edr-freight-web/backoffice/src/components/ui/input.tsx
Normal file
22
apps/edr-freight-web/backoffice/src/components/ui/input.tsx
Normal 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 };
|
||||
22
apps/edr-freight-web/backoffice/src/components/ui/label.tsx
Normal file
22
apps/edr-freight-web/backoffice/src/components/ui/label.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,4 @@
|
||||
export const FILE_SETTINGS = {
|
||||
CUSTOMER_REGISTRATION: "customer_registration",
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
124
apps/edr-freight-web/backoffice/src/constants/URLS.ts
Normal file
124
apps/edr-freight-web/backoffice/src/constants/URLS.ts
Normal 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}`,
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
126
apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts
Normal file
126
apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts
Normal 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 }),
|
||||
});
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
45
apps/edr-freight-web/backoffice/src/lib/currentCustomer.ts
Normal file
45
apps/edr-freight-web/backoffice/src/lib/currentCustomer.ts
Normal 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);
|
||||
}
|
||||
6
apps/edr-freight-web/backoffice/src/lib/utils.ts
Normal file
6
apps/edr-freight-web/backoffice/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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));
|
||||
5
apps/edr-freight-web/backoffice/src/types/apiResponse.ts
Normal file
5
apps/edr-freight-web/backoffice/src/types/apiResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
119
apps/edr-freight-web/backoffice/src/utils/endpoint.ts
Normal file
119
apps/edr-freight-web/backoffice/src/utils/endpoint.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user