complete rule engine and booking flow

This commit is contained in:
marshal
2026-05-30 10:28:51 +03:00
84 changed files with 4622 additions and 13074 deletions

View File

@@ -20,8 +20,9 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set
import { OtpModule } from './modules/otp/otp.module';
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
@Module({
imports: [
@@ -49,17 +50,20 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
OtpModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
],
providers: [EdrOrgSeeder],
providers: [EdrOrgSeeder, DemoUsersSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
) {}
private readonly demoUsersSeeder: DemoUsersSeeder,
) { }
async onApplicationBootstrap() {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
}
}

View File

@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard";
@ApiTags("demo-permissions")
@Controller()
export class DemoPermissionsController {
@Get("test_user1")
@ApiOperation({ summary: "Permission demo (can:demo:user1)" })
@UseGuards(PermissionGuard(["can:demo:user1"]))
testUser1() {
return { ok: true, permission: "can:demo:user1" };
}
@Get("test_user2")
@ApiOperation({ summary: "Permission demo (can:demo:user2)" })
@UseGuards(PermissionGuard(["can:demo:user2"]))
testUser2() {
return { ok: true, permission: "can:demo:user2" };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DemoPermissionsController } from "./demo-permissions.controller";
@Module({
controllers: [DemoPermissionsController],
})
export class DemoPermissionsModule {}

View File

@@ -0,0 +1,213 @@
import { Injectable, Logger } from "@nestjs/common";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
import {
Employee,
Organization,
Permission,
Role,
RolePermission,
User,
UserCredential,
UserRole,
} from "@tria-plc/iamapi-common";
import { DataSource } from "typeorm";
const SEED_FLAG = "SEED_DEMO_USERS";
const DEMO_ORG_KEY = "demo_iam";
const DEMO_ORG_NAME = { en: "Demo IAM" };
const DEMO_PERMISSIONS = [
{ key: "can:demo:user1", name: { en: "Can access demo user1" } },
{ key: "can:demo:user2", name: { en: "Can access demo user2" } },
];
const DEMO_ROLES = [
{ key: "demo_user1", name: { en: "Demo User1" } },
{ key: "demo_user2", name: { en: "Demo User2" } },
];
const DEMO_USERS = [
{
email: "user@gmail.com",
username: "user",
name: { en: "Demo User 1" },
roleKey: "demo_user1",
},
{
email: "user2@gmail.com",
username: "user2",
name: { en: "Demo User 2" },
roleKey: "demo_user2",
},
];
@Injectable()
export class DemoUsersSeeder {
private readonly logger = new Logger(DemoUsersSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
if (!shouldSeed) {
this.logger.log(`Skipping demo user seed because ${SEED_FLAG} is not enabled`);
return;
}
await this.dataSource.transaction(async (manager) => {
const organizationRepository = manager.getRepository(Organization);
const employeeRepository = manager.getRepository(Employee);
const permissionRepository = manager.getRepository(Permission);
const roleRepository = manager.getRepository(Role);
const rolePermissionRepository = manager.getRepository(RolePermission);
const userRepository = manager.getRepository(User);
const userCredentialRepository = manager.getRepository(UserCredential);
const userRoleRepository = manager.getRepository(UserRole);
await organizationRepository.upsert(
{
key: DEMO_ORG_KEY,
name: DEMO_ORG_NAME,
// status defaults to ACTIVE in IAM entity
isGovernmentOrganization: true,
},
{ conflictPaths: { key: true } },
);
const organization = await organizationRepository.findOne({
where: { key: DEMO_ORG_KEY },
select: { id: true, key: true },
});
if (!organization) {
throw new Error("demo_org_seed_failed");
}
await permissionRepository.upsert(DEMO_PERMISSIONS, {
conflictPaths: { key: true },
});
await roleRepository.upsert(DEMO_ROLES, {
conflictPaths: { key: true },
});
const roles = await roleRepository.find({ where: DEMO_ROLES.map((r) => ({ key: r.key })) });
const permissions = await permissionRepository.find({
where: DEMO_PERMISSIONS.map((p) => ({ key: p.key })),
});
const roleByKey = new Map(roles.map((r) => [r.key, r]));
const permissionByKey = new Map(permissions.map((p) => [p.key, p]));
const superAdminRole = await roleRepository.findOne({
where: { key: ERoleKey.SUPER_ADMIN },
select: { id: true, key: true },
});
const rolePermissionsToUpsert = [
{
roleId: roleByKey.get("demo_user1")!.id,
permissionId: permissionByKey.get("can:demo:user1")!.id,
},
{
roleId: roleByKey.get("demo_user2")!.id,
permissionId: permissionByKey.get("can:demo:user2")!.id,
},
...(superAdminRole
? ([
{
roleId: superAdminRole.id,
permissionId: permissionByKey.get("can:demo:user1")!.id,
},
{
roleId: superAdminRole.id,
permissionId: permissionByKey.get("can:demo:user2")!.id,
},
] as Array<{ roleId: string; permissionId: string }>)
: []),
];
await rolePermissionRepository.upsert(rolePermissionsToUpsert, {
conflictPaths: { roleId: true, permissionId: true },
});
const hashedPassword = await hashPassword("12345678");
for (const demoUser of DEMO_USERS) {
const existingUser = await userRepository.findOne({
where: { email: demoUser.email },
select: { id: true, email: true },
});
let user = existingUser;
if (!user) {
user = await userRepository.save(
userRepository.create({
email: demoUser.email,
username: demoUser.username,
name: demoUser.name,
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
}
// Ensure an active credential exists for login.
const activeCredentialExists = await userCredentialRepository.exists({
where: {
userId: user.id,
isActive: true,
},
});
if (!activeCredentialExists) {
await userCredentialRepository.insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
// Login query requires a current employee in an ACTIVE organization.
const employeeExists = await employeeRepository.exists({
where: {
userId: user.id,
organizationId: organization.id,
isCurrent: true,
},
});
if (!employeeExists) {
await employeeRepository.insert({
userId: user.id,
organizationId: organization.id,
isCurrent: true,
name: demoUser.name,
});
}
const role = roleByKey.get(demoUser.roleKey);
if (!role) {
throw new Error(`missing_role:${demoUser.roleKey}`);
}
await userRoleRepository.upsert(
{
userId: user.id,
roleId: role.id,
organizationId: organization.id,
},
{ conflictPaths: { userId: true, roleId: true } },
);
}
});
this.logger.log(
"Seeded demo users + permissions (user@gmail.com, user2@gmail.com; permissions can:demo:user1/can:demo:user2)",
);
}
}

View File

@@ -1,6 +1,6 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import { LayoutDashboard, Network } from "lucide-react";
import { LayoutDashboard, Network, Settings } from "lucide-react";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
@@ -10,39 +10,77 @@ 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 DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
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",
},
],
},
];
const hasPermission = (
user: ReturnType<typeof useAuth>["user"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
};
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 (
@@ -84,9 +122,15 @@ const App = () => {
<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="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
</Route>

View File

@@ -0,0 +1,221 @@
import { JSXElementConstructor, MouseEventHandler, ReactElement, ReactNode, SetStateAction, useState } from 'react';
export const ContractTypePage = () => {
const [expandedSections, setExpandedSections] = useState({
contractType: true,
serviceType: false,
cargoType: false
});
const [contractTypes, setContractTypes] = useState([
{ id: 1, name: 'Shipper', description: 'Company that sends the freight' },
{ id: 2, name: 'Consignee', description: 'Company that receives the freight' },
{ id: 3, name: 'Third Party', description: 'Company that is neither the shipper nor the consignee but is involved in the freight process' }
]);
const [serviceTypes, setServiceTypes] = useState([
{ id: 1, name: 'Standard', description: 'Regular shipping service' },
{ id: 2, name: 'Express', description: 'Fast delivery service' },
{ id: 3, name: 'Economy', description: 'Cost-effective shipping option' }
]);
const [cargoTypes, setCargoTypes] = useState([
{ id: 1, name: 'General Cargo', description: 'Standard packaged goods' },
{ id: 2, name: 'Temperature Controlled', description: 'Goods requiring specific temperature' },
{ id: 3, name: 'Hazardous Materials', description: 'Dangerous goods requiring special handling' }
]);
const [newContractType, setNewContractType] = useState({ name: '', description: '' });
const [newServiceType, setNewServiceType] = useState({ name: '', description: '' });
const [newCargoType, setNewCargoType] = useState({ name: '', description: '' });
const [showAddForms, setShowAddForms] = useState({
contractType: false,
serviceType: false,
cargoType: false
});
type SectionKey = 'contractType' | 'serviceType' | 'cargoType';
const toggleSection = (section: SectionKey) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const toggleAddForm = (section: SectionKey) => {
setShowAddForms(prev => ({
...prev,
[section]: !prev[section]
}));
};
const handleAddContractType = () => {
if (newContractType.name && newContractType.description) {
setContractTypes([
...contractTypes,
{ id: Date.now(), ...newContractType }
]);
setNewContractType({ name: '', description: '' });
toggleAddForm('contractType');
}
};
const handleAddServiceType = () => {
if (newServiceType.name && newServiceType.description) {
setServiceTypes([
...serviceTypes,
{ id: Date.now(), ...newServiceType }
]);
setNewServiceType({ name: '', description: '' });
toggleAddForm('serviceType');
}
};
const handleAddCargoType = () => {
if (newCargoType.name && newCargoType.description) {
setCargoTypes([
...cargoTypes,
{ id: Date.now(), ...newCargoType }
]);
setNewCargoType({ name: '', description: '' });
toggleAddForm('cargoType');
}
};
const handleDelete = (type: string, id: number) => {
if (type === 'contract') {
setContractTypes(contractTypes.filter(item => item.id !== id));
} else if (type === 'service') {
setServiceTypes(serviceTypes.filter(item => item.id !== id));
} else if (type === 'cargo') {
setCargoTypes(cargoTypes.filter(item => item.id !== id));
}
};
const handleEdit = (type: any, id: any) => {
// Implement edit functionality as needed
alert(`Edit ${type} type with id: ${id}`);
};
const renderTable = (title: string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined, types: any[], onAdd: { (): void; (): void; (): void; }, newItem: { name: any; description: any; }, setNewItem: { (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (arg0: any): void; }, showAddForm: boolean, typeKey: string, addHandler: MouseEventHandler<HTMLButtonElement> | undefined) => (
<div style={{ marginBottom: '20px' }}>
<div
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '10px',
backgroundColor: '#f0f0f0',
marginBottom: '10px'
}}
onClick={() => toggleSection(typeKey)}
>
<span style={{ marginRight: '10px', fontSize: '20px', color: '#138a49' }}>
{expandedSections[typeKey] ? '▼' : '▶'}
</span>
<h3 style={{ margin: 0 }}>{title}</h3>
</div>
{expandedSections[typeKey] && (
<div style={{ marginLeft: '20px' }}>
<button onClick={() => toggleAddForm(typeKey)}>
Add {title.replace(' Types', ' type')}
</button>
{showAddForm && (
<div style={{
marginTop: '10px',
marginBottom: '10px',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px'
}}>
<h4>Add New {title.replace(' Types', '')}</h4>
<div>
<input
type="text"
placeholder="Name"
value={newItem.name}
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
style={{ marginRight: '10px', padding: '5px' }}
/>
<input
type="text"
placeholder="Description"
value={newItem.description}
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
style={{ marginRight: '10px', padding: '5px' }}
/>
<button onClick={addHandler}>Save</button>
<button onClick={() => toggleAddForm(typeKey)} style={{ marginLeft: '5px' }}>Cancel</button>
</div>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '10px' }}>
<thead>
<tr style={{ backgroundColor: '#f2f2f2' }}>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>ID</th>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Name</th>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Description</th>
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{types.map((type) => (
<tr key={type.id}>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.id}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.name}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.description}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
<button onClick={() => handleEdit(typeKey, type.id)} style={{ marginRight: '5px' }}>Edit</button>
<button onClick={() => handleDelete(typeKey === 'contractType' ? 'contract' : typeKey === 'serviceType' ? 'service' : 'cargo', type.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
return (
<div>
{renderTable(
'Contract Types',
contractTypes,
handleAddContractType,
newContractType,
setNewContractType,
showAddForms.contractType,
'contractType',
handleAddContractType
)}
{renderTable(
'Service Types',
serviceTypes,
handleAddServiceType,
newServiceType,
setNewServiceType,
showAddForms.serviceType,
'serviceType',
handleAddServiceType
)}
{renderTable(
'Cargo Types',
cargoTypes,
handleAddCargoType,
newCargoType,
setNewCargoType,
showAddForms.cargoType,
'cargoType',
handleAddCargoType
)}
</div>
);
};

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { api } from "@/auth/http";
const DemoUser1Page = () => {
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get("/test_user1");
if (cancelled) return;
setData(response.data);
} catch (e: any) {
if (cancelled) return;
const message =
e?.response?.data?.message ||
e?.response?.data?.error ||
e?.message ||
"Request failed";
setError(String(message));
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => {
cancelled = true;
};
}, []);
return (
<div className="p-6">
<div className="rounded-2xl border border-border bg-card p-6">
<h1 className="text-lg font-semibold text-foreground">User1 Demo</h1>
<p className="mt-1 text-sm text-muted-foreground">
Calls <code className="font-mono">GET /api/test_user1</code> (requires{' '}
<code className="font-mono">can:demo:user1</code>).
</p>
<div className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{!loading && !error ? (
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
{JSON.stringify(data, null, 2)}
</pre>
) : null}
</div>
</div>
</div>
);
};
export default DemoUser1Page;

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { api } from "@/auth/http";
const DemoUser2Page = () => {
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get("/test_user2");
if (cancelled) return;
setData(response.data);
} catch (e: any) {
if (cancelled) return;
const message =
e?.response?.data?.message ||
e?.response?.data?.error ||
e?.message ||
"Request failed";
setError(String(message));
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => {
cancelled = true;
};
}, []);
return (
<div className="p-6">
<div className="rounded-2xl border border-border bg-card p-6">
<h1 className="text-lg font-semibold text-foreground">User2 Demo</h1>
<p className="mt-1 text-sm text-muted-foreground">
Calls <code className="font-mono">GET /api/test_user2</code> (requires{' '}
<code className="font-mono">can:demo:user2</code>).
</p>
<div className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{!loading && !error ? (
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
{JSON.stringify(data, null, 2)}
</pre>
) : null}
</div>
</div>
</div>
);
};
export default DemoUser2Page;

View File

@@ -0,0 +1,14 @@
import { ContractTypePage, } from "@/components/ruleEngine/ContractType";
export const RuleEnginePage = () => {
return <div>
<h3>
Rule Engine Page
</h3>
<div>
<ContractTypePage />
</div>
</div>;
};

View File

@@ -7,109 +7,79 @@ import {
} from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import {
LayoutDashboard,
Users,
CalendarCheck,
Package,
MapPin,
Train,
Receipt,
FileText,
Settings,
UserCircle,
FileUp,
MapPinned,
Home,
Loader2,
User,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
import useAuth from "./hooks/useAuth";
import ProfilePage from "./pages/ProfilePage";
import MyPortalPage from "./pages/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
import OnboardingPage from "./pages/accounts/OnboardingPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import MyBookings from "./pages/bookings/MyBookings";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import TrainsPage from "./pages/trains/TrainsPage";
import DashboardPage from "./pages/dashboard/DashboardPage";
import {
IamLoginPage,
LoadingScreen,
useAuth,
useAuthUser,
} from "@tria-plc/iamui-common";
import CustomersPage from "./pages/customers/CustomersPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import NewCustomerPage from "./pages/customers/NewCustomerPage";
import DocumentsPage from "./pages/documents/DocumentsPage";
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
import MyPortalPage from "./pages/portal/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import Station from "./components/stations/Station";
import { useEffect } from "react";
const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/", icon: <UserCircle /> },
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Home", href: "/", icon: <Home /> },
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
{ label: "Trains", href: "/trains", icon: <Train /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
{
label: "File Upload Settings",
href: "/admin/file-uploads",
icon: <FileUp />,
},
{ label: "Profile", href: "/profile", icon: <User /> },
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading } = useAuth();
const { logout } = useAuthUser();
const { user, isPending, logout, customer, customerQuery } = useAuth();
if (loading) {
return <LoadingScreen />;
console.log({ customer, isPending, user });
useEffect(() => {
if (!user) return;
// if (!user.hasSetPassword) navigate("/set-password");
}, [user]);
if (isPending) {
return (
<div className="flex items-center justify-center h-screen">
<Loader2 className="animate-spin text-primary" />
</div>
);
}
if (user) {
if (!user) {
return (
<Routes>
<Route path="/" element={<EDRFreightLandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/auth" element={<IamLoginPage />} />
{/* <Route path="*" element={<Navigate to="/auth" replace />} /> */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
if (user && !customer && !customerQuery.isPending) {
return <OnboardingPage />;
}
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const handleLogout = () => {
logout();
[
"auth-token",
"refresh-token",
"auth-user",
"current-position-id",
"selected-position-id",
].forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
});
localStorage.clear();
window.location.replace("/auth");
};
return (
<DashboardLayout
title="EDR Freight"
@@ -119,31 +89,16 @@ const App = () => {
enableThemeToggle
userName={displayName}
userEmail={userEmail}
onLogout={handleLogout}
onLogout={logout}
>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/" element={<MyPortalPage />} />
<Route path="/bookings" element={<MyBookings />} />
<Route path="/admin/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
<Route path="/new-customer" element={<NewCustomerPage />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route path="/consignments" element={<ConsignmentsPage />} />
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/stations" element={<Station />} />
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
<Route
path="/admin/file-uploads"
element={<FileUploadSettingsPage />}
/>
<Route path="/user-management" element={<Navigate to="/" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from "react";
import { ShieldCheck, Train } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AuthLayoutProps {
children: ReactNode;
parentClassName?: string;
contentClassName?: string;
left: {
badge: string;
title: string;
description: string;
features: string[];
stats: {
label: string;
value: string;
footer: string;
progress: string;
};
};
}
export default function AuthLayout({
children,
parentClassName,
contentClassName,
left,
}: AuthLayoutProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
<div className="relative hidden overflow-hidden bg-primary p-8 px-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-xl font-bold">EDR Freight</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics Platform
</p>
</div>
</div>
<div className="mt-16 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
{left.badge}
</div>
<h2 className="mt-6 text-4xl font-bold leading-tight tracking-tight">
{left.title}
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
{left.description}
</p>
</div>
<div className="mt-14 grid gap-5">
{left.features.map((item) => (
<div key={item} className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">{item}</span>
</div>
))}
</div>
</div>
</div>
<div
className={cn(
"flex items-center justify-center p-6 md:p-10",
contentClassName,
)}
>
<div className="w-full max-w-lg">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">EDR Freight</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics Platform
</p>
</div>
</div>
<div>{children}</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Field, FieldLabel, FieldError, Input } from "@edr/ui-common";
interface PhoneInputProps {
disabled?: boolean;
countryCode?: React.ComponentProps<typeof Input>;
phone?: React.ComponentProps<typeof Input>;
countryCodeError?: { message?: string };
phoneError?: { message?: string };
label?: string;
}
export default function PhoneInput({
disabled,
countryCode: countryCodeProps,
phone: phoneProps,
countryCodeError,
phoneError,
label = "Phone Number",
}: PhoneInputProps) {
return (
<Field data-invalid={Boolean(countryCodeError || phoneError)}>
<FieldLabel>{label}</FieldLabel>
<div className="flex gap-2">
<Input
type="text"
disabled={disabled}
className="w-20"
aria-invalid={Boolean(countryCodeError)}
{...countryCodeProps}
/>
<Input
type="tel"
placeholder="912345678"
disabled={disabled}
className="flex-1"
aria-invalid={Boolean(phoneError)}
{...phoneProps}
/>
</div>
<FieldError errors={[countryCodeError, phoneError]} />
</Field>
);
}

View File

@@ -1,228 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
CircleOff,
Loader2,
MapPin,
Search,
TrainFront,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import type { DropdownOption } from "@/types/dropdownSettings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DataTable,
DataTableFooter,
Input,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
const STATION_DROPDOWN_CODE = "stations_ter";
export default function Station() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useDropdownSettingByCode(
STATION_DROPDOWN_CODE,
);
const stations = useMemo<DropdownOption[]>(
() => [...(data?.children ?? [])].sort((a, b) => a.order - b.order),
[data?.children],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return stations;
return stations.filter(
(station) =>
station.label.toLowerCase().includes(q) ||
station.value.toLowerCase().includes(q) ||
(station.note ?? "").toLowerCase().includes(q),
);
}, [query, stations]);
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),
[end, filtered, start],
);
const activeCount = stations.filter((station) => !station.disabled).length;
const disabledCount = stations.length - activeCount;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownOption>[] = [
{
id: "station",
header: "Station",
cell: ({ row }) => {
const station = 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">
<MapPin />
</div>
<div>
<p className="font-medium text-slate-900">{station.label}</p>
<p className="text-xs text-slate-500">
{station.note ?? "No station note"}
</p>
</div>
</div>
);
},
},
{
id: "value",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.value}
</span>
),
},
{
accessorKey: "order",
header: "Order",
},
{
id: "status",
header: "Status",
cell: ({ row }) =>
row.original.disabled ? (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
<CircleOff className="h-3 w-3" />
Disabled
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<TrainFront className="h-3 w-3" />
Active
</span>
),
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Stations" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Stations
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Station options loaded from dropdown code{" "}
<span className="font-mono">stations_ter</span>.
</p>
</div>
<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={(event) => {
setQuery(event.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search stations..."
className="pl-8!"
/>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StationStat label="Stations" value={stations.length} />
<StationStat label="Active" value={activeCount} />
<StationStat label="Disabled" value={disabledCount} />
</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 stations.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="border-b">
<CardTitle>Station List</CardTitle>
<CardDescription>
All configured freight stations from the dropdown service.
</CardDescription>
</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 stations...
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function StationStat({ label, value }: { label: string; value: number }) {
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">
<MapPin />
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,21 +1,25 @@
export const URL_CONSTANTS = {
AUTH: {
LOGIN: "/auth/login",
REGISTER: "/auth/register",
REFRESH_TOKEN: "/auth/refresh-token",
LOGOUT: "/auth/logout",
LOGIN: "/api/auth/login",
REGISTER: "/api/auth/register",
REFRESH_TOKEN: "/api/auth/refresh-token",
LOGOUT: "/api/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}`,
SIGN_UP: "/api/auth/signup",
SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me"
ME: "/api/auth/me",
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
},
ROLES: {
BASE: "/roles",
BY_ID: (id: string | number) => `/roles/${id}`,
@@ -68,11 +72,11 @@ export const URL_CONSTANTS = {
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}`
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
},
BOOKINGS: {
@@ -81,9 +85,4 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
}
};
};

View File

@@ -0,0 +1,185 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/services/api";
import type {
LoginPayload,
LoginResponse,
SignupPayload,
SignupResponse,
OtpResponse,
} from "@/types/auth";
import type { Result } from "@/utils/result";
import { extractApiError } from "@/utils/result";
function setCookie(name: string, value: string, days: number) {
const expires = new Date();
expires.setDate(expires.getDate() + days);
document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`;
}
function getCookie(name: string): string | undefined {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.split("=")[1];
}
const useAuth = () => {
const queryClient = useQueryClient();
const authQuery = useQuery(
api.auth.getMyInfo.queryOptions({
enabled: !!getCookie("auth-token"),
retry: false,
staleTime: 10 * 60 * 1000,
}),
);
const customerQuery = useQuery(
api.customers.getByUserId.queryOptions({
input: { id: authQuery.data?.id ?? "" },
enabled: !!authQuery.data?.id,
retry: false,
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
}),
);
const hasToken = !!getCookie("auth-token");
const isPending = authQuery.isPending && hasToken;
const login = async (
payload: LoginPayload,
): Promise<Result<LoginResponse>> => {
try {
const res = await api.auth.login.call(payload);
setCookie("auth-token", res.token, 7);
setCookie("refresh-token", res.refreshToken, 7);
await authQuery.refetch();
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const signup = async (
payload: SignupPayload,
): Promise<Result<SignupResponse>> => {
try {
const res = await api.auth.createUser.call(payload);
setCookie("auth-token", res.token, 7);
setCookie("refresh-token", res.refreshToken, 7);
await authQuery.refetch();
const otpCode = res.otp?.split(" ")?.[6] ?? "";
localStorage.setItem("otp", otpCode);
localStorage.setItem("otp-phone", payload.phoneNumber);
localStorage.setItem("otp-email", payload.email);
api.auth.sendOTP
.call({ phone: payload.phoneNumber, otp: otpCode })
.catch(() => { });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const setPassword = async (data: {
newPassword: string;
confirmPassword: string;
}): Promise<Result<void>> => {
try {
const userId = authQuery.data?.id ?? "";
const email = localStorage.getItem("otp-email") ?? "";
const verificationCode = localStorage.getItem("otp") ?? "";
await api.auth.setPassword.call({
newPassword: data.newPassword,
confirmPassword: data.confirmPassword,
userId,
email,
verificationCode,
});
["userId", "otp", "otp-phone", "otp-email"].forEach((k) =>
localStorage.removeItem(k),
);
await queryClient.invalidateQueries({
queryKey: api.auth.getMyInfo.queryKey(),
});
return { success: true, data: undefined };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const verifyOTP = async (otp: string): Promise<Result<OtpResponse>> => {
try {
const phone = localStorage.getItem("otp-phone") ?? "";
const res = await api.auth.verifyOTP.call({ phone, otp });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const sendOTP = async (otp: string): Promise<Result<OtpResponse>> => {
try {
const phone = localStorage.getItem("otp-phone") ?? "";
const res = await api.auth.sendOTP.call({ phone, otp });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const generateVerificationCode = async (
type: string,
): Promise<Result<string>> => {
try {
const email = localStorage.getItem("otp-email") ?? "";
const phoneNumber = localStorage.getItem("otp-phone") ?? "";
const res = await api.auth.generateVerificationCode.call({
email,
phoneNumber,
type,
});
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const logout = async () => {
try {
await api.auth.logout.call();
} catch {
// proceed with client-side cleanup even if server call fails
}
[
"auth-token",
"refresh-token",
"auth-user",
"current-position-id",
"selected-position-id",
].forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
});
localStorage.clear();
queryClient.clear();
window.location.href = "/login";
};
return {
isPending,
user: authQuery.data ?? null,
customer: customerQuery.data ?? null,
login,
signup,
setPassword,
verifyOTP,
sendOTP,
generateVerificationCode,
logout,
authQuery,
customerQuery,
};
};
export default useAuth;

View File

@@ -2,18 +2,11 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "@tria-plc/iamui-common/styles.css";
import "@edr/ui-common/styles.css";
import "../index.css";
import "@edr/ui-common/theme.css";
import App from "./App";
import {
AuthProvider,
configureIam,
UserProvider,
axiosInstance,
} from "@tria-plc/iamui-common";
// Purge cookies that were stored as the literal string "undefined" before the
// envelope interceptor fix. Without this, stale sessions would keep sending
@@ -29,36 +22,6 @@ import {
});
const queryClient = new QueryClient();
window.__IAM_CONFIG__ = {
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
postLoginPath: "/",
};
// Unwrap the StandardResponse envelope ({ success, data, timestamp }) that the
// freight API's ResponseTransformInterceptor adds to every response, so that
// iamui-common can read response.data.token / response.data fields as expected.
axiosInstance.interceptors.response.use((response) => {
if (
response.data &&
typeof response.data === "object" &&
"success" in response.data &&
"data" in response.data
) {
response.data = response.data.data;
}
return response;
});
window.__USER_MANAGEMENT_BRANDING__ = {
organizationName: "EDR Platform",
appName: "EDR Portal",
moduleBasePath: "/user-management",
backToAppPath: "/",
backToAppLabel: "Back to dashboard",
};
configureIam({
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
});
const rootElement = document.getElementById("root");
@@ -70,11 +33,7 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<UserProvider>
<App />
</UserProvider>
</AuthProvider>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,

View File

@@ -1,3 +1,4 @@
import { Link } from "react-router-dom";
import {
ArrowRight,
BarChart3,
@@ -85,9 +86,7 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">
EDR Freight
</h1>
<h1 className="text-xl font-bold tracking-tight">EDR Freight</h1>
<p className="text-sm text-muted-foreground">
Rail Logistics Platform
@@ -119,20 +118,20 @@ export default function EDRFreightLandingPage() {
</nav>
<div className="flex items-center gap-3">
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="hidden rounded-2xl border border-border bg-card px-5 py-2.5 text-sm font-medium transition hover:bg-accent md:block"
>
Login
</a>
</Link>
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="hidden items-center gap-2 rounded-2xl bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-lg transition hover:opacity-90 md:flex"
>
Get Started
<ArrowRight className="size-4" />
</a>
</Link>
<button className="rounded-2xl border border-border p-2 md:hidden">
<Menu className="size-5" />
@@ -145,7 +144,7 @@ export default function EDRFreightLandingPage() {
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(16,185,129,0.16),transparent_35%)]" />
<div className="mx-auto grid max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
<div className="mx-auto grid relative z-10 max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border bg-accent px-4 py-2 text-sm font-medium text-primary shadow-sm">
<span className="size-2 rounded-full bg-primary" />
@@ -157,26 +156,26 @@ export default function EDRFreightLandingPage() {
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
EDR Freight enables logistics companies and railway operators
to manage shipments, monitor freight corridors, optimize train
EDR Freight enables logistics companies and railway operators to
manage shipments, monitor freight corridors, optimize train
operations, and streamline enterprise logistics workflows.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90"
>
Get Started
<ArrowRight className="size-5" />
</a>
</Link>
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="rounded-2xl border border-border bg-card px-6 py-3 font-semibold transition hover:bg-accent"
>
Login
</a>
</Link>
</div>
<div className="mt-12 flex flex-wrap gap-6">
@@ -206,9 +205,7 @@ export default function EDRFreightLandingPage() {
Freight Operations
</p>
<h3 className="mt-2 text-3xl font-bold">
Live Statistics
</h3>
<h3 className="mt-2 text-3xl font-bold">Live Statistics</h3>
</div>
<div className="rounded-2xl bg-primary/10 p-4 text-primary">
@@ -229,9 +226,7 @@ export default function EDRFreightLandingPage() {
<Icon className="size-6" />
</div>
<h4 className="text-3xl font-black">
{item.value}
</h4>
<h4 className="text-3xl font-black">{item.value}</h4>
<p className="mt-1 text-sm text-muted-foreground">
{item.label}
@@ -271,9 +266,7 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<h4 className="text-lg font-bold">
16 Trains Active
</h4>
<h4 className="text-lg font-bold">16 Trains Active</h4>
<p className="text-sm text-muted-foreground">
Across all freight corridors
@@ -302,8 +295,8 @@ export default function EDRFreightLandingPage() {
<p className="mt-4 text-lg leading-8 text-muted-foreground">
Centralized railway freight operations with live shipment
visibility, operational monitoring, customer management,
and intelligent logistics insights.
visibility, operational monitoring, customer management, and
intelligent logistics insights.
</p>
</div>
@@ -320,9 +313,7 @@ export default function EDRFreightLandingPage() {
<Icon className="size-7" />
</div>
<h3 className="mt-6 text-xl font-bold">
{feature.title}
</h3>
<h3 className="mt-6 text-xl font-bold">{feature.title}</h3>
<p className="mt-3 leading-7 text-muted-foreground">
{feature.description}
@@ -444,10 +435,7 @@ export default function EDRFreightLandingPage() {
</section>
{/* Contact */}
<section
id="contact"
className="border-t border-border bg-card/40 py-24"
>
<section id="contact" className="border-t border-border bg-card/40 py-24">
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-12 lg:grid-cols-2">
<div>
@@ -460,8 +448,8 @@ export default function EDRFreightLandingPage() {
</h2>
<p className="mt-5 text-lg leading-8 text-muted-foreground">
Contact EDR Freight for partnership opportunities,
enterprise onboarding, or logistics support.
Contact EDR Freight for partnership opportunities, enterprise
onboarding, or logistics support.
</p>
<div className="mt-10 space-y-5">
@@ -485,9 +473,7 @@ export default function EDRFreightLandingPage() {
<div>
<p className="font-semibold">Phone</p>
<p className="text-muted-foreground">
+251 11 000 0000
</p>
<p className="text-muted-foreground">+251 11 000 0000</p>
</div>
</div>
@@ -508,9 +494,7 @@ export default function EDRFreightLandingPage() {
{/* Contact Form */}
<div className="rounded-[32px] border border-border bg-card p-8 shadow-xl">
<h3 className="text-2xl font-bold">
Send us a message
</h3>
<h3 className="text-2xl font-bold">Send us a message</h3>
<p className="mt-2 text-muted-foreground">
Well get back to you as soon as possible.
@@ -559,26 +543,26 @@ export default function EDRFreightLandingPage() {
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Centralize logistics workflows, optimize freight movement,
and gain real-time operational visibility across all
railway corridors.
Centralize logistics workflows, optimize freight movement, and
gain real-time operational visibility across all railway
corridors.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="flex items-center gap-2 rounded-2xl bg-white px-7 py-3 font-semibold text-primary shadow-lg transition hover:opacity-90"
>
Create Account
<ArrowRight className="size-5" />
</a>
</Link>
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="rounded-2xl border border-white/20 bg-white/10 px-7 py-3 font-semibold backdrop-blur transition hover:bg-white/20"
>
Sign In
</a>
</Link>
</div>
</div>
</div>
@@ -594,19 +578,15 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<p className="font-semibold text-foreground">
EDR Freight
</p>
<p className="font-semibold text-foreground">EDR Freight</p>
<p>Modern railway logistics management platform</p>
</div>
</div>
<div>
© 2026 EDR Freight. All rights reserved.
</div>
<div>© 2026 EDR Freight. All rights reserved.</div>
</div>
</footer>
</div>
);
}
}

View File

@@ -0,0 +1,360 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
DollarSign,
Eye,
Mail,
MapPin,
Package,
Phone,
Plus,
Receipt,
Truck,
} from "lucide-react";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@edr/ui-common";
export default function MyPortalPage() {
const me = useMemo(() => getCurrentCustomer(), []);
const myBookings = useMemo(() => getMyBookings(), []);
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const activeBookings = myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
);
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const totalSpent = myInvoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const recentBookings = [...myBookings].slice(0, 5);
const recentInvoices = [...myInvoices].slice(0, 4);
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-7xl space-y-6">
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
{me.company.charAt(0)}
</div>
<div>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">{me.name}</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<Building2 className="h-4 w-4" />
{me.company}
<span className="text-white/40">·</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
{me.customerType}
</span>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link to="/bookings/new">
<Button
variant="secondary"
className="bg-white text-[#10B981] hover:bg-slate-100"
>
<Plus className="h-4 w-4" />
New Booking
</Button>
</Link>
<Link to="/tracking">
<Button
variant="outline"
className="border-white/40 text-white hover:bg-white/10"
>
<Truck className="h-4 w-4" />
Track Shipment
</Button>
</Link>
</div>
</div>
</div>
{/* Active Shipments */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Active Shipments</CardTitle>
<CardDescription>
Live tracking for your in-flight cargo
</CardDescription>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{activeShipments.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No shipments currently in transit.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeShipments.slice(0, 4).map((shipment) => (
<div
key={shipment.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-slate-900">
{shipment.reference}
</span>
<ShipmentBadge status={shipment.status} />
</div>
<p className="mt-1 text-sm text-slate-700">
{shipment.originStation}
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
{shipment.destinationStation}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3 text-primary" />
{shipment.currentLocation}
</span>
<span>ETA {shipment.eta}</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent bookings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Bookings</CardTitle>
<CardDescription>Your latest freight requests</CardDescription>
</div>
<Link
to="/bookings"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent className="px-0">
{recentBookings.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
You haven't booked any freight yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="px-6 py-2 font-medium">Reference</th>
<th className="py-2 font-medium">Route</th>
<th className="py-2 font-medium">Cargo</th>
<th className="py-2 font-medium">Status</th>
<th className="px-6 py-2 text-right font-medium">
Action
</th>
</tr>
</thead>
<tbody>
{recentBookings.map((booking) => (
<tr
key={booking.id}
className="border-t border-slate-100 transition hover:bg-primary/5"
>
<td className="px-6 py-3 font-medium text-slate-900">
{booking.reference}
</td>
<td className="py-3 text-slate-700">
{booking.originStation} {booking.destinationStation}
</td>
<td className="py-3 text-slate-700">
{booking.cargoType}
</td>
<td className="py-3">
<BookingBadge status={booking.status} />
</td>
<td className="px-6 py-3 text-right">
<Link
to={`/bookings/${booking.id}`}
aria-label="View booking"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-primary/10 hover:text-primary"
>
<Eye className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Invoices</CardTitle>
<CardDescription>
{outstandingInvoices.length} outstanding · {myInvoices.length}{" "}
total
</CardDescription>
</div>
<Link
to="/billing"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No invoices yet.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<Receipt className="h-4 w-4 text-primary" />
<InvoiceBadge status={invoice.status} />
</div>
<p className="mt-0.5 pt-2 text-lg font-bold text-slate-900">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
Due {invoice.dueDate}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function ProfileRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Delayed: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function BookingBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,326 @@
import { useMemo } from "react";
import {
User,
Building2,
Phone,
Mail,
MapPin,
ShieldCheck,
Briefcase,
UserCheck,
Building,
Globe,
Fingerprint,
FileCheck,
Settings2,
ExternalLink,
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardAction,
Badge,
Separator,
SmartFileInput,
Button,
} from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import { cn } from "@/lib/utils";
export default function ProfilePage() {
const { user, customer, isPending } = useAuth();
const documentSettings = useMemo<IFileUploadSetting>(() => ({
id: "profile-docs",
code: "customer_documents",
label: "Customer Documents",
entity: "customer",
createdAt: new Date(),
updatedAt: new Date(),
fields: [
{
id: "doc-tin",
settingId: "profile-docs",
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 1,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-license",
settingId: "profile-docs",
fileKey: "business_license",
fileLabel: "Business/Investment License",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 2,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-reg",
settingId: "profile-docs",
fileKey: "registration_certificate",
fileLabel: "Business Registration Certificate",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 3,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-id",
settingId: "profile-docs",
fileKey: "national_id",
fileLabel: "National ID",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 4,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-poa",
settingId: "profile-docs",
fileKey: "power_of_attorney",
fileLabel: "Power of Attorney",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 5,
createdAt: new Date(),
updatedAt: new Date(),
},
],
}), []);
if (isPending) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
);
}
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="flex flex-col gap-8">
{/* Header Section */}
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-6">
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
<User className="size-12" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-black tracking-tight text-foreground">
{displayName}
</h1>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
</Badge>
</div>
<p className="flex items-center gap-2 font-medium text-muted-foreground">
<Building className="size-4" />
{customer?.companyName || "No Company Linked"}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline">
<Settings2 data-icon="inline-start" />
Account Settings
</Button>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column - Personal & Company Info */}
<div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Personal Details Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Personal Details
</CardTitle>
<CardDescription>Your account contact information</CardDescription>
<CardAction>
<Button variant="ghost" size="icon">
<ExternalLink />
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Mail />} label="Email Address" value={user?.email} />
<InfoItem icon={<Phone />} label="Phone Number" value={user?.phoneNumber} />
<InfoItem icon={<UserCheck />} label="Username" value={user?.username} />
</CardContent>
</Card>
{/* Company Details Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={customer?.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={customer?.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={customer?.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={customer?.fanNumber} />
</CardContent>
</Card>
</div>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={customer?.contactPersonName} />
<InfoItem label="Phone" value={customer?.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={customer?.generalManagerName} />
<InfoItem label="Email" value={customer?.generalManagerEmail} />
<InfoItem label="Phone" value={customer?.generalManagerPhone} />
</div>
</div>
</CardContent>
</Card>
{/* Power of Attorney Section (Conditional) */}
{customer?.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={customer.poaName} />
<InfoItem label="PoA Email" value={customer.poaEmail} />
<InfoItem label="PoA Phone" value={customer.poaPhone} />
<InfoItem label="PoA Location" value={customer.poaLocation} />
</CardContent>
</Card>
)}
</div>
{/* Right Column - Documents */}
<div className="flex flex-col gap-8">
<Card className="border-primary/20 bg-primary/[0.02] shadow-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileCheck className="size-6 text-primary" />
Documents
</CardTitle>
<CardDescription>Manage required business documents</CardDescription>
</CardHeader>
<CardContent className="px-6 pb-6 pt-0">
<SmartFileInput
file={documentSettings}
variant="minimal"
className="flex flex-col gap-4"
/>
</CardContent>
</Card>
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<Button variant="secondary" size="sm">
Contact Support
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<div className="flex items-start gap-3">
{icon && (
<div className="mt-1 text-muted-foreground [&_svg]:size-4">
{icon}
</div>
)}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{label}
</p>
<p className="text-sm font-bold text-foreground">
{value || "—"}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
import PhoneInput from "@/components/auth/PhoneInput";
type LoginMethod = "email" | "phone";
export default function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState("");
const [countryCode, setCountryCode] = useState("+251");
const [phoneNumber, setPhoneNumber] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const loginId = method === "email"
? identifier
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
const result = await login({ email: loginId, password });
if (result.success) {
navigate("/");
} else {
setError(result.error.message);
}
} catch {
setError("An unexpected error occurred");
} finally {
setLoading(false);
}
};
return (
<AuthLayout
left={{
badge: "Welcome Back",
title: "Sign in to your freight operations account",
description:
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: {
label: "Active Corridors",
value: "24+",
footer: "Operational",
progress: "w-[95%]",
},
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Mail className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Welcome back</h2>
<p className="mt-2 text-base text-muted-foreground">
Enter your credentials to access your portal
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex gap-2 rounded-lg bg-muted p-1">
<Button
type="button"
variant={"ghost"}
size="sm"
onClick={() => setMethod("email")}
className={`flex-1 hover:bg-background/40! ${method === "email" ? "bg-background shadow border" : ""}`}
>
<Mail data-icon="inline-start" />
Email
</Button>
<Button
type="button"
variant={"ghost"}
size="sm"
onClick={() => setMethod("phone")}
className={`flex-1 hover:bg-background/40! ${method === "phone" ? "bg-background shadow border" : ""}`}
>
<Phone data-icon="inline-start" />
Phone
</Button>
</div>
<FieldGroup>
{method === "email" ? (
<Field>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="name@company.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
disabled={loading}
/>
</Field>
) : (
<PhoneInput
disabled={loading}
countryCode={{
value: countryCode,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCountryCode(e.target.value),
}}
phone={{
value: phoneNumber,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setPhoneNumber(e.target.value),
}}
/>
)}
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Password</FieldLabel>
<Button
type="button"
variant="link"
size="xs"
className="h-auto p-0"
>
Forgot password?
</Button>
</div>
<Input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</Field>
</FieldGroup>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
<Button type="submit" disabled={loading} size="lg" className="w-full">
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Signing in...
</>
) : (
<>
Sign In
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Button
type="button"
variant="link"
size="sm"
onClick={() => navigate("/signup")}
className="h-auto p-0 font-semibold"
>
Create an account
</Button>
</p>
</form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,520 @@
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";
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 OnboardingPage() {
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%]",
},
}}
>
<div className="mb-8 lg:col-span-2">
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step !== "company"}
/>
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && "Step 1 of 3 — Company Information"}
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
{step === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location</FieldLabel>
<Input
placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Bole Subcity, Woreda 03"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.vatNumber)}>
<FieldLabel>VAT Number</FieldLabel>
<Input
placeholder="VAT-12345"
aria-invalid={Boolean(errors.vatNumber)}
maxLength={10}
{...register("vatNumber")}
/>
<FieldError errors={[errors.vatNumber]} />
</Field>
</div>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</>
)}
{step === "personnel" && (
<>
<p className="text-sm text-muted-foreground">
Personal details are pulled from your account. Contact and
management info is collected below.
</p>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
</h3>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<PhoneInput
countryCode={{
...register("contactPersonPhoneCountryCode"),
}}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
/>
</div>
</div>
<hr className="border-border" />
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
General Manager
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
className="col-span-2"
data-invalid={Boolean(errors.generalManagerName)}
>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Abebe Bikila"
aria-invalid={Boolean(errors.generalManagerName)}
{...register("generalManagerName")}
/>
<FieldError errors={[errors.generalManagerName]} />
</Field>
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput
countryCode={{
...register("generalManagerPhoneCountryCode"),
}}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
/>
</div>
</div>
</>
)}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Skip if not applicable.
</p>
<Field>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
{...register("poaName")}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
{...register("poaEmail")}
/>
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
{...register("poaLocation")}
/>
</Field>
<Field>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
{...register("poaAddress")}
/>
</Field>
</div>
</>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "company"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={createCustomerMutation.isPending}
>
{createCustomerMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</AuthLayout>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
);
}

View File

@@ -1,418 +1,217 @@
import { setPassword } from "@/services/account";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowRight,
LockKeyhole,
ShieldCheck,
Train,
Eye,
EyeOff,
} from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ArrowRight, Check, Eye, EyeOff, LockKeyhole, Loader2, X } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import { cn } from "@/lib/utils";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
const passwordSchema = z
.object({
password: z
.string()
.min(
8,
"Password must be at least 8 characters"
),
confirmPassword: z
.string()
.min(
8,
"Confirm password is required"
),
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.refine(
(data) =>
data.password ===
data.confirmPassword,
{
message:
"Passwords do not match",
path: ["confirmPassword"],
}
);
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type FormData = z.infer<
typeof passwordSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
type FormData = z.infer<typeof passwordSchema>;
export default function SetPasswordPage() {
const [
showPassword,
setShowPassword,
] = useState(false);
const [
showConfirmPassword,
setShowConfirmPassword,
] = useState(false);
const navigate = useNavigate();
const { setPassword } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
watch,
formState: { errors },
reset,
} = useForm<FormData>({
resolver:
zodResolver(passwordSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
resolver: zodResolver(passwordSchema),
defaultValues: { password: "", confirmPassword: "" },
});
const naviagte = useNavigate();
const password = watch("password");
// ---------------------------------------------------------------------------
// Mutation
// ---------------------------------------------------------------------------
const requirements = useMemo(
() => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
[password],
);
const setPasswordMutation =
useMutation({
mutationFn: async (
data: FormData
) => setPassword({
newPassword: data?.password,
confirmPassword: data?.confirmPassword,
userId: localStorage.getItem("userId"),
email: localStorage.getItem("otp-email"),
verificationCode: localStorage.getItem("otp"),
}),
const allMet = requirements.every((r) => r.met);
onSuccess: () => {
naviagte("/auth");
reset();
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
const onSubmit = async (data: FormData) => {
setError(null);
setLoading(true);
try {
await setPasswordMutation.mutateAsync(
data
);
} catch (err) {
console.error(err);
const result = await setPassword({
newPassword: data.password,
confirmPassword: data.confirmPassword,
});
if (result.success) {
navigate("/auth");
} else {
setError(result.error.message);
}
} catch {
setError("An unexpected error occurred");
} finally {
setLoading(false);
}
};
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Account Security
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Set your secure
password
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Create a strong
password to secure
your EDR Freight
account and protect
railway logistics
operations and shipment
data.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Enterprise-grade security",
"Protected account access",
"Secure freight operations",
"Advanced authentication system",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Security Protection
</p>
<h3 className="mt-2 text-4xl font-black">
256-bit
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Encrypted
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[98%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<LockKeyhole className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
Set Password
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Create a secure
password for your
EDR Freight account.
</p>
</div>
{/* Success */}
{setPasswordMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Password updated
successfully.
</div>
)}
{/* Error */}
{setPasswordMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Failed to set
password. Please try
again.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Password
</label>
<div className="relative">
<input
type={
showPassword
? "text"
: "password"
}
placeholder="Enter password"
disabled={
setPasswordMutation.isPending
}
{...register(
"password"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowPassword(
!showPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{
errors.password
.message
}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Confirm Password
</label>
<div className="relative">
<input
type={
showConfirmPassword
? "text"
: "password"
}
placeholder="Confirm password"
disabled={
setPasswordMutation.isPending
}
{...register(
"confirmPassword"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(
!showConfirmPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">
{
errors
.confirmPassword
.message
}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={
setPasswordMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{setPasswordMutation.isPending ? (
"Saving..."
) : (
<>
Save Password
<ArrowRight className="size-5" />
</>
)}
</button>
</form>
</div>
</div>
<AuthLayout
left={{
badge: "Account Security",
title: "Set your secure password",
description:
"Create a strong password to secure your EDR Freight account and protect railway logistics operations and shipment data.",
features: [
"Enterprise-grade security",
"Protected account access",
"Secure freight operations",
"Advanced authentication system",
],
stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" },
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<LockKeyhole className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Set Password</h2>
<p className="mt-2 text-base text-muted-foreground">
Create a secure password for your account.
</p>
</div>
</div>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel>Password</FieldLabel>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Enter password"
disabled={loading}
aria-invalid={Boolean(errors.password)}
className="pr-12"
{...register("password")}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? <EyeOff /> : <Eye />}
</Button>
</div>
<FieldError errors={[errors.password]} />
</Field>
{password && (
<ul className="space-y-1.5">
{requirements.map((req) => (
<li
key={req.label}
className={cn(
"flex items-center gap-2 text-sm",
req.met ? "text-emerald-600" : "text-muted-foreground",
)}
>
{req.met ? (
<Check className="size-4 shrink-0 text-emerald-500" />
) : (
<X className="size-4 shrink-0 text-muted-foreground/50" />
)}
{req.label}
</li>
))}
</ul>
)}
<Field data-invalid={Boolean(errors.confirmPassword)}>
<FieldLabel>Confirm Password</FieldLabel>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
disabled={loading}
aria-invalid={Boolean(errors.confirmPassword)}
className="pr-12"
{...register("confirmPassword")}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showConfirmPassword ? <EyeOff /> : <Eye />}
</Button>
</div>
<FieldError errors={[errors.confirmPassword]} />
</Field>
</FieldGroup>
<Button
type="submit"
disabled={loading || !allMet}
size="lg"
className="w-full"
>
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Saving...
</>
) : (
<>
Save Password
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
</form>
</AuthLayout>
);
}
}

View File

@@ -1,534 +1,186 @@
import { userType } from "@/enums/userType";
import { createOTP, createUser } from "@/services/account";
import { CreateUserPayload } from "@/types/createUser";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowRight,
ShieldCheck,
Train,
UserPlus,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
import { ArrowRight, UserPlus, Loader2 } from "lucide-react";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
const userSchema = z.object({
email: z
.string()
.email("Invalid email address"),
username: z
.string()
.min(
3,
"Username must be at least 3 characters"
),
countryCode: z
.string()
.min(
1,
"Country code is required"
),
email: z.string().email("Invalid email address"),
countryCode: z.string().min(1, "Country code is required"),
phone: z
.string()
.min(
9,
"Phone number is too short"
)
.max(
9,
"Phone number is too long"
),
.min(9, "Phone number is too short")
.max(9, "Phone number is too long"),
userType: z.string(),
name: z.object({
en: z
.string()
.min(2, "Name is required"),
en: z.string().min(2, "Name is required"),
am: z.string().nullable(),
}),
});
type FormData = z.infer<
typeof userSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
type FormData = z.infer<typeof userSchema>;
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>({
resolver:
zodResolver(userSchema),
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
username: "",
countryCode: "+251",
phone: "",
userType:
userType.individual,
name: {
en: "",
am: "",
},
userType: userType.individual,
name: { en: "", am: "" },
},
});
// ---------------------------------------------------------------------------
// Create User Mutation
// ---------------------------------------------------------------------------
const createUserMutation =
useMutation({
mutationFn: (
user: CreateUserPayload
) => createUser(user),
onSuccess: () => {
reset();
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
const onSubmit = async (data: FormData) => {
setError(null);
setLoading(true);
try {
const normalizedPhone =
data.phone.startsWith(
"0"
)
? data.phone.slice(1)
: data.phone;
const fullPhoneNumber = `${data.countryCode
}${normalizedPhone}`;
const payload: CreateUserPayload =
{
const normalizedPhone = data.phone.startsWith("0")
? data.phone.slice(1)
: data.phone;
const payload: SignupPayload = {
email: data.email,
username:
data.username,
phoneNumber:
fullPhoneNumber,
userType:
data.userType,
name: {
en: data.name.en,
am:
data.name.am ||
"",
},
username: data.email,
phoneNumber: `${data.countryCode}${normalizedPhone}`,
userType: data.userType,
name: { en: data.name.en, am: data.name.am ?? "" },
};
const res =
await createUserMutation.mutateAsync(
payload
);
if (res?.success) {
// save auth token
// document.cookie = `auth-token=${res.data?.token}; path=/`;
localStorage.setItem(
"auth-token",
`auth-token=${res.data?.token}; path=/`
);
localStorage.setItem(
"userId",res.data?.userId
);
localStorage.setItem(
"otp",res.data?.otp?.split(" ")?.[6]
);
createOTP({ phone: payload.phoneNumber, otp:res.data?.otp?.split(" ")?.[6] })
// save phone for otp page
localStorage.setItem(
"otp-phone",
payload.phoneNumber
);
// save phone for set password page
localStorage.setItem(
"otp-email",
payload.email
);
// navigate otp page
const result = await signup(payload);
if (result.success) {
navigate("/otp");
} else {
setError(result.error.message);
}
} catch (err) {
console.error(err);
} catch {
setError("An unexpected error occurred");
} finally {
setLoading(false);
}
};
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Smart Freight
Operations
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Create your freight
operations account
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Join EDR Freight to
manage shipments,
monitor railway
operations, track
consignments, and
streamline logistics
workflows across
Ethiopia and
Djibouti.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Active Corridors
</p>
<h3 className="mt-2 text-4xl font-black">
24+
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Operational
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[95%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-2xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Form Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<UserPlus className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
Create Account
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Register to access
EDR Freight
services and railway
logistics operations.
</p>
</div>
{/* Success */}
{createUserMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Account created
successfully.
</div>
)}
{/* Error */}
{createUserMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Failed to create
account. Please try
again.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* Full Name */}
<div>
<label className="mb-2 block text-sm font-semibold">
Full Name
</label>
<input
type="text"
placeholder="John Doe"
disabled={
createUserMutation.isPending
}
{...register(
"name.en"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.name?.en && (
<p className="mt-1 text-sm text-red-500">
{
errors.name.en
.message
}
</p>
)}
</div>
{/* Username */}
<div>
<label className="mb-2 block text-sm font-semibold">
Username
</label>
<input
type="text"
placeholder="john_doe"
disabled={
createUserMutation.isPending
}
{...register(
"username"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
{
errors.username
.message
}
</p>
)}
</div>
{/* Email */}
<div>
<label className="mb-2 block text-sm font-semibold">
Email Address
</label>
<input
type="email"
placeholder="john@example.com"
disabled={
createUserMutation.isPending
}
{...register(
"email"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">
{
errors.email
.message
}
</p>
)}
</div>
{/* Phone */}
<div>
<label className="mb-2 block text-sm font-semibold">
Phone Number
</label>
<div className="flex gap-2">
<input
type="text"
disabled={
createUserMutation.isPending
}
{...register(
"countryCode"
)}
className="h-13 w-28 rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<input
type="tel"
placeholder="912345678"
disabled={
createUserMutation.isPending
}
{...register(
"phone"
)}
className="h-13 flex-1 rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
</div>
{(errors.countryCode ||
errors.phone) && (
<p className="mt-1 text-sm text-red-500">
{errors
.countryCode
?.message ||
errors.phone
?.message}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={
createUserMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{createUserMutation.isPending ? (
"Creating..."
) : (
<>
Create Account
<ArrowRight className="size-5" />
</>
)}
</button>
{/* Footer */}
<p className="text-center text-sm text-muted-foreground">
Already have an
account?
<button
type="button"
className="ml-2 font-semibold text-primary hover:underline"
>
Sign In
</button>
</p>
</form>
</div>
</div>
<AuthLayout
left={{
badge: "Smart Freight Operations",
title: "Create your freight operations account",
description:
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: {
label: "Active Corridors",
value: "24+",
footer: "Operational",
progress: "w-[95%]",
},
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<UserPlus className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Create Account</h2>
<p className="mt-2 text-base text-muted-foreground">
Register to access EDR Freight services.
</p>
</div>
</div>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
<Field data-invalid={Boolean(errors.name?.en)}>
<FieldLabel>Full Name</FieldLabel>
<Input
type="text"
placeholder="John Doe"
disabled={loading}
aria-invalid={Boolean(errors.name?.en)}
{...register("name.en")}
/>
<FieldError errors={[errors.name?.en]} />
</Field>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="john@example.com"
disabled={loading}
aria-invalid={Boolean(errors.email)}
{...register("email")}
/>
<FieldError errors={[errors.email]} />
</Field>
<PhoneInput
disabled={loading}
countryCode={{ ...register("countryCode") }}
phone={{ ...register("phone") }}
countryCodeError={errors.countryCode}
phoneError={errors.phone}
/>
</FieldGroup>
<Button type="submit" disabled={loading} size="lg" className="w-full">
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Creating...
</>
) : (
<>
Create Account
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Already have an account?
<Button
type="button"
variant="link"
size="sm"
onClick={() => navigate("/login")}
className="h-auto p-0 font-semibold"
>
Sign In
</Button>
</p>
</form>
</AuthLayout>
);
}
}

View File

@@ -1,70 +1,36 @@
import { verificationCodeType } from "@/enums/verificationCodeType";
import {
generateVerificationCode,
verifyOTP,
} from "@/services/account";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ArrowRight,
ShieldCheck,
Train,
MailCheck,
RotateCw,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
import { ArrowRight, MailCheck, RotateCw, Loader2 } from "lucide-react";
import { verificationCodeType } from "@/enums/verificationCodeType";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import {
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
} from "@edr/ui-common";
const otpSchema = z.object({
code: z
.string()
.regex(
/^\d{6}$/,
"OTP must be exactly 6 digits"
),
code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"),
});
type FormData = z.infer<
typeof otpSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
type FormData = z.infer<typeof otpSchema>;
export default function VerificationOtpPage() {
const navigate =
useNavigate();
const navigate = useNavigate();
const { verifyOTP, generateVerificationCode } = useAuth();
const [verifying, setVerifying] = useState(false);
const [resending, setResending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resentMessage, setResentMessage] = useState<string | null>(null);
// ---------------------------------------------------------------------------
// Local Storage Data
// ---------------------------------------------------------------------------
const phone =
localStorage.getItem(
"otp-phone"
) || "";
const email =
localStorage.getItem(
"otp-email"
) || "";
// ---------------------------------------------------------------------------
// Form
// ---------------------------------------------------------------------------
const phone = localStorage.getItem("otp-phone") || "";
const {
register,
@@ -72,383 +38,165 @@ export default function VerificationOtpPage() {
formState: { errors },
watch,
} = useForm<FormData>({
resolver:
zodResolver(otpSchema),
defaultValues: {
code: "",
},
resolver: zodResolver(otpSchema),
defaultValues: { code: "" },
});
const otpValue =
watch("code");
const otpValue = watch("code");
// ---------------------------------------------------------------------------
// Verify Mutation
// ---------------------------------------------------------------------------
const verifyMutation =
useMutation({
mutationFn: async (
data: {
phone: string;
otp: string;
}
) => verifyOTP(data),
onSuccess: () => {
navigate(
"/set-password"
);
},
});
// ---------------------------------------------------------------------------
// Resend Mutation
// ---------------------------------------------------------------------------
const resendMutation =
useMutation({
mutationFn: async () => {
return generateVerificationCode(
{
email,
phoneNumber:
phone,
type:
verificationCodeType.setPassword,
}
);
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
const onSubmit = async (data: FormData) => {
setError(null);
setVerifying(true);
try {
await verifyMutation.mutateAsync(
{
phone,
otp: data.code,
}
);
} catch (err) {
console.error(err);
const result = await verifyOTP(data.code);
if (result.success) {
navigate("/set-password");
} else {
setError(result.error.message);
}
} catch {
setError("An unexpected error occurred");
} finally {
setVerifying(false);
}
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const handleResend = async () => {
setResentMessage(null);
setResending(true);
try {
const result = await generateVerificationCode(verificationCodeType.setPassword);
if (result.success) {
setResentMessage("New OTP code sent successfully.");
} else {
setError(result.error.message);
}
} catch {
setError("An unexpected error occurred");
} finally {
setResending(false);
}
};
const maskedPhone =
phone.length > 4
? `${phone.slice(
0,
7
)}******`
: phone;
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
const maskedPhone = phone.length > 4 ? `${phone.slice(0, 7)}******` : phone;
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Secure
Verification
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Verify your
account securely
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Enter the
verification code
sent to your phone
number to continue
using EDR Freight
logistics services.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Secure OTP verification",
"Protected account access",
"Fast identity confirmation",
"Enterprise-grade security",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Footer Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Verification
Security
</p>
<h3 className="mt-2 text-4xl font-black">
99.9%
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Protected
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[99%] rounded-full bg-white" />
</div>
</div>
<AuthLayout
left={{
badge: "Secure Verification",
title: "Verify your account securely",
description:
"Enter the verification code sent to your phone number to continue using EDR Freight logistics services.",
features: [
"Secure OTP verification",
"Protected account access",
"Fast identity confirmation",
"Enterprise-grade security",
],
stats: { label: "Verification Security", value: "99.9%", footer: "Protected", progress: "w-[99%]" },
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<MailCheck className="size-6" />
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* OTP Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<MailCheck className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
OTP Verification
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Enter the
6-digit code sent
to:
</p>
<div className="mt-4 rounded-2xl border border-border bg-muted/50 px-4 py-3">
<p className="font-semibold">
{maskedPhone}
</p>
</div>
</div>
{/* Success */}
{verifyMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Verification
successful.
</div>
)}
{/* Error */}
{verifyMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Invalid OTP
code. Please try
again.
</div>
)}
{/* Resend Success */}
{resendMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700">
New OTP code sent
successfully.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* OTP */}
<div>
<label className="mb-2 block text-sm font-semibold">
Verification
Code
</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
disabled={
verifyMutation.isPending
}
{...register(
"code"
)}
className="h-16 w-full rounded-2xl border border-input bg-background px-5 text-center text-3xl font-black tracking-[12px] outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<div className="mt-2 flex items-center justify-between">
{errors.code ? (
<p className="text-sm text-red-500">
{
errors.code
.message
}
</p>
) : (
<p className="text-sm text-muted-foreground">
Enter the OTP
sent to your
phone
</p>
)}
<span className="text-xs text-muted-foreground">
{
otpValue.length
}
/6
</span>
</div>
</div>
{/* Verify Button */}
<button
type="submit"
disabled={
verifyMutation.isPending ||
otpValue.length !==
6
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{verifyMutation.isPending ? (
"Verifying..."
) : (
<>
Verify Account
<ArrowRight className="size-5" />
</>
)}
</button>
{/* Resend */}
<button
type="button"
onClick={() =>
resendMutation.mutate()
}
disabled={
resendMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl border border-border bg-background text-base font-semibold transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
>
{resendMutation.isPending ? (
"Sending..."
) : (
<>
<RotateCw className="size-5" />
Resend Code
</>
)}
</button>
{/* Footer */}
<p className="text-center text-sm text-muted-foreground">
Didnt receive
the code?
<button
type="button"
onClick={() =>
resendMutation.mutate()
}
className="ml-2 font-semibold text-primary hover:underline"
>
Send again
</button>
</p>
</form>
</div>
</div>
<h2 className="text-2xl font-black tracking-tight">OTP Verification</h2>
<p className="mt-2 text-base text-muted-foreground">Enter the 6-digit code sent to:</p>
<div className="mt-3 rounded-xl border border-border bg-muted/50 px-4 py-2">
<p className="text-sm font-semibold">{maskedPhone}</p>
</div>
</div>
</div>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
{resentMessage && (
<div className="mb-4 rounded-xl border border-blue-200 bg-blue-50 px-4 py-2.5 text-sm text-blue-700">
{resentMessage}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup>
<Field data-invalid={Boolean(errors.code)}>
<FieldLabel>Verification Code</FieldLabel>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
disabled={verifying}
aria-invalid={Boolean(errors.code)}
className="h-14 text-center text-2xl font-black tracking-[10px]"
{...register("code")}
/>
<div className="mt-1.5 flex items-center justify-between">
{errors.code ? (
<FieldError errors={[errors.code]} />
) : (
<p className="text-xs text-muted-foreground">Enter the OTP sent to your phone</p>
)}
<span className="text-xs text-muted-foreground">{otpValue.length}/6</span>
</div>
</Field>
</FieldGroup>
<Button
type="submit"
disabled={verifying || otpValue.length !== 6}
size="lg"
className="w-full"
>
{verifying ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Verifying...
</>
) : (
<>
Verify Account
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={handleResend}
disabled={resending}
className="w-full"
>
{resending ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Sending...
</>
) : (
<>
<RotateCw data-icon="inline-start" />
Resend Code
</>
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Didn't receive the code?
<Button
type="button"
variant="link"
size="sm"
onClick={handleResend}
className="h-auto p-0 font-semibold"
>
Send again
</Button>
</p>
</form>
</AuthLayout>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,460 +0,0 @@
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 {
useDeleteFileUploadSetting,
useFileUploadSettings,
} from "@/hooks/useFileUploadSettings";
import { getMinFiles } from "@/types/fileUploadSettings";
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 items-center justify-center gap-2 rounded-2xl 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) => f.isRequired,
).length;
const multi = setting.fields.filter(
(f) => f.isMultiple,
).length;
const maxSize = Math.max(
0,
...setting.fields.map((f) => f.maxSizeMb),
);
return (
<tr
key={setting.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
<FileUp className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{setting.label}
</p>
<p className="text-xs text-slate-500">
{setting.description ?? "No description"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{setting.code}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
{setting.entity ?? "—"}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-slate-700">
<Paperclip className="h-4 w-4 text-[#10B981]" />
<span className="font-medium">
{setting.fields.length}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Chip>{required} required</Chip>
<Chip muted>{multi} multi</Chip>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-1.5">
<HardDrive className="h-4 w-4 text-slate-400" />
{maxSize ? `${maxSize} MB` : "—"}
</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<ManageFileUploadFieldsDialog setting={setting}>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Paperclip className="h-3.5 w-3.5" />
Fields
</button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog
mode="edit"
setting={setting}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Pencil className="h-4 w-4" />
</button>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() =>
deleteMutation.mutate(setting.id)
}
>
<button
type="button"
disabled={deleteMutation.isPending}
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteFileUploadSettingDialog>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Behavior reference card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Required × Multiple behavior
</h2>
<p className="mt-1 text-sm text-slate-500">
Min and max file counts are derived from these two toggles. The
"Max Files" you set on a field is only used when{" "}
<span className="font-medium">Multiple</span> is on.
</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Required</th>
<th className="py-2 font-medium">Multiple</th>
<th className="py-2 font-medium">min_files</th>
<th className="py-2 font-medium">max_files</th>
</tr>
</thead>
<tbody>
<BehaviorRow
required={false}
multiple={false}
min="0"
max="1"
/>
<BehaviorRow
required={true}
multiple={false}
min="1"
max="1"
/>
<BehaviorRow
required={false}
multiple={true}
min="0"
max="field.maxFiles"
/>
<BehaviorRow
required={true}
multiple={true}
min="1"
max="field.maxFiles"
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-500">
Helpers <span className="font-mono">getMinFiles</span> and{" "}
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
<span className="font-mono">@/types/fileUploadSettings</span> use
them when wiring real uploaders. Example: a field with{" "}
<span className="font-mono">isRequired=false</span>,{" "}
<span className="font-mono">isMultiple=true</span>,{" "}
<span className="font-mono">maxFiles=5</span> gives{" "}
<span className="font-mono">{getMinFiles({
id: "demo",
fileKey: "demo",
fileLabel: "demo",
isRequired: false,
isMultiple: true,
maxFiles: 5,
allowedExtensions: [],
maxSizeMb: 1,
})}</span>
5.
</p>
</div>
</div>
</div>
);
}
function BehaviorRow({
required,
multiple,
min,
max,
}: {
required: boolean;
multiple: boolean;
min: string;
max: string;
}) {
return (
<tr className="border-t border-slate-100">
<td className="py-2.5">
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
</td>
<td className="py-2.5">
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
</td>
<td className="py-2.5 font-mono text-slate-700">{min}</td>
<td className="py-2.5 font-mono text-slate-700">{max}</td>
</tr>
);
}
function Chip({
children,
muted = false,
}: {
children: React.ReactNode;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
}
>
{children}
</span>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,174 +0,0 @@
import type {
FileUploadField,
FileUploadSetting,
} from "@/types/fileUploadSettings";
const field = (
settingCode: string,
idx: number,
data: Omit<FileUploadField, "id" | "order">,
): FileUploadField => ({
id: `${settingCode}-${idx + 1}`,
order: idx + 1,
...data,
});
export const fileUploadSettings: FileUploadSetting[] = [
{
id: "fu-customer_registration",
code: "customer_registration",
label: "Customer Registration",
description: "Documents required when onboarding a new customer.",
entity: "customer",
fields: [
field("customer_registration", 0, {
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Tax Identification Number certificate.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
field("customer_registration", 1, {
fileKey: "trade_license",
fileLabel: "Trade License",
helpText: "Current, non-expired trade license.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
],
},
{
id: "fu-booking",
code: "booking",
label: "Freight Booking",
description: "Documents attached to a freight booking submission.",
entity: "booking",
fields: [
field("booking", 0, {
fileKey: "packing_list",
fileLabel: "Packing List",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "xlsx"],
maxSizeMb: 10,
}),
field("booking", 1, {
fileKey: "commercial_invoice",
fileLabel: "Commercial Invoice",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
}),
field("booking", 2, {
fileKey: "certificate_of_origin",
fileLabel: "Certificate of Origin",
helpText: "Optional. Required for international shipments.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
],
},
{
id: "fu-consignment",
code: "consignment",
label: "Consignment",
description: "Cargo-level documents.",
entity: "consignment",
fields: [
field("consignment", 0, {
fileKey: "bill_of_lading",
fileLabel: "Bill of Lading",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
}),
field("consignment", 1, {
fileKey: "supporting_doc",
fileLabel: "Supporting Documents",
helpText: "Customs declarations, inspection reports, etc.",
isRequired: false,
isMultiple: true,
maxFiles: 5,
allowedExtensions: ["pdf", "docx", "jpg", "png"],
maxSizeMb: 10,
}),
],
},
{
id: "fu-invoice",
code: "invoice",
label: "Invoice",
description: "Attachments for billing invoices.",
entity: "invoice",
fields: [
field("invoice", 0, {
fileKey: "proof_of_payment",
fileLabel: "Proof of Payment",
helpText: "Bank transfer receipt or wire confirmation.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
],
},
{
id: "fu-train",
code: "train_maintenance",
label: "Train Maintenance",
description: "Maintenance and inspection records for rolling stock.",
entity: "train",
fields: [
field("train_maintenance", 0, {
fileKey: "inspection_report",
fileLabel: "Inspection Report",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
}),
field("train_maintenance", 1, {
fileKey: "photos",
fileLabel: "Inspection Photos",
helpText: "Photos of the condition.",
isRequired: false,
isMultiple: true,
maxFiles: 10,
allowedExtensions: ["jpg", "png", "heic"],
maxSizeMb: 8,
}),
],
},
];
export function getFileUploadSettingByCode(
code: string,
): FileUploadSetting | undefined {
return fileUploadSettings.find((s) => s.code === code);
}
export function getFileUploadFieldsByCode(
code: string,
): FileUploadField[] {
const setting = getFileUploadSettingByCode(code);
if (!setting) return [];
return [...setting.fields].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
}

View File

@@ -1,22 +1,69 @@
import { Link, useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
ArrowRight,
Calendar,
Flag,
MapPin,
Package,
StickyNote,
Trash2,
Train,
User,
Weight,
Ship,
Truck,
Anchor,
FileText,
ShieldCheck,
AlertTriangle,
Info,
Clock,
Layers,
CheckCircle2,
History,
ArrowRight,
ClipboardCheck,
CreditCard,
FileSignature,
PackageCheck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getBookingById, deleteBooking, type BookingStatus } from "./bookings.mock";
import { Button, Card } from "@edr/ui-common";
import { getBookingById } from "./bookings.mock";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Badge,
Separator,
} from "@edr/ui-common";
import { cn } from "@/lib/utils";
// Grouping the 15 granular statuses into 6 logical progress stages for the UI tracker
const PROGRESS_STAGES = [
{ label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
{ label: "Quotation", icon: ClipboardCheck, statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"] },
{ label: "Approval", icon: ShieldCheck, statuses: ["PENDING_APPROVAL", "APPROVED"] },
{ label: "Execution", icon: FileSignature, statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"] },
{ label: "In Transit", icon: Train, statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
{ label: "Complete", icon: PackageCheck, statuses: ["COMPLETED"] },
];
const STATUS_MAP: Record<string, { title: string; description: string; color: string; stage: number }> = {
DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 },
RFQ_SUBMITTED: { title: "RFQ Submitted", description: "Request for Quotation has been sent to the operations team.", color: "text-amber-600", stage: 0 },
QUOTATION_SENT: { title: "Quotation Received", description: "EDR has sent a formal quotation for your review.", color: "text-sky-600", stage: 1 },
QUOTATION_APPROVED: { title: "Quotation Approved", description: "You have accepted the quotation terms.", color: "text-emerald-600", stage: 1 },
QUOTATION_REJECTED: { title: "Quotation Rejected", description: "The quotation was not accepted.", color: "text-red-600", stage: 1 },
PENDING_APPROVAL: { title: "Internal Approval", description: "Booking is undergoing final administrative review.", color: "text-amber-600", stage: 2 },
APPROVED: { title: "Booking Approved", description: "Request is fully approved and ready for execution.", color: "text-emerald-600", stage: 2 },
SIGNED_CUSTOMER: { title: "Customer Signed", description: "Contract has been signed by the customer.", color: "text-sky-600", stage: 3 },
FULLY_EXECUTED: { title: "Contract Executed", description: "All parties have signed. Operational setup in progress.", color: "text-indigo-600", stage: 3 },
PAID: { title: "Payment Received", description: "Initial payments confirmed. Cargo ready for dispatch.", color: "text-emerald-600", stage: 3 },
IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 4 },
PENDING_CONSOLIDATION: { title: "Consolidation Node", description: "Cargo is waiting to be consolidated with other shipments.", color: "text-amber-500", stage: 4 },
CONSOLIDATED: { title: "Load Consolidated", description: "Cargo has been successfully merged into a larger shipment.", color: "text-indigo-500", stage: 4 },
COMPLETED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 5 },
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
};
export default function BookingDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -25,37 +72,29 @@ export default function BookingDetailPage() {
if (!booking) {
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Bookings", href: "/bookings" },
{ label: "Not found" },
]}
/>
<Card className="p-8 text-center">
<h1 className="text-2xl font-bold text-slate-900">
Booking not found
</h1>
<p className="mt-2 text-sm text-muted-foreground">
The booking you're looking for doesn't exist or has been removed.
</p>
<Link
to="/bookings"
className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
<ArrowLeft />
Back to Bookings
</Link>
</Card>
</div>
<div className="container mx-auto p-6">
<Card className="flex flex-col items-center p-12 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<Package className="size-8" />
</div>
<h1 className="mt-4 text-2xl font-bold text-slate-900">
Booking not found
</h1>
</Card>
</div>
);
}
// Normalize status to upper case for mapping
const normalizedStatus = (booking.status === "In Transit" ? "IN_TRANSIT" : booking.status === "Pending" ? "RFQ_SUBMITTED" : booking.status.toUpperCase()) as keyof typeof STATUS_MAP;
const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT;
const currentStageIndex = statusConfig.stage;
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="flex flex-col gap-8">
{/* Breadcrumbs Restored */}
<Breadcrumbs
items={[
{ label: "Bookings", href: "/bookings" },
@@ -63,159 +102,256 @@ export default function BookingDetailPage() {
]}
/>
<Card className="p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package />
{/* Compact Header Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-6">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package className="size-6" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
{booking.reference}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>{booking.customer}</span>
<span className="text-slate-300"></span>
<span>{booking.requestedDate}</span>
<span className="text-slate-300"></span>
<StatusBadge status={booking.status} />
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-black tracking-tight text-foreground">
{booking.reference}
</h1>
<StatusBadge status={normalizedStatus} />
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-semibold">{booking.customer}</span>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{booking.requestedDate}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button>Edit Booking</Button>
<DeleteBookingDialog
bookingReference={booking.reference}
onConfirm={() => {
deleteBooking(booking.id);
navigate("/bookings");
}}
>
<Button variant="outline">
<Trash2 />
Cancel
</Button>
</DeleteBookingDialog>
</div>
</div>
</CardHeader>
</Card>
<Card className="p-6">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<RouteEndpoint
label="Origin"
station={booking.originStation}
icon={<MapPin />}
/>
<div className="flex items-center gap-2 text-primary">
<Train />
<ArrowRight />
{/* Granular Status Lifecycle */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<History className="size-4 text-primary" />
Booking Status Lifecycle
</CardTitle>
<CardDescription>Track the journey from request to completion</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-8">
<div className="relative flex w-full justify-between px-2">
{/* Progress Line */}
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: currentStageIndex >= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }}
/>
</div>
{PROGRESS_STAGES.map((stage, idx) => {
const isCompleted = idx < currentStageIndex;
const isActive = idx === currentStageIndex;
return (
<div key={stage.label} className="relative z-10 flex flex-col items-center gap-2">
<div className={cn(
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
isCompleted ? "border-primary text-primary" :
isActive ? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110" :
"border-muted text-muted-foreground"
)}>
{isCompleted ? <CheckCircle2 className="size-4" /> : <stage.icon className="size-4" />}
</div>
<span className={cn(
"text-[9px] font-bold uppercase tracking-widest",
isActive ? "text-primary" : "text-muted-foreground"
)}>
{stage.label}
</span>
</div>
);
})}
</div>
<RouteEndpoint
label="Destination"
station={booking.destinationStation}
icon={<MapPin />}
/>
</div>
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
{normalizedStatus === "CANCELLED" ? <AlertTriangle className="size-5 text-red-500" /> : <Info className="size-5 text-primary" />}
</div>
<div className="flex flex-col gap-0.5">
<h4 className={cn("text-sm font-black uppercase tracking-tight", statusConfig.color)}>
{statusConfig.title}
</h4>
<p className="text-xs font-medium text-muted-foreground">
{statusConfig.description}
</p>
</div>
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && (
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase text-muted-foreground">Est. Waiting</p>
<p className="text-xs font-black text-foreground">1-2 Working Days</p>
</div>
<CreditCard className="size-5 text-muted-foreground" />
</div>
)}
</div>
</CardContent>
</Card>
{booking.transportMode === "Multimodal" &&
booking.legs &&
booking.legs.length > 0 ? (
<Card className="p-6">
<div className="mb-4 flex items-center gap-2">
<Train />
<h2 className="text-lg font-semibold text-slate-900">
Transport Legs
</h2>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{booking.legs.length} legs
</span>
</div>
<div className="space-y-3">
{booking.legs.map((leg, i) => (
<div
key={i}
className="flex flex-col gap-3 rounded-2xl border bg-primary/5 p-4 md:flex-row md:items-center md:justify-between"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-sm font-bold text-primary-foreground">
{i + 1}
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Leg {i + 1} · {leg.mode}
</p>
<p className="font-semibold text-slate-900">
{leg.from || "—"}
<ArrowRight className="mx-2 inline text-primary" />
{leg.to || "—"}
</p>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="flex flex-col gap-8 lg:col-span-2">
{/* Route & Core Service Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Anchor className="size-4 text-primary" />
Route & Service
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border bg-muted/30 p-4 md:flex-row">
<RouteEndpoint
label="Origin Yard"
station={booking.originStation}
icon={<MapPin />}
/>
<div className="flex flex-col items-center gap-1 text-primary">
<div className="flex items-center gap-2">
<Train className="size-5" />
<ArrowRight className="size-4" />
</div>
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase">
Rail
</Badge>
</div>
<RouteEndpoint
label="Destination Yard"
station={booking.destinationStation}
icon={<MapPin />}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem icon={<Layers />} label="Service" value="Rail & Forwarding" />
<InfoItem icon={<ShieldCheck />} label="Return" value="With Return" />
<InfoItem icon={<FileText />} label="Customs" value="Enabled" />
</div>
</CardContent>
</Card>
{/* Mile Services Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Truck className="size-4 text-primary" />
Mile Services
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
First Mile
</h3>
<InfoItem label="Address" value="Inside Addis Ababa Yard, Gate 2" />
</div>
<div className="flex flex-col gap-3">
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
Last Mile
</h3>
<p className="pl-4 text-xs text-muted-foreground italic">Not requested</p>
</div>
</CardContent>
</Card>
{/* Cargo Specifications Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="size-4 text-primary" />
Cargo Specifications
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem icon={<Package />} label="Category" value={booking.cargoType} />
<InfoItem icon={<Weight />} label="Weight" value={`${booking.weightTons} Tons`} />
<InfoItem icon={<Ship />} label="Shipping Line" value="MSC" />
</div>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-left text-xs">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="px-3 py-2 font-semibold">Description</th>
<th className="px-3 py-2 font-semibold text-center">Unit</th>
<th className="px-3 py-2 font-semibold text-right">Value</th>
</tr>
</thead>
<tbody className="divide-y">
<tr>
<td className="px-3 py-2 font-medium">Main Equipment</td>
<td className="px-3 py-2 text-center">20FT Container</td>
<td className="px-3 py-2 text-right">4 Units</td>
</tr>
</tbody>
</table>
</div>
</div>
))}
</div>
</Card>
) : null}
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Customer & Cargo">
<DetailRow
icon={<User />}
label="Customer"
value={booking.customer}
/>
<DetailRow
icon={<Package />}
label="Cargo Type"
value={booking.cargoType}
/>
<DetailRow
icon={<Package />}
label="Container"
value={`${booking.containerCount} × ${booking.containerType}`}
/>
<DetailRow
icon={<Weight />}
label="Weight"
value={`${booking.weightTons} tons`}
/>
</DetailCard>
<div className="flex flex-col gap-8">
{/* Contract Card */}
<Card className="border-primary/20 bg-primary/[0.02]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="size-4 text-primary" />
Contract Info
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem label="Type" value="Renewal" />
<InfoItem label="Ref" value="EDR-2024-88123" />
<Separator />
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-background text-[9px]">
Hazardous: No
</Badge>
<Badge variant="outline" className="bg-background text-[9px]">
Refrigerated: No
</Badge>
</div>
</CardContent>
</Card>
<DetailCard title="Schedule & Mode">
<DetailRow
icon={<Train />}
label="Transport Mode"
value={booking.transportMode}
/>
<DetailRow
icon={<Calendar />}
label="Requested Date"
value={booking.requestedDate}
/>
<DetailRow
icon={<Flag />}
label="Priority"
value={booking.priority}
/>
</DetailCard>
<DetailCard title="Cargo Description">
<div className="flex items-start gap-3 text-sm text-slate-700">
<Package />
<p className="leading-relaxed">{booking.cargoDescription}</p>
</div>
</DetailCard>
<DetailCard title="Special Instructions">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote />
<p className="leading-relaxed">{booking.specialInstructions}</p>
</div>
</DetailCard>
{/* Notes Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Additional Info</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Description</p>
<p className="text-xs text-foreground leading-relaxed italic">"{booking.cargoDescription}"</p>
</div>
<Separator />
<div className="flex flex-col gap-1">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Instructions</p>
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
<p className="text-xs text-amber-900 flex gap-2">
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
{booking.specialInstructions}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
@@ -233,68 +369,64 @@ function RouteEndpoint({
}) {
return (
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
{icon}
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
{icon && <div className="[&_svg]:size-5">{icon}</div>}
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="text-lg font-semibold text-slate-900">{station}</p>
<p className="text-sm font-black text-foreground">{station}</p>
</div>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
function InfoItem({
icon,
label,
value
}: {
icon?: React.ReactNode;
label: string;
value?: string | number | null
}) {
return (
<Card className="p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</Card>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
<div className="flex items-start gap-2">
{icon && <div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">{icon}</div>}
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
<p className="text-xs font-bold text-foreground">{value || "—"}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
function StatusBadge({ status }: { status: string }) {
const statusColors: Record<string, string> = {
DRAFT: "bg-slate-50 text-slate-700 border-slate-200",
RFQ_SUBMITTED: "bg-amber-50 text-amber-700 border-amber-200",
QUOTATION_SENT: "bg-sky-50 text-sky-700 border-sky-200",
QUOTATION_APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
QUOTATION_REJECTED: "bg-red-50 text-red-700 border-red-200",
PENDING_APPROVAL: "bg-amber-50 text-amber-700 border-amber-200",
APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
SIGNED_CUSTOMER: "bg-sky-50 text-sky-700 border-sky-200",
FULLY_EXECUTED: "bg-indigo-50 text-indigo-700 border-indigo-200",
PAID: "bg-emerald-50 text-emerald-700 border-emerald-200",
IN_TRANSIT: "bg-sky-50 text-sky-700 border-sky-200",
COMPLETED: "bg-indigo-50 text-indigo-700 border-indigo-200",
CANCELLED: "bg-red-50 text-red-700 border-red-200",
PENDING_CONSOLIDATION: "bg-amber-50 text-amber-700 border-amber-200",
CONSOLIDATED: "bg-indigo-50 text-indigo-700 border-indigo-200",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
<Badge
variant="outline"
className={cn("px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]", statusColors[status] || "bg-muted")}
>
{status}
</span>
{status.replace(/_/g, ' ')}
</Badge>
);
}

View File

@@ -1,282 +0,0 @@
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
ArrowRight,
Clock,
Eye,
Filter,
MoreHorizontal,
Package,
Pencil,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { bookings, type BookingStatus } from "./bookings.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
export default function BookingsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const total = bookings.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(() => bookings.slice(start, end), [start, end]);
const columns: ColumnDef<(typeof bookings)[number]>[] = [
{
accessorKey: "reference",
header: "Reference",
cell: ({ row }) => {
const booking = 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">
<Package />
</div>
<div>
<p className="font-medium text-slate-900">{booking.reference}</p>
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
</div>
</div>
);
},
},
{
accessorKey: "customer",
header: "Customer",
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span>{row.original.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{row.original.destinationStation}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
return (
<div className="text-sm text-slate-700">
<p>{b.cargoType}</p>
<p className="text-xs text-slate-500">
{b.containerCount} × {b.containerType} · {b.weightTons}t
</p>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const booking = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuSeparator />
<DeleteBookingDialog bookingReference={booking.reference}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteBookingDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Bookings" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Bookings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage and monitor your freight bookings.
</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"
placeholder="Search bookings..."
className="pl-8!"
/>
</div>
<Link to="/bookings/new">
<Button>
<Plus />
New Booking
</Button>
</Link>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{total}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Package />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">In Transit</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{bookings.filter((b) => b.status === "In Transit").length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Pending</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{bookings.filter((b) => b.status === "Pending").length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Booking List</CardTitle>
<CardDescription>
Recent freight bookings and their status.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) =>
navigate(`/bookings/${(row as (typeof bookings)[number]).id}`)
}
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>
</div>
);
}
function StatusBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -13,7 +13,6 @@ import {
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getMyBookings } from "@/lib/currentCustomer";
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
@@ -183,8 +182,6 @@ export default function MyBookings() {
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "My Bookings" }]} />
{/* Header Section Card */}
<Card className="p-6 flex-row justify-between">
<div>

View File

@@ -1,16 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@edr/ui-common";
import Breadcrumbs from "@/components/Breadcrumbs";
import { addBooking } from "./bookings.mock";
import { getCurrentCustomer } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
MOCK_VALID_CONTRACTS,
STEPS,
bookingFormSchema,
calcWagons,
@@ -23,20 +21,25 @@ import { StepIndicator } from "./new-booking-form/StepIndicator";
import {
Step1ContractType,
Step2ServiceType,
Step3FirstLastMile,
Step4Route,
Step5CargoDetails,
Step6WagonAllocation,
Step7Documents,
Step8Review,
} from "./new-booking-form/steps";
import useAuth from "@/hooks/useAuth";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [renewalValidating, setRenewalValidating] = useState(false);
const [renewalValid, setRenewalValid] = useState<boolean | null>(null);
const [submitted, setSubmitted] = useState(false);
const { customer } = useAuth();
const createMutation = useMutation({
mutationFn: (payload: CreateBookingPayload) =>
api.bookings.create.call(payload),
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
setTimeout(() => navigate(`/bookings/${booking.id}`), 2500);
},
});
const form = useForm<BookingFormValues>({
defaultValues: initialBookingFormValues,
@@ -47,9 +50,6 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const containers = form.watch("containers");
const previousContractRef = form.watch("previousContractRef");
const contractId =
form.watch("draftContractId") || form.watch("previousContractRef");
const direction = useMemo(
() => getRouteDirection(originYard, destinationYard),
@@ -61,78 +61,24 @@ export default function NewBookingPage() {
return calcWagons(containers);
}, [containers]);
useEffect(() => {
setRenewalValid(null);
}, [previousContractRef]);
function validateRenewal() {
const previousContractRef = form.getValues("previousContractRef").trim();
if (!previousContractRef) {
form.setError("previousContractRef", {
type: "manual",
message: "Enter a previous contract reference.",
});
return;
}
setRenewalValidating(true);
setRenewalValid(null);
setTimeout(() => {
const valid = MOCK_VALID_CONTRACTS.includes(
previousContractRef.toUpperCase(),
);
setRenewalValidating(false);
setRenewalValid(valid);
if (!valid) {
form.setError("previousContractRef", {
type: "manual",
message: "Contract Reference Number not found or unauthorized.",
});
} else {
form.clearErrors("previousContractRef");
}
}, 1200);
}
async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
if (step === 1 && form.getValues("contractType") === "renewal") {
if (renewalValid !== true) {
form.setError("previousContractRef", {
type: "manual",
message:
"Validate the previous contract reference before continuing.",
});
return;
}
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
function handleSubmit(data: BookingFormValues) {
if (data.contractType === "renewal" && renewalValid !== true) {
const handleSubmit = form.handleSubmit((data) => {
if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", {
type: "manual",
message: "Validate the previous contract reference before submitting.",
message: "Select a previous contract reference.",
});
setStep(1);
return;
}
const me = getCurrentCustomer();
const reference =
data.draftContractId ||
data.previousContractRef ||
`EDR-DRAFT-${Date.now()}`;
const qtyCount =
data.cargoType === "container"
? data.containers.reduce((acc, c) => acc + Number(c.qty || 0), 0)
: 1;
const reference = data.previousContractRef;
const totalWeight =
data.cargoType === "container"
@@ -142,63 +88,31 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
const description =
data.cargoType === "container"
? data.containers.map((c) => `${c.qty} × ${c.type}`).join(", ")
: data.freightType === "bulk"
? `Bulk - ${data.bulkCommodity === "Others" ? data.bulkCommodityOther : data.bulkCommodity}`
: `Break-Bulk - ${data.breakBulkType === "Others" ? data.breakBulkTypeOther : data.breakBulkType}`;
const newBooking = {
id: Date.now(),
reference,
customerId: me.id,
customer: me.company,
cargoType: (data.cargoType === "container"
? "Containerized"
: "Bulk") as any,
originStation: data.originYard,
destinationStation: data.destinationYard,
transportMode: (data.serviceType === "rail"
? "Rail"
: "Multimodal") as any,
containerType: (data.cargoType === "container" &&
data.containers[0]?.type === "40ft"
? "40FT"
: "20FT") as any,
containerCount: qtyCount,
weightTons: totalWeight,
requestedDate: new Date().toISOString().slice(0, 10),
priority: (data.isHazardous ? "High" : "Normal") as any,
cargoDescription: description,
specialInstructions: data.notes || "Standard handling required",
status: "Pending" as any,
};
addBooking(newBooking);
// Call API using api.bookings.create.call
const apiPayload = {
reference,
customerId: String(me.id),
customerId: customer!.id,
scheduledDate: new Date().toISOString().slice(0, 10),
totalAmount: 0,
contractType: data.contractType.toUpperCase(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
previousContractId: data.previousContractRef || undefined,
serviceType:
data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING",
firstMileEnabled: data.firstMileEnabled,
firstMilePickupAddress: data.firstMileEnabled
? data.pickUpAddress
: undefined,
lastMileEnabled: data.lastMileEnabled,
lastMileDeliveryAddress: data.lastMileEnabled
? data.deliveryAddress
: undefined,
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
data.service.serviceType === "rail"
? "RAIL_ONLY"
: "RAIL_AND_FORWARDING",
...(data.service.serviceType === "rail"
? {}
: {
firstMileEnabled: data.firstMile.enabled,
firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined,
lastMileEnabled: data.lastMile.enabled,
lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined,
equipmentReturn:
data.equipmentReturn === "with_return"
? ("WITH_RETURN" as const)
: ("WITHOUT_RETURN" as const),
customsClearingEnabled: data.customsClearingEnabled,
}),
originStation: data.originYard,
destinationStation: data.destinationYard,
cargoTotalWeightVgm: totalWeight,
@@ -220,31 +134,18 @@ export default function NewBookingPage() {
...(data.cargoType === "container" && data.containers.length > 0
? {
containers: data.containers.map((c) => ({
type: c.type === "40ft" ? "40FT" as const : "20FT" as const,
type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const),
qty: Number(c.qty || 1),
vgm: Number(c.vgm || 0),
})),
}
: {}),
};
} satisfies CreateBookingPayload;
api.bookings.create
.call(apiPayload as CreateBookingPayload)
.then((created) => {
console.log("Successfully created booking via API:", created);
})
.catch((err) => {
console.warn(
"API call failed (expected if API server is offline), falling back to mock storage:",
err,
);
});
createMutation.mutate(apiPayload);
});
setSubmitted(true);
setTimeout(() => navigate("/bookings"), 2500);
}
if (submitted) {
if (createMutation.isSuccess) {
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-sm rounded-2xl border border-border bg-card p-10 text-center shadow-sm">
@@ -257,7 +158,7 @@ export default function NewBookingPage() {
notified once approved.
</p>
<p className="mt-4 font-mono text-sm font-semibold text-primary">
{contractId}
{createMutation.data?.reference}
</p>
</div>
</div>
@@ -268,45 +169,24 @@ export default function NewBookingPage() {
<form
id="new-booking-form"
className="flex flex-col"
onSubmit={form.handleSubmit(handleSubmit)}
onSubmit={handleSubmit}
>
<div className="sticky top-0 z-20 border-b border-border bg-background">
<div className="mx-auto max-w-4xl space-y-3 px-6 py-3">
<Breadcrumbs
items={[
{ label: "Bookings", href: "/bookings" },
{ label: "New Contract Request" },
]}
/>
<div className="sticky top-0 z-20">
<div className="mx-auto max-w-4xl space-y-3 px-6 pt-4">
<StepIndicator step={step} />
</div>
</div>
<div className="flex-1">
<div className="mx-auto max-w-4xl px-6 py-8">
{step === 1 && (
<Step1ContractType
form={form}
renewalValid={renewalValid}
renewalValidating={renewalValidating}
onValidate={validateRenewal}
/>
)}
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && <Step3FirstLastMile form={form} />}
{step === 4 && <Step4Route form={form} />}
{step === 5 && (
{step === 3 && <Step4Route form={form} />}
{step === 4 && (
<Step5CargoDetails form={form} direction={direction} />
)}
{step === 6 && <Step6WagonAllocation form={form} wagons={wagons} />}
{step === 7 && <Step7Documents form={form} />}
{step === 8 && (
<Step8Review
form={form}
setStep={setStep}
wagons={wagons}
direction={direction}
/>
{step === 5 && (
<Step8Review form={form} setStep={setStep} direction={direction} />
)}
</div>
</div>
@@ -327,11 +207,11 @@ export default function NewBookingPage() {
{step < STEPS.length ? (
<Button type="button" onClick={handleContinue}>
Continue
<ChevronRight className="ml-1 h-4 w-4" />
<ChevronRight />
</Button>
) : (
<Button type="submit" form="new-booking-form">
<CheckCircle2 className="mr-2 h-4 w-4" />
<Check />
Submit Contract Request
</Button>
)}

View File

@@ -1,3 +1,4 @@
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
export const STATIONS = [
@@ -42,127 +43,72 @@ export const MOCK_VALID_CONTRACTS = [
"EDR-2022-55442",
];
export const REQUIRED_DOC_KEYS = [
"tin_certificate",
"business_license",
"business_registration",
// "national_id",
export const CONTAINER_TYPES = [
"Dry Container",
"High Cubic",
"Reefer Container",
"Open Top",
"Flat Rack",
"Tank Container",
"Open Side",
] as const;
export const SHIPPING_LINES = [
"MSC",
"CMA CGM",
"Evergreen",
"COSCO",
"Hapag-Lloyd",
"ONE",
"Yang Ming",
"ZIM",
"Messina Line",
"Safmarine",
"Wan Hai",
"Ethiopian Shipping Lines (ESLSE)",
] as const;
export const STEPS = [
{ id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type", short: "Service" },
{ id: 3, label: "First & Last Mile", short: "Mile" },
{ id: 4, label: "Route", short: "Route" },
{ id: 5, label: "Cargo Details", short: "Cargo" },
{ id: 6, label: "Wagon Allocation", short: "Wagons" },
{ id: 7, label: "Documents", short: "Docs" },
{ id: 8, label: "Review & Submit", short: "Submit" },
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Route", short: "Route" },
{ id: 4, label: "Cargo Details", short: "Cargo" },
{ id: 5, label: "Review & Submit", short: "Submit" },
] as const;
export const BOOKING_DOCS_SETTING = {
id: "booking-compliance",
createdAt: "",
updatedAt: "",
deletedAt: null,
code: "booking_compliance_docs",
label: "Compliance Documents",
description:
"Upload your company's legal credentials. All mandatory documents must be submitted before the contract request can be reviewed by EDR Line Staff.",
entity: "booking" as const,
fields: [
{
id: "f1",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText:
"Tax Identification Number certificate issued by ERCA (10-digit TIN).",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 1,
},
{
id: "f2",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "business_license",
fileLabel: "Business / Investment License",
helpText:
"Current business or investment license issued by the relevant government authority.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 2,
},
{
id: "f3",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "business_registration",
fileLabel: "Business Registration Certificate",
helpText: "Certificate of registration from the relevant authority.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 3,
},
{
id: "f5",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "power_of_attorney",
fileLabel: "Power of Attorney (PoA)",
helpText:
"Required only if a representative is signing on behalf of the company.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 5,
},
],
};
const fileValueSchema = z.union([
z.custom<File>(),
z.array(z.custom<File>()),
z.null(),
]);
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
draftContractId: z.string(),
serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."),
firstMileEnabled: z.boolean(),
pickUpAddress: z.string(),
lastMileEnabled: z.boolean(),
deliveryAddress: z.string(),
equipmentReturn: z.enum(["with_return", "without_return"]),
originYard: z.string(),
destinationYard: z.string(),
firstMile: z
.object({
enabled: z.boolean().default(false),
pickUpAddress: z.string(),
})
.refine((data) => !(data.enabled && !data.pickUpAddress.trim()), {
message: "Enter the pick-up address.",
path: ["pickUpAddress"],
}),
lastMile: z
.object({
enabled: z.boolean().default(false),
deliveryAddress: z.string(),
})
.refine((data) => !(data.enabled && !data.deliveryAddress.trim()), {
message: "Enter the delivery address.",
path: ["deliveryAddress"],
}),
equipmentReturn: z
.enum(["with_return", "without_return"])
.default("with_return"),
customsClearingEnabled: z.boolean().default(false),
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.enum(["bulk", "break_bulk", ""]).default(""),
freightType: z.enum(["bulk", "break_bulk"]).optional(),
bulkCommodity: z.string(),
bulkCommodityOther: z.string(),
breakBulkType: z.string(),
@@ -172,150 +118,116 @@ export const bookingFormSchema = z
containers: z.array(
z.object({
type: z.enum(["20ft", "40ft"]),
containerType: z.string().min(1, "Select a container type."),
qty: z
.string()
.refine((q) => q.length !== 0, "Quantity is required.")
.refine((q) => !isNaN(+q), "Enter a valid Number")
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
vgm: z
.string()
.refine((vgm) => vgm.length !== 0, "VGM is required.")
.refine((vgm) => !isNaN(+vgm), "Enter a valid Number")
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
}),
),
consolidationEnabled: z.boolean(),
documents: z.record(z.string(), fileValueSchema),
notes: z.string(),
termsAccepted: z.boolean(),
})
.superRefine((data, ctx) => {
if (data.contractType === "new" && !data.draftContractId.trim()) {
ctx.addIssue({
code: "custom",
path: ["draftContractId"],
message: "A draft contract ID is required.",
});
}
if (data.contractType === "renewal" && !data.previousContractRef.trim()) {
ctx.addIssue({
code: "custom",
path: ["previousContractRef"],
message: "Enter a previous contract reference.",
});
}
if (data.firstMileEnabled && !data.pickUpAddress.trim()) {
ctx.addIssue({
code: "custom",
path: ["pickUpAddress"],
message: "Enter the pick-up address.",
});
}
if (data.lastMileEnabled && !data.deliveryAddress.trim()) {
ctx.addIssue({
code: "custom",
path: ["deliveryAddress"],
message: "Enter the delivery address.",
});
}
if (!data.originYard) {
ctx.addIssue({
code: "custom",
path: ["originYard"],
message: "Select an origin yard.",
});
}
if (!data.destinationYard) {
ctx.addIssue({
code: "custom",
path: ["destinationYard"],
message: "Select a destination yard.",
});
}
if (
data.originYard &&
data.destinationYard &&
data.originYard === data.destinationYard
) {
ctx.addIssue({
code: "custom",
path: ["destinationYard"],
message: "Destination must be different from origin.",
});
}
if (data.cargoType === "bulk") {
if (!data.freightType) {
ctx.addIssue({
code: "custom",
path: ["freightType"],
message: "Select a freight type.",
});
}
if (data.freightType === "bulk") {
if (!data.bulkCommodity) {
ctx.addIssue({
code: "custom",
path: ["bulkCommodity"],
message: "Select a commodity.",
});
}
if (
data.bulkCommodity === "Others" &&
!data.bulkCommodityOther.trim()
) {
ctx.addIssue({
code: "custom",
path: ["bulkCommodityOther"],
message: "Specify the commodity.",
});
}
}
if (data.freightType === "break_bulk") {
if (!data.breakBulkType) {
ctx.addIssue({
code: "custom",
path: ["breakBulkType"],
message: "Select a break-bulk type.",
});
}
if (
data.breakBulkType === "Others" &&
!data.breakBulkTypeOther.trim()
) {
ctx.addIssue({
code: "custom",
path: ["breakBulkTypeOther"],
message: "Specify the break-bulk type.",
});
}
}
.refine(
(data) =>
!(data.contractType === "renewal" && !data.previousContractRef.trim()),
{
message: "Enter a previous contract reference.",
path: ["previousContractRef"],
},
)
.refine((data) => data.originYard !== "", {
message: "Select an origin yard.",
path: ["originYard"],
})
.refine((data) => data.destinationYard !== "", {
message: "Select a destination yard.",
path: ["destinationYard"],
})
.refine(
(data) =>
!(
data.originYard &&
data.destinationYard &&
data.originYard === data.destinationYard
),
{
message: "Destination must be different from origin.",
path: ["destinationYard"],
},
)
.refine((data) => !(data.cargoType === "bulk" && !data.freightType), {
message: "Select a freight type.",
path: ["freightType"],
})
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "bulk" &&
!data.bulkCommodity
),
{ message: "Select a commodity.", path: ["bulkCommodity"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "bulk" &&
data.bulkCommodity === "Others" &&
!data.bulkCommodityOther.trim()
),
{ message: "Specify the commodity.", path: ["bulkCommodityOther"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "break_bulk" &&
!data.breakBulkType
),
{ message: "Select a break-bulk type.", path: ["breakBulkType"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "break_bulk" &&
data.breakBulkType === "Others" &&
!data.breakBulkTypeOther.trim()
),
{
message: "Specify the break-bulk type.",
path: ["breakBulkTypeOther"],
},
)
.refine(
(data) => {
if (data.cargoType !== "bulk") return true;
const cargoWeight = Number(data.cargoWeight);
if (!data.cargoWeight || Number.isNaN(cargoWeight) || cargoWeight <= 0) {
ctx.addIssue({
code: "custom",
path: ["cargoWeight"],
message: "Enter a cargo weight greater than 0.",
});
}
}
return (
!!data.cargoWeight && !Number.isNaN(cargoWeight) && cargoWeight > 0
);
},
{ message: "Enter a cargo weight greater than 0.", path: ["cargoWeight"] },
)
.refine(
(data) => !(data.cargoType === "container" && data.containers.length === 0),
{ message: "Add at least one container.", path: ["containers"] },
)
.refine((data) => data.termsAccepted, {
message: "Accept the freight contract terms to submit.",
path: ["termsAccepted"],
})
.superRefine((data, ctx) => {
if (data.cargoType === "container") {
if (data.containers.length === 0) {
ctx.addIssue({
code: "custom",
path: ["containers"],
message: "Add at least one container.",
});
}
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
ctx.addIssue({
@@ -334,40 +246,26 @@ export const bookingFormSchema = z
}
});
}
for (const key of REQUIRED_DOC_KEYS) {
const value = data.documents[key];
const hasFile = Array.isArray(value) ? value.length > 0 : Boolean(value);
if (!hasFile) {
ctx.addIssue({
code: "custom",
path: ["documents", key],
message: "Upload this required document.",
});
}
}
if (!data.termsAccepted) {
ctx.addIssue({
code: "custom",
path: ["termsAccepted"],
message: "Accept the freight contract terms to submit.",
});
}
});
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
export const initialBookingFormValues: Partial<BookingFormValues> = {
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "",
draftContractId: "",
firstMileEnabled: false,
pickUpAddress: "",
lastMileEnabled: false,
deliveryAddress: "",
firstMile: {
enabled: false,
pickUpAddress: "",
},
lastMile: {
enabled: false,
deliveryAddress: "",
},
equipmentReturn: "with_return",
customsClearingEnabled: false,
originYard: "",
destinationYard: "",
shippingLine: "",
cargoWeight: "",
bulkCommodity: "",
bulkCommodityOther: "",
@@ -375,25 +273,29 @@ export const initialBookingFormValues: Partial<BookingFormValues> = {
breakBulkTypeOther: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", qty: "1", vgm: "" }],
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: false,
documents: {},
notes: "",
termsAccepted: false,
};
export const stepFields: Record<number, Array<keyof BookingFormValues>> = {
1: ["contractType", "previousContractRef", "draftContractId"],
2: ["serviceType"],
3: [
"firstMileEnabled",
"pickUpAddress",
"lastMileEnabled",
"deliveryAddress",
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceType",
"firstMile",
"lastMile",
"equipmentReturn",
"customsClearingEnabled",
],
4: ["originYard", "destinationYard", "isHazardous", "isRefrigerated"],
5: [
3: [
"originYard",
"destinationYard",
"isHazardous",
"isRefrigerated",
"shippingLine",
],
4: [
"cargoType",
"cargoWeight",
"freightType",
@@ -402,16 +304,16 @@ export const stepFields: Record<number, Array<keyof BookingFormValues>> = {
"breakBulkType",
"breakBulkTypeOther",
"containers",
"consolidationEnabled",
],
6: ["consolidationEnabled"],
7: ["documents"],
8: ["notes", "termsAccepted"],
5: ["notes", "termsAccepted"],
};
export type RouteDirection = "import" | "export" | "domestic" | null;
export interface ContainerConfig {
type: "20ft" | "40ft";
containerType: string;
qty: string;
vgm: string;
}

View File

@@ -22,18 +22,8 @@ import {
SelectValue,
} from "@edr/ui-common";
import type { BookingFormValues } from "./schema";
import { REQUIRED_DOC_KEYS } from "./schema";
import { cn } from "@/lib/utils";
export function getUploadedRequiredCount(
documents: BookingFormValues["documents"],
) {
return REQUIRED_DOC_KEYS.filter((key) => {
const file = documents[key];
return Array.isArray(file) ? file.length > 0 : Boolean(file);
}).length;
}
export function OptionFieldError({ error }: { error?: { message?: string } }) {
return <FieldError errors={[error]} />;
}
@@ -160,6 +150,8 @@ export function SelectField({
);
}
export { SelectItem };
export function SelectOptions({ options }: { options: readonly string[] }) {
return (
<>

View File

@@ -1,26 +1,21 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Loader2, RefreshCw } from "lucide-react";
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
import { type BookingFormValues, genContractId } from "./schema";
import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared";
import { FileText, RefreshCw } from "lucide-react";
import { Field } from "@edr/ui-common";
import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema";
import {
AlertBox,
OptionCard,
OptionFieldError,
SelectField,
SelectItem,
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step1ContractType({
form,
renewalValid,
renewalValidating,
onValidate,
}: {
form: BookingForm;
renewalValid: boolean | null;
renewalValidating: boolean;
onValidate: () => void;
}) {
export function Step1ContractType({ form }: { form: BookingForm }) {
const contractType = form.watch("contractType");
const draftContractId = form.watch("draftContractId");
const previousContractRef = form.watch("previousContractRef");
const errors = form.formState.errors;
return (
<div className="space-y-6">
@@ -40,12 +35,7 @@ export function Step1ContractType({
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
if (!draftContractId) {
form.setValue("draftContractId", genContractId(), {
shouldDirty: true,
shouldValidate: true,
});
}
form.setValue("previousContractRef", "");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
@@ -53,13 +43,8 @@ export function Step1ContractType({
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Blank contract form. A draft ID is auto-generated.
Create a new contract.
</p>
{field.value === "new" && draftContractId && (
<p className="mt-2 font-mono text-xs font-semibold text-primary">
{draftContractId}
</p>
)}
</OptionCard>
<OptionCard
@@ -74,7 +59,7 @@ export function Step1ContractType({
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Enter a previous reference to auto-populate historical
Select a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
@@ -90,49 +75,26 @@ export function Step1ContractType({
name="previousContractRef"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="previousContractRef">
Previous Contract Reference Number
</FieldLabel>
<div className="flex gap-2">
<Input
{...field}
id="previousContractRef"
aria-invalid={fieldState.invalid}
placeholder="e.g. EDR-2024-10001"
className="font-mono"
/>
<Button
type="button"
variant="outline"
onClick={onValidate}
disabled={!previousContractRef || renewalValidating}
>
{renewalValidating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Validate"
)}
</Button>
</div>
<FieldError
errors={[fieldState.error, errors.draftContractId]}
/>
</Field>
<SelectField
field={field}
error={fieldState.error}
label="Previous Contract Reference Number"
placeholder="Select a contract..."
>
{MOCK_VALID_CONTRACTS.map((ref) => (
<SelectItem key={ref} value={ref}>
{ref}
</SelectItem>
))}
</SelectField>
)}
/>
{renewalValid === true && (
{previousContractRef && (
<AlertBox tone="success">
<strong>Contract found.</strong> Company details, route, and wagon
preferences will be pre-filled.
</AlertBox>
)}
{renewalValid === false && (
<AlertBox tone="error">
Contract Reference Number not found or unauthorized. Try{" "}
<span className="font-mono">EDR-2024-10001</span>.
</AlertBox>
)}
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Package, Train } from "lucide-react";
import { Badge, Field, FieldError } from "@edr/ui-common";
import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
@@ -8,12 +9,67 @@ type BookingForm = UseFormReturn<BookingFormValues>;
export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType");
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
const prev = prevServiceType.current;
prevServiceType.current = serviceType;
if (!prev || prev === serviceType) return;
if (serviceType === "rail") {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
form.setValue("customsClearingEnabled", false, {
shouldDirty: true,
});
} else if (serviceType === "rail_forwarding") {
form.setValue(
"firstMile",
{
enabled: false,
pickUpAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
);
form.setValue(
"lastMile",
{
enabled: false,
deliveryAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
);
}
}, [serviceType, form]);
const showServiceSections = serviceType === "rail_forwarding";
return (
<div className="space-y-6">
<StepHeader
title="Service Type"
description="Select the service combination you require."
description="Select the service combination and configure trucking options."
/>
<Controller
@@ -46,9 +102,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
</div>
<p className="font-semibold">
Rail Transport & Freight Forwarding
</p>
<p className="font-semibold">Logistics</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Rail transport plus documentation, customs liaison, and a
dedicated coordinator.
@@ -63,10 +117,172 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
)}
/>
<p className="text-xs text-muted-foreground">
Customs and Clearance Service cannot be selected independently. It must
be bundled with a Rail Transport service.
</p>
{showServiceSections && (
<div className="divide-y divide-border rounded-xl border border-border">
<div className="p-4">
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
First Mile - Pick-up
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Pick-up address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
<div className="p-4">
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
Last Mile - Delivery
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Delivery address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
{lastMileEnabled && (
<div className="p-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
</div>
<Switch
checked={field.value === "with_return"}
onCheckedChange={(value) => {
field.onChange(
value ? "with_return" : "without_return",
);
}}
/>
</div>
)}
/>
</div>
)}
<div className="p-4">
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
Customs Clearing Service
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
EDR handles customs documentation and clearance on your
behalf.
</p>
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</div>
)}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema";
import { OptionCard, StepHeader } from "./shared";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
@@ -27,7 +27,8 @@ export function Step3FirstLastMile({ form }: { form: BookingForm }) {
<div>
<p className="text-sm font-medium">First Mile - Pick-up</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck pick-up from your premises to the origin rail yard.
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
<Switch
@@ -73,7 +74,7 @@ export function Step3FirstLastMile({ form }: { form: BookingForm }) {
<p className="text-sm font-medium">Last Mile - Delivery</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck delivery from the destination rail yard to the final
address.
address (Port to Door).
</p>
</div>
<Switch
@@ -107,40 +108,35 @@ export function Step3FirstLastMile({ form }: { form: BookingForm }) {
)}
/>
)}
</div>
<div className="p-4">
<p className="mb-3 text-sm font-medium">Equipment Return</p>
<p className="mb-3 text-xs text-muted-foreground">
Declare whether the container asset will be returned after
unloading.
</p>
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="grid gap-2 sm:grid-cols-2">
<OptionCard
selected={equipmentReturn === "with_return"}
onClick={() => field.onChange("with_return")}
>
<p className="text-sm font-semibold">With Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Container returned to EDR after unloading.
</p>
</OptionCard>
<OptionCard
selected={equipmentReturn === "without_return"}
onClick={() => field.onChange("without_return")}
>
<p className="text-sm font-semibold">Without Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Container retained by the customer after delivery.
</p>
</OptionCard>
</div>
)}
/>
{lastMileEnabled && (
<div className="mt-4 border-t border-border pt-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onCheckedChange={(value) => {
field.onChange(
value ? "with_return" : "without_return",
);
}}
/>
</div>
)}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,10 +1,22 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
import {
SHIPPING_LINES,
type BookingFormValues,
getRouteDirection,
STATIONS,
} from "./schema";
import {
AlertBox,
SelectField,
SelectOptions,
StepHeader,
StepLabel,
} from "./shared";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import { DropdownOption } from "@/types/dropdownSettings";
import { useEffect } from "react";
type BookingForm = UseFormReturn<BookingFormValues>;
@@ -33,6 +45,12 @@ export function Step4Route({ form }: { form: BookingForm }) {
};
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
useEffect(() => {
if (direction === "domestic") {
form.setValue("shippingLine", "", { shouldDirty: true });
}
}, [direction]);
return (
<div className="space-y-6">
<StepHeader
@@ -100,6 +118,22 @@ export function Step4Route({ form }: { form: BookingForm }) {
)}
</div>
{direction && direction != "domestic" && (
<Controller
name="shippingLine"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
>
<SelectOptions options={SHIPPING_LINES} />
</SelectField>
)}
/>
)}
<Separator />
<div className="space-y-0 divide-y divide-border">
@@ -145,12 +179,10 @@ export function Step4Route({ form }: { form: BookingForm }) {
);
}
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
return [...(options ?? [])].sort((a, b) => a.order - b.order);
}
function StationSelectOptions({
options,
excludeValue,
@@ -193,4 +225,4 @@ function StationSelectOptions({
))}
</>
);
}
}

View File

@@ -1,16 +1,11 @@
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import {
Button,
Field,
FieldError,
FieldLabel,
Input,
Separator,
} from "@edr/ui-common";
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
import {
BREAK_BULK_TYPES,
BULK_COMMODITIES,
CONTAINER_TYPES,
calcWagons,
type BookingFormValues,
type RouteDirection,
} from "./schema";
@@ -78,8 +73,9 @@ export function Step5CargoDetails({
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", "", { shouldDirty: true });
form.setValue("cargoWeight", "", { shouldDirty: true });
form.setValue("freightType", undefined, {
shouldDirty: true,
});
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
@@ -94,11 +90,7 @@ export function Step5CargoDetails({
selected={cargoType === "bulk"}
onClick={() => {
field.onChange("bulk");
form.setValue(
"containers",
[{ type: "20ft", qty: "1", vgm: "0" }],
{ shouldDirty: true },
);
form.setValue("containers", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
@@ -116,173 +108,162 @@ export function Step5CargoDetails({
/>
</div>
<div className="space-y-3">
<StepLabel>Weight</StepLabel>
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="cargoWeight">
Total Cargo Weight(Tons)*
</FieldLabel>
<div className="relative">
<Weight className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
{...field}
id="cargoWeight"
type="number"
aria-invalid={fieldState.invalid}
placeholder="0.00"
className="pl-9"
min="0"
step="0.01"
/>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
{cargoType === "bulk" && (
<>
<Separator />
<div className="space-y-3">
<StepLabel>Freight Type *</StepLabel>
<Controller
name="freightType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div className="grid gap-3 sm:grid-cols-2">
<OptionCard
selected={freightType === "bulk"}
onClick={() => field.onChange("bulk")}
>
<p className="font-semibold">Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Coffee, fertilizer, grain, ore, etc.
</p>
</OptionCard>
<OptionCard
selected={freightType === "break_bulk"}
onClick={() => field.onChange("break_bulk")}
>
<p className="font-semibold">Break-Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Machinery, vehicles, project cargo, etc.
</p>
</OptionCard>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<div className="space-y-3">
<StepLabel>Freight Type *</StepLabel>
<Controller
name="freightType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div className="grid gap-3 sm:grid-cols-2">
<OptionCard
selected={freightType === "bulk"}
onClick={() => field.onChange("bulk")}
>
<p className="font-semibold">Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Coffee, fertilizer, grain, ore, etc.
</p>
</OptionCard>
<OptionCard
selected={freightType === "break_bulk"}
onClick={() => field.onChange("break_bulk")}
>
<p className="font-semibold">Break-Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Machinery, vehicles, project cargo, etc.
</p>
</OptionCard>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
{freightType === "bulk" && (
<div className="space-y-2">
{freightType === "bulk" && (
<div className="space-y-2">
<Controller
name="bulkCommodity"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Commodity *"
placeholder="Select commodity *"
>
<SelectOptions options={BULK_COMMODITIES} />
</SelectField>
)}
/>
{bulkCommodity === "Others" && (
<Controller
name="bulkCommodity"
name="bulkCommodityOther"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Commodity *"
placeholder="Select commodity *"
>
<SelectOptions options={BULK_COMMODITIES} />
</SelectField>
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify commodity *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
{bulkCommodity === "Others" && (
<Controller
name="bulkCommodityOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify commodity *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
)}
)}
</div>
)}
{freightType === "break_bulk" && (
<div className="space-y-2">
{freightType === "break_bulk" && (
<div className="space-y-2">
<Controller
name="breakBulkType"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Break-bulk type *"
placeholder="Select type *"
>
<SelectOptions options={BREAK_BULK_TYPES} />
</SelectField>
)}
/>
{breakBulkType === "Others" && (
<Controller
name="breakBulkType"
name="breakBulkTypeOther"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Break-bulk type *"
placeholder="Select type *"
>
<SelectOptions options={BREAK_BULK_TYPES} />
</SelectField>
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify break-bulk type *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
{breakBulkType === "Others" && (
<Controller
name="breakBulkTypeOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify break-bulk type *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<StepLabel>Weight</StepLabel>
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="cargoWeight">
Total Cargo Weight - VGM (Tons) *
</FieldLabel>
<div className="relative">
<Weight className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
{...field}
id="cargoWeight"
type="number"
aria-invalid={fieldState.invalid}
placeholder="0.00"
className="pl-9"
min="0"
step="0.01"
/>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
</>
</div>
)}
</div>
)}
{cargoType === "container" && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between">
<StepLabel>Container Configuration</StepLabel>
<StepLabel>Containers</StepLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ type: "20ft", qty: "1", vgm: "0" })}
onClick={() =>
append({
type: "20ft",
containerType: "",
qty: "1",
vgm: "",
})
}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Container
</Button>
</div>
{direction && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5" />
Route detected as{" "}
<span className="font-medium capitalize text-foreground">
{direction}
</span>{" "}
workflow
</p>
)}
{fields.map((field, index) => {
const containerType = containers[index]?.type;
const vgm = containers[index]?.vgm ?? 0;
@@ -291,12 +272,9 @@ export function Step5CargoDetails({
return (
<div
key={field.id}
className="space-y-3 rounded-xl border border-border p-4"
className="rounded-xl flex flex-col border border-border p-3 gap-2"
>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Container #{index + 1}
</p>
{fields.length > 1 && (
<button
type="button"
@@ -313,7 +291,6 @@ export function Step5CargoDetails({
control={form.control}
render={({ field: typeField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Container Type *</FieldLabel>
<div className="grid gap-2 sm:grid-cols-2">
{[
{
@@ -350,7 +327,7 @@ export function Step5CargoDetails({
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-3">
<Controller
name={`containers.${index}.qty`}
control={form.control}
@@ -405,7 +382,7 @@ export function Step5CargoDetails({
control={form.control}
render={({ field: vgmField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>VGM (Tons) *</FieldLabel>
<FieldLabel>Tons*</FieldLabel>
<Input
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
@@ -420,6 +397,21 @@ export function Step5CargoDetails({
</Field>
)}
/>
<Controller
name={`containers.${index}.containerType`}
control={form.control}
render={({ field: ctField, fieldState }) => (
<SelectField
field={ctField}
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
>
<SelectOptions options={CONTAINER_TYPES} />
</SelectField>
)}
/>
</div>
{alert && (
@@ -431,6 +423,29 @@ export function Step5CargoDetails({
);
})}
</div>
{(() => {
const result = calcWagons(containers ?? []);
if (result.hasOddUnit) {
return (
<AlertBox tone="warning">
<div className="flex items-start gap-2">
<div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the
remaining slot, which{" "}
<strong>may delay departure</strong> beyond the standard
lead time.
</p>
</div>
</div>
</AlertBox>
);
}
return null;
})()}
</>
)}
</div>

View File

@@ -11,24 +11,21 @@ import {
Textarea,
} from "@edr/ui-common";
import {
REQUIRED_DOC_KEYS,
type BookingFormValues,
type RouteDirection,
type WagonCalcResult,
} from "./schema";
import { getUploadedRequiredCount, StepHeader } from "./shared";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step8Review({
form,
setStep,
wagons,
direction,
}: {
form: BookingForm;
setStep: (step: number) => void;
wagons: WagonCalcResult | null;
direction: RouteDirection;
}) {
const values = form.watch();
@@ -63,13 +60,16 @@ export function Step8Review({
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce((sum, c) => sum + (c.qty || 0) * (c.vgm || 0), 0)
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
const cargoValue =
@@ -80,8 +80,6 @@ export function Step8Review({
: values.freightType === "break_bulk"
? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}`
: "";
const uploadedCount = getUploadedRequiredCount(values.documents);
return (
<div className="space-y-6">
<StepHeader
@@ -91,7 +89,7 @@ export function Step8Review({
<div className="grid gap-4 md:grid-cols-2">
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contract & Service
</CardTitle>
@@ -102,11 +100,6 @@ export function Step8Review({
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row
label="Contract ID"
value={values.draftContractId || values.previousContractRef}
target={1}
/>
<Row
label="Service"
value={
@@ -122,7 +115,7 @@ export function Step8Review({
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
First & Last Mile
</CardTitle>
@@ -131,18 +124,20 @@ export function Step8Review({
<Row
label="First Mile"
value={
values.firstMileEnabled ? values.pickUpAddress : "Not requested"
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
target={3}
target={2}
/>
<Row
label="Last Mile"
value={
values.lastMileEnabled
? values.deliveryAddress
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
target={3}
target={2}
/>
<Row
label="Equipment Return"
@@ -151,13 +146,20 @@ export function Step8Review({
? "With Return"
: "Without Return"
}
target={3}
target={2}
/>
<Row
label="Customs Clearing"
value={
values.customsClearingEnabled ? "Enabled" : "Not requested"
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Route & Cargo
</CardTitle>
@@ -166,7 +168,7 @@ export function Step8Review({
<Row
label="Route"
value={`${values.originYard} -> ${values.destinationYard}`}
target={4}
target={3}
/>
<Row
label="Workflow"
@@ -175,14 +177,14 @@ export function Step8Review({
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={4}
target={3}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={5}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={5} />
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
@@ -193,13 +195,13 @@ export function Step8Review({
.filter(Boolean)
.join(", ") || "None"
}
target={4}
target={3}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Container & Wagons
</CardTitle>
@@ -208,26 +210,12 @@ export function Step8Review({
<Row
label="Containers"
value={containerSummary || "-"}
target={5}
target={4}
/>
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={5}
/>
<Row
label="Wagons"
value={
wagons
? `${wagons.totalWagons} wagon${wagons.totalWagons > 1 ? "s" : ""}`
: ""
}
target={6}
/>
<Row
label="Documents"
value={`${uploadedCount}/${REQUIRED_DOC_KEYS.length} mandatory uploaded`}
target={7}
target={4}
/>
</CardContent>
</Card>

View File

@@ -1,8 +1,5 @@
export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type";
export { Step3FirstLastMile } from "./step3-first-last-mile";
export { Step4Route } from "./step4-route";
export { Step5CargoDetails } from "./step5-cargo-details";
export { Step6WagonAllocation } from "./step6-wagon-allocation";
export { Step7Documents } from "./step7-documents";
export { Step8Review } from "./step8-review";

View File

@@ -1,287 +0,0 @@
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Building2,
Calendar,
Hash,
MapPin,
Package,
Ruler,
StickyNote,
Trash2,
Weight,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewConsignmentPage from "./NewConsignmentPage";
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
import {
getConsignmentById,
type ConsignmentStatus,
} from "./consignments.mock";
import {
Button,
Card,
} from "@edr/ui-common";
export default function ConsignmentDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const consignment = id ? getConsignmentById(id) : undefined;
if (!consignment) {
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Consignments", href: "/consignments" },
{ label: "Not found" },
]}
/>
<Card className="p-8 text-center">
<h1 className="text-2xl font-bold text-slate-900">
Consignment not found
</h1>
<p className="mt-2 text-sm text-muted-foreground">
The consignment you're looking for doesn't exist or has been removed.
</p>
<Link
to="/consignments"
className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
<ArrowLeft />
Back to Consignments
</Link>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Consignments", href: "/consignments" },
{ label: consignment.trackingNumber },
]}
/>
<Card className="p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package />
</div>
<div>
<h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-slate-900">
{consignment.trackingNumber}
{consignment.hazardous ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-1 text-xs font-semibold text-red-700">
<AlertTriangle />
Hazardous
</span>
) : null}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>{consignment.customer}</span>
<span className="text-slate-300"></span>
<span>Booking {consignment.bookingReference}</span>
<span className="text-slate-300"></span>
<StatusBadge status={consignment.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<NewConsignmentPage
mode="edit"
consignment={{
trackingNumber: consignment.trackingNumber,
bookingId: consignment.bookingId,
bookingReference: consignment.bookingReference,
cargoType: consignment.cargoType,
description: consignment.description,
weightKg: consignment.weightKg,
volumeM3: consignment.volumeM3,
pieces: consignment.pieces,
hazardous: consignment.hazardous,
specialHandling: consignment.specialHandling,
status: consignment.status,
estimatedDelivery: consignment.estimatedDelivery,
}}
>
<Button>Edit Consignment</Button>
</NewConsignmentPage>
<DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber}
onConfirm={() => navigate("/consignments")}
>
<Button variant="outline">
<Trash2 />
Remove
</Button>
</DeleteConsignmentDialog>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<RouteEndpoint
label="Origin"
station={consignment.originStation}
/>
<ArrowRight className="text-primary" />
<RouteEndpoint
label="Destination"
station={consignment.destinationStation}
/>
</div>
</Card>
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Cargo">
<DetailRow
icon={<Package />}
label="Cargo Type"
value={consignment.cargoType}
/>
<DetailRow
icon={<Hash />}
label="Pieces"
value={String(consignment.pieces)}
/>
<DetailRow
icon={<Weight />}
label="Weight"
value={`${consignment.weightKg.toLocaleString()} kg`}
/>
<DetailRow
icon={<Ruler />}
label="Volume"
value={`${consignment.volumeM3}`}
/>
</DetailCard>
<DetailCard title="References & Schedule">
<DetailRow
icon={<Building2 />}
label="Customer"
value={consignment.customer}
/>
<DetailRow
icon={<Hash />}
label="Booking"
value={consignment.bookingReference}
/>
<DetailRow
icon={<Calendar />}
label="Created"
value={consignment.createdAt}
/>
<DetailRow
icon={<Calendar />}
label="Estimated Delivery"
value={consignment.estimatedDelivery}
/>
</DetailCard>
<DetailCard title="Description">
<div className="flex items-start gap-3 text-sm text-slate-700">
<Package />
<p className="leading-relaxed">{consignment.description}</p>
</div>
</DetailCard>
<DetailCard title="Special Handling">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote />
<p className="leading-relaxed">{consignment.specialHandling}</p>
</div>
</DetailCard>
</div>
</div>
</div>
);
}
function RouteEndpoint({
label,
station,
}: {
label: string;
station: string;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<MapPin />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="text-lg font-semibold text-slate-900">{station}</p>
</div>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card className="p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</Card>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: ConsignmentStatus }) {
const styles: Record<ConsignmentStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
"In Warehouse": "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Returned: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,429 +0,0 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Eye,
Filter,
MoreHorizontal,
Package,
Pencil,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewConsignmentPage from "./NewConsignmentPage";
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
import { consignments, type ConsignmentStatus } from "./consignments.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | ConsignmentStatus;
const FILTERS: FilterValue[] = [
"All",
"Pending",
"In Warehouse",
"In Transit",
"Delivered",
"Returned",
];
export default function ConsignmentsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return consignments.filter((c) => {
if (filter !== "All" && c.status !== filter) return false;
if (!q) return true;
return (
c.trackingNumber.toLowerCase().includes(q) ||
c.bookingReference.toLowerCase().includes(q) ||
c.customer.toLowerCase().includes(q) ||
c.originStation.toLowerCase().includes(q) ||
c.destinationStation.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = 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 inTransitCount = consignments.filter(
(c) => c.status === "In Transit",
).length;
const deliveredCount = consignments.filter(
(c) => c.status === "Delivered",
).length;
const hazmatCount = consignments.filter((c) => c.hazardous).length;
const columns: ColumnDef<(typeof consignments)[number]>[] = [
{
id: "trackingNumber",
header: "Tracking #",
cell: ({ row }) => {
const c = 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">
<Package />
</div>
<div>
<p className="flex items-center gap-2 font-medium text-slate-900">
{c.trackingNumber}
{c.hazardous ? (
<span
title="Hazardous"
className="inline-flex items-center gap-0.5 rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700"
>
<AlertTriangle />
DG
</span>
) : null}
</p>
<p className="text-sm text-slate-500">{c.createdAt}</p>
</div>
</div>
);
},
},
{
accessorKey: "bookingReference",
header: "Booking",
},
{
accessorKey: "customer",
header: "Customer",
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span>{row.original.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{row.original.destinationStation}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const c = row.original;
return (
<div className="text-sm text-slate-700">
<p>{c.cargoType}</p>
<p className="text-xs text-slate-500">
{c.pieces} pcs · {c.weightKg.toLocaleString()} kg · {c.volumeM3}{" "}
m³
</p>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const consignment = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`/consignments/${consignment.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<NewConsignmentPage
mode="edit"
consignment={{
trackingNumber: consignment.trackingNumber,
bookingId: consignment.bookingId,
bookingReference: consignment.bookingReference,
cargoType: consignment.cargoType,
description: consignment.description,
weightKg: consignment.weightKg,
volumeM3: consignment.volumeM3,
pieces: consignment.pieces,
hazardous: consignment.hazardous,
specialHandling: consignment.specialHandling,
status: consignment.status,
estimatedDelivery: consignment.estimatedDelivery,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewConsignmentPage>
<DropdownMenuSeparator />
<DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber}
>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteConsignmentDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Consignments" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Consignments
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Track cargo units and their handling status.
</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 consignments..."
className="pl-8!"
/>
</div>
<NewConsignmentPage>
<Button>
<Plus />
New Consignment
</Button>
</NewConsignmentPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Consignments</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{consignments.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Package />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">In Transit</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{inTransitCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Delivered</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{deliveredCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<CheckCircle2 />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Hazardous</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{hazmatCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertTriangle />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? consignments.length
: consignments.filter((c) => c.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
</Card>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Consignment List</CardTitle>
<CardDescription>
Cargo units and their current handling status.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) =>
navigate(
`/consignments/${(row as (typeof consignments)[number]).id}`,
)
}
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>
</div>
);
}
function StatusBadge({ status }: { status: ConsignmentStatus }) {
const styles: Record<ConsignmentStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
"In Warehouse": "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Returned: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,63 +0,0 @@
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 DeleteConsignmentDialogProps {
trackingNumber: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteConsignmentDialog({
trackingNumber,
onConfirm,
children,
}: DeleteConsignmentDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Remove consignment?
</DialogTitle>
<DialogDescription>
This will permanently remove consignment{" "}
<span className="font-semibold text-slate-900">
{trackingNumber}
</span>
. This action cannot be undone.
</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"
>
Remove
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,235 +0,0 @@
import type { ReactNode } from "react";
import { Calendar, Hash, Package, Ruler, Weight } from "lucide-react";
import {
Dialog,
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 { bookings } from "../bookings/bookings.mock";
import type {
ConsignmentCargo,
ConsignmentStatus,
} from "./consignments.mock";
export interface ConsignmentFormData {
trackingNumber?: string;
bookingId?: number;
bookingReference?: string;
cargoType?: ConsignmentCargo;
description?: string;
weightKg?: number;
volumeM3?: number;
pieces?: number;
hazardous?: boolean;
specialHandling?: string;
status?: ConsignmentStatus;
estimatedDelivery?: string;
}
export interface NewConsignmentPageProps {
mode?: "create" | "edit";
consignment?: ConsignmentFormData;
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";
export default function NewConsignmentPage({
mode = "create",
consignment,
children,
}: NewConsignmentPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Consignment" : "New Consignment";
const description = isEdit
? "Update consignment details."
: "Register a new consignment under a freight booking.";
const submitLabel = isEdit ? "Save Changes" : "Create Consignment";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? (
<Button>{isEdit ? "Edit" : "New Consignment"}</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Tracking Number */}
<div className="space-y-2">
<Label>Tracking Number *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={consignment?.trackingNumber ?? ""}
placeholder="e.g. CGM-0001"
className="pl-10"
/>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={consignment?.status ?? "Pending"}
className={selectClass}
>
<option>Pending</option>
<option>In Warehouse</option>
<option>In Transit</option>
<option>Delivered</option>
<option>Returned</option>
</select>
</div>
{/* Booking */}
<div className="space-y-2 md:col-span-2">
<Label>Freight Booking *</Label>
<select
defaultValue={consignment?.bookingId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select freight booking
</option>
{bookings.map((b) => (
<option key={b.id} value={b.id}>
{b.reference} {b.customer} ({b.originStation} {" "}
{b.destinationStation})
</option>
))}
</select>
</div>
{/* Cargo Type */}
<div className="space-y-2">
<Label>Cargo Type *</Label>
<select
defaultValue={consignment?.cargoType ?? "Containerized"}
className={selectClass}
>
<option>Containerized</option>
<option>Bulk</option>
<option>Liquid</option>
<option>Refrigerated</option>
<option>Hazardous</option>
<option>General</option>
</select>
</div>
{/* Pieces */}
<div className="space-y-2">
<Label>Pieces</Label>
<div className="relative">
<Package className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={1}
defaultValue={consignment?.pieces ?? 1}
className="pl-10"
/>
</div>
</div>
{/* Weight */}
<div className="space-y-2">
<Label>Weight (kg)</Label>
<div className="relative">
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.1"
defaultValue={consignment?.weightKg ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Volume */}
<div className="space-y-2">
<Label>Volume (m³)</Label>
<div className="relative">
<Ruler className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.1"
defaultValue={consignment?.volumeM3 ?? 0}
className="pl-10"
/>
</div>
</div>
{/* ETA */}
<div className="space-y-2">
<Label>Estimated Delivery</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={consignment?.estimatedDelivery ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Hazardous */}
<div className="space-y-2">
<Label>Hazardous Goods</Label>
<label className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700">
<input
type="checkbox"
defaultChecked={consignment?.hazardous ?? false}
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
<span>Mark as hazardous (DG)</span>
</label>
</div>
{/* Description */}
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
defaultValue={consignment?.description ?? ""}
placeholder="Describe the consignment contents..."
/>
</div>
{/* Special Handling */}
<div className="space-y-2 md:col-span-2">
<Label>Special Handling</Label>
<Textarea
defaultValue={consignment?.specialHandling ?? ""}
placeholder="Any handling instructions..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,273 +0,0 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
Building2,
FileText,
Globe,
Loader2,
Mail,
MapPin,
Phone,
StickyNote,
Trash2,
User,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
import type { CustomerStatus } from "@/types/customers";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: customer, isLoading, isError, error } = useCustomer(id);
const deleteMutation = useDeleteCustomer();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
/>
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
Loading customer
</div>
</div>
</div>
);
}
if (isError || !customer) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: "Not found" },
]}
/>
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
<h1 className="text-2xl font-bold text-slate-900">
{isError ? "Failed to load customer" : "Customer not found"}
</h1>
<p className="mt-2 text-sm text-slate-500">
{isError && error instanceof Error
? error.message
: "The customer you're looking for doesn't exist or has been removed."}
</p>
<Link
to="/customers"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Link>
</div>
</div>
</div>
);
}
const handleDelete = () => {
deleteMutation.mutate(customer.id, {
onSuccess: () => navigate("/customers"),
});
};
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: customer.name },
]}
/>
{/* Header */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
<User className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
{customer.name}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
<span className="text-slate-300"></span>
<span>{customer.company ?? "—"}</span>
<span className="text-slate-300"></span>
<StatusBadge status={customer.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setEditOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
Edit Customer
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</div>
</div>
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Company Information">
<DetailRow
icon={<Building2 className="h-4 w-4" />}
label="Company Name"
value={customer.company ?? "—"}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="Customer Type"
value={customer.customerType}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="TIN Number"
value={customer.tinNumber ?? "—"}
/>
</DetailCard>
<DetailCard title="Contact">
<DetailRow
icon={<User className="h-4 w-4" />}
label="Contact Person"
value={customer.name}
/>
<DetailRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={customer.email}
/>
<DetailRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={customer.phone}
/>
</DetailCard>
<DetailCard title="Location">
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="City"
value={customer.city ?? "—"}
/>
<DetailRow
icon={<Globe className="h-4 w-4" />}
label="Country"
value={customer.country ?? "—"}
/>
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="Address"
value={customer.address ?? "—"}
/>
</DetailCard>
<DetailCard title="Notes">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
</div>
</DetailCard>
</div>
</div>
<NewCustomerPage
mode="edit"
customer={customer}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteCustomerDialog
customerName={customer.name}
onConfirm={handleDelete}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,386 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Clock3,
Eye,
Filter,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
User,
UserCheck,
Users,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
import type { Customer, CustomerStatus } from "@/types/customers";
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" | "delete";
export default function CustomerPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
// Defer past the DropdownMenu close cycle so Radix doesn't leave
// `pointer-events: none` on <body>.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveCustomer(customer);
setActiveDialog(dialog);
});
});
};
const closeDialog = () => setActiveDialog(null);
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const { data, isLoading, isError, error } = useCustomers();
const deleteMutation = useDeleteCustomer();
const customers = useMemo<Customer[]>(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return customers;
return customers.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
(c.company ?? "").toLowerCase().includes(q) ||
c.phone.toLowerCase().includes(q),
);
}, [customers, 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 activeCount = customers.filter((c) => c.status === "Active").length;
const pendingCount = customers.filter((c) => c.status === "Pending").length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<Customer>[] = [
{
accessorKey: "name",
header: "Customer",
cell: ({ row }) => {
const customer = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
<User className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{customer.name}</p>
<p className="text-sm text-slate-500">
{customer.company ?? "—"}
</p>
</div>
</div>
);
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.email}</span>
),
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.phone}</span>
),
},
{
accessorKey: "customerType",
header: "Type",
cell: ({ row }) => (
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
{row.original.customerType}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const customer = 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
onSelect={() => navigate(`/customers/${customer.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("edit", customer)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", customer)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Customers" }]} />
<Card className="p-6 flex-row justify-between ">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Customers
</h1>
<p className="mt-1 text-sm text-secondary-foreground ">
Manage and monitor your customer records.
</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 customers..."
className="pl-8!"
/>
</div>
<NewCustomerPage>
<Button>
<Plus />
Add Customer
</Button>
</NewCustomerPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Customers"
value={customers.length}
icon={<Users className="h-5 w-5" />}
/>
<StatCard
title="Active Accounts"
value={activeCount}
icon={<UserCheck className="h-5 w-5" />}
/>
<StatCard
title="Pending Requests"
value={pendingCount}
icon={<Clock3 className="h-5 w-5" />}
/>
</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 customers.{" "}
{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>Customer List</CardTitle>
<CardDescription>
Recent customer activities and records.
</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 customers
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={(row) => navigate(`/customers/${row.id}`)}
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>
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
unmount + pointer-events conflict). */}
{activeCustomer ? (
<>
<NewCustomerPage
key={`edit-${activeCustomer.id}`}
mode="edit"
customer={activeCustomer}
open={activeDialog === "edit"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<DeleteCustomerDialog
key={`delete-${activeCustomer.id}`}
customerName={activeCustomer.name}
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent 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-primary text-white">
{icon}
</div>
</CardContent>
</Card>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,72 +0,0 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Button,
} from "@edr/ui-common";
export interface DeleteCustomerDialogProps {
customerName: string;
onConfirm?: () => void;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteCustomerDialog({
customerName,
onConfirm,
children,
open: openProp,
onOpenChange,
}: DeleteCustomerDialogProps) {
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>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete customer?
</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
from your records. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,223 +0,0 @@
import type { ReactNode } from "react";
import {
Dialog,
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 {
Building2,
Mail,
Phone,
User,
Globe,
MapPin,
FileText,
} from "lucide-react";
export interface CustomerFormData {
companyName?: string;
customerType?: string;
contactPerson?: string;
email?: string;
phone?: string;
tinNumber?: string;
city?: string;
country?: string;
address?: string;
notes?: string;
}
export interface NewCustomerPageProps {
mode?: "create" | "edit";
customer?: CustomerFormData;
children?: ReactNode;
}
export default function NewCustomerPage({
mode = "create",
customer,
children,
}: NewCustomerPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit
? "Update existing customer information."
: "Create and manage customer information.";
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Company Name */}
<div className="space-y-2">
<Label>Company Name *</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.companyName ?? ""}
placeholder="Enter company name"
className="pl-10"
/>
</div>
</div>
{/* Customer Type */}
<div className="space-y-2">
<Label>Customer Type *</Label>
<select
defaultValue={customer?.customerType ?? "Importer"}
className="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"
>
<option>Importer</option>
<option>Exporter</option>
<option>Supplier</option>
</select>
</div>
{/* Contact Person */}
<div className="space-y-2">
<Label>Contact Person</Label>
<div className="relative">
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.contactPerson ?? ""}
placeholder="Enter contact person"
className="pl-10"
/>
</div>
</div>
{/* Email */}
<div className="space-y-2">
<Label>Email *</Label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
type="email"
defaultValue={customer?.email ?? ""}
placeholder="Enter email"
className="pl-10"
/>
</div>
</div>
{/* Phone */}
<div className="space-y-2">
<Label>Phone</Label>
<div className="relative">
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.phone ?? ""}
placeholder="Enter phone"
className="pl-10"
/>
</div>
</div>
{/* TIN */}
<div className="space-y-2">
<Label>TIN Number</Label>
<div className="relative">
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.tinNumber ?? ""}
placeholder="Enter TIN number"
className="pl-10"
/>
</div>
</div>
{/* City */}
<div className="space-y-2">
<Label>City</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.city ?? ""}
placeholder="Enter city"
className="pl-10"
/>
</div>
</div>
{/* Country */}
<div className="space-y-2">
<Label>Country</Label>
<div className="relative">
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.country ?? ""}
placeholder="Enter country"
className="pl-10"
/>
</div>
</div>
{/* Address */}
<div className="space-y-2 md:col-span-2">
<Label>Address</Label>
<Textarea
defaultValue={customer?.address ?? ""}
placeholder="Enter address"
/>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={customer?.notes ?? ""}
placeholder="Additional notes..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,624 +0,0 @@
import type { ReactNode } from "react";
import { useState } from "react";
import {
Dialog,
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 {
Building2,
Mail,
Phone,
User,
Globe,
MapPin,
FileText,
CreditCard,
Briefcase,
Users,
UserCircle,
StickyNote,
} from "lucide-react";
import { z } from "zod";
import { URL_CONSTANTS } from "@/constants/URLS";
import { customersService } from "@/services/customers.service";
import { useNavigate } from "react-router-dom";
export interface CustomerFormData {
firstName: string;
lastName: string;
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
contactPersonPhone: string;
tinNumber: string;
vatNumber: string;
fanNumber: string;
generalManagerName: string;
generalManagerEmail: string;
generalManagerPhone: string;
poaName?: string;
poaPhone?: string;
poaAddress?: string;
poaEmail?: string;
poaLocation?: string;
notes?: string;
}
export interface NewCustomerPageProps {
mode?: "create" | "edit";
customer?: Partial<CustomerFormData>;
children?: ReactNode;
}
export default function NewCustomerPage({
mode = "create",
customer,
children,
}: NewCustomerPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit
? "Update existing customer information."
: "Create and manage customer information.";
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
const currentUser = JSON.parse(localStorage.getItem("currentUser")?? "{}");
console.log(currentUser)
const [formData, setFormData] = useState<CustomerFormData>({
firstName: currentUser?.name?.en?.split(" ")?.[0] ?? "",
lastName: currentUser?.name?.en?.split(" ")?.[1] ?? "",
email: currentUser?.email ?? "",
phone: currentUser?.phoneNumber ?? "",
companyName: customer?.companyName ?? "",
companyEmail: customer?.companyEmail ?? "",
companyPhone: customer?.companyPhone ?? "",
companyLocation: customer?.companyLocation ?? "",
companyAddress: customer?.companyAddress ?? "",
contactPersonName: customer?.contactPersonName ?? "",
contactPersonPhone: customer?.contactPersonPhone ?? "",
tinNumber: customer?.tinNumber ?? "",
vatNumber: customer?.vatNumber ?? "",
fanNumber: customer?.fanNumber ?? "",
generalManagerName: customer?.generalManagerName ?? "",
generalManagerEmail: customer?.generalManagerEmail ?? "",
generalManagerPhone: customer?.generalManagerPhone ?? "",
poaName: customer?.poaName ?? "",
poaPhone: customer?.poaPhone ?? "",
poaAddress: customer?.poaAddress ?? "",
poaEmail: customer?.poaEmail ?? "",
poaLocation: customer?.poaLocation ?? "",
notes: customer?.notes ?? "",
});
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const validateForm = (): boolean => {
const {
companyName,
companyEmail,
companyPhone,
companyLocation,
companyAddress,
contactPersonName,
contactPersonPhone,
tinNumber,
vatNumber,
fanNumber,
generalManagerName,
generalManagerEmail,
generalManagerPhone,
} = formData;
if (
!companyName ||
!companyEmail ||
!companyPhone ||
!companyLocation ||
!companyAddress ||
!contactPersonName ||
!contactPersonPhone ||
!tinNumber ||
!vatNumber ||
!fanNumber ||
!generalManagerName ||
!generalManagerEmail ||
!generalManagerPhone
) {
alert("Please fill all mandatory fields.");
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(companyEmail)) {
alert("Please enter a valid email address.");
return false;
}
if (!emailRegex.test(companyEmail)) {
alert("Please enter a valid company email address.");
return false;
}
if (!emailRegex.test(generalManagerEmail)) {
alert("Please enter a valid general manager email address.");
return false;
}
if (tinNumber.length !== 10 || !/^\d+$/.test(tinNumber)) {
alert("TIN must be exactly 10 digits.");
return false;
}
if (fanNumber.length !== 16 || !/^\d+$/.test(fanNumber)) {
alert("FAN must be exactly 16 digits.");
return false;
}
return true;
};
const handleSubmit = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
const apiUrl = `${import.meta.env.VITE_API_URL}/api${URL_CONSTANTS.CUSTOMERS.BASE}`;
// const response = await fetch(apiUrl, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({...formData, userId: currentUser?.id}),
// });
const response = await customersService.create({...formData, userId: currentUser?.id})
console.log(";;;;", response)
if(response){
// navigate("/")
window.navigation.reload();
}
if (!response) {
// throw new Error(data.message || `Failed to ${isEdit ? 'update' : 'create'} customer`);
}
// console.log(`Customer ${isEdit ? 'updated' : 'created'}:`, data);
alert(`Customer ${isEdit ? 'updated' : 'created'} successfully!`);
// Close dialog or reset form here if needed
} catch (error) {
console.error('Error:', error);
alert(error instanceof Error ? error.message : `Failed to ${isEdit ? 'update' : 'create'} customer`);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Personal Information Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3">
<User className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Personal Information</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* First Name */}
<div className="space-y-2">
<Label>First Name <span className="text-red-500">*</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter first name"
className="pl-10"
/>
</div>
</div>
{/* Last Name */}
<div className="space-y-2">
<Label>Last Name <span className="text-red-500">*</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter last name"
className="pl-10"
/>
</div>
</div>
{/* Email */}
<div className="space-y-2">
<Label>Email <span className="text-red-500">*</span></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter email"
className="pl-10"
/>
</div>
</div>
{/* Phone */}
<div className="space-y-2">
<Label>Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="Enter phone number"
className="pl-10"
/>
</div>
</div>
</div>
</div>
{/* Company Information Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<Building2 className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Company Information</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* Company Name */}
<div className="space-y-2">
<Label>Company Name <span className="text-red-500">*</span></Label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="companyName"
value={formData.companyName}
onChange={handleChange}
placeholder="Enter company name"
className="pl-10"
/>
</div>
</div>
{/* Company Email */}
<div className="space-y-2">
<Label>Company Email <span className="text-red-500">*</span></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
name="companyEmail"
value={formData.companyEmail}
onChange={handleChange}
placeholder="Enter company email"
className="pl-10"
/>
</div>
</div>
{/* Company Phone */}
<div className="space-y-2">
<Label>Company Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="companyPhone"
value={formData.companyPhone}
onChange={handleChange}
placeholder="Enter company phone"
className="pl-10"
/>
</div>
</div>
{/* Company Location */}
<div className="space-y-2">
<Label>Company Location <span className="text-red-500">*</span></Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="companyLocation"
value={formData.companyLocation}
onChange={handleChange}
placeholder="Enter company location"
className="pl-10"
/>
</div>
</div>
{/* Company Address */}
<div className="space-y-2 md:col-span-2">
<Label>Company Address <span className="text-red-500">*</span></Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
name="companyAddress"
value={formData.companyAddress}
onChange={handleChange}
placeholder="Enter company address"
className="pl-10 resize-none"
rows={2}
/>
</div>
</div>
</div>
</div>
{/* Tax & Registration Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<CreditCard className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Tax & Registration Numbers</h3>
</div>
<div className="grid gap-5 md:grid-cols-3">
{/* TIN Number */}
<div className="space-y-2">
<Label>TIN Number <span className="text-red-500">*</span></Label>
<Input
name="tinNumber"
value={formData.tinNumber}
onChange={handleChange}
placeholder="10-digit TIN"
maxLength={10}
/>
</div>
{/* VAT Number */}
<div className="space-y-2">
<Label>VAT Number <span className="text-red-500">*</span></Label>
<Input
name="vatNumber"
value={formData.vatNumber}
onChange={handleChange}
placeholder="Enter VAT number"
/>
</div>
{/* FAN Number */}
<div className="space-y-2">
<Label>FAN Number <span className="text-red-500">*</span></Label>
<Input
name="fanNumber"
value={formData.fanNumber}
onChange={handleChange}
placeholder="16-digit FAN"
maxLength={16}
/>
</div>
</div>
</div>
{/* General Manager Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<Briefcase className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">General Manager</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* General Manager Name */}
<div className="space-y-2">
<Label>General Manager Name <span className="text-red-500">*</span></Label>
<div className="relative">
<UserCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="generalManagerName"
value={formData.generalManagerName}
onChange={handleChange}
placeholder="Enter general manager name"
className="pl-10"
/>
</div>
</div>
{/* General Manager Email */}
<div className="space-y-2">
<Label>General Manager Email <span className="text-red-500">*</span></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
name="generalManagerEmail"
value={formData.generalManagerEmail}
onChange={handleChange}
placeholder="Enter general manager email"
className="pl-10"
/>
</div>
</div>
{/* General Manager Phone */}
<div className="space-y-2">
<Label>General Manager Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="generalManagerPhone"
value={formData.generalManagerPhone}
onChange={handleChange}
placeholder="Enter general manager phone"
className="pl-10"
/>
</div>
</div>
</div>
</div>
{/* Contact Person Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<Users className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Contact Person</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* Contact Person Name */}
<div className="space-y-2">
<Label>Contact Person Name <span className="text-red-500">*</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="contactPersonName"
value={formData.contactPersonName}
onChange={handleChange}
placeholder="Enter contact person name"
className="pl-10"
/>
</div>
</div>
{/* Contact Person Phone */}
<div className="space-y-2">
<Label>Contact Person Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="contactPersonPhone"
value={formData.contactPersonPhone}
onChange={handleChange}
placeholder="Enter contact person phone"
className="pl-10"
/>
</div>
</div>
</div>
</div>
{/* Power of Attorney Section (Optional) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<FileText className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Power of Attorney (Optional)</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* POA Name */}
<div className="space-y-2">
<Label>PoA Name</Label>
<Input
name="poaName"
value={formData.poaName ?? ""}
onChange={handleChange}
placeholder="Enter PoA name"
/>
</div>
{/* POA Phone */}
<div className="space-y-2">
<Label>PoA Phone</Label>
<Input
name="poaPhone"
value={formData.poaPhone ?? ""}
onChange={handleChange}
placeholder="Enter PoA phone"
/>
</div>
{/* POA Email */}
<div className="space-y-2">
<Label>PoA Email</Label>
<Input
type="email"
name="poaEmail"
value={formData.poaEmail ?? ""}
onChange={handleChange}
placeholder="Enter PoA email"
/>
</div>
{/* POA Location */}
<div className="space-y-2">
<Label>PoA Location</Label>
<Input
name="poaLocation"
value={formData.poaLocation ?? ""}
onChange={handleChange}
placeholder="Enter PoA location"
/>
</div>
{/* POA Address */}
<div className="space-y-2 md:col-span-2">
<Label>PoA Address</Label>
<Textarea
name="poaAddress"
value={formData.poaAddress ?? ""}
onChange={handleChange}
placeholder="Enter PoA address"
rows={2}
/>
</div>
</div>
</div>
{/* Notes Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<StickyNote className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Additional Notes</h3>
</div>
<Textarea
name="notes"
value={formData.notes ?? ""}
onChange={handleChange}
placeholder="Add any additional notes about the customer..."
rows={3}
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-4">
<Button variant="outline">Cancel</Button>
<Button
className="bg-[#10B981] text-white hover:bg-[#10B981]/90"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? "Submitting..." : submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,273 +0,0 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
Building2,
FileText,
Globe,
Loader2,
Mail,
MapPin,
Phone,
StickyNote,
Trash2,
User,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
import type { CustomerStatus } from "@/types/customers";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: customer, isLoading, isError, error } = useCustomer(id);
const deleteMutation = useDeleteCustomer();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
/>
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
Loading customer
</div>
</div>
</div>
);
}
if (isError || !customer) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: "Not found" },
]}
/>
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
<h1 className="text-2xl font-bold text-slate-900">
{isError ? "Failed to load customer" : "Customer not found"}
</h1>
<p className="mt-2 text-sm text-slate-500">
{isError && error instanceof Error
? error.message
: "The customer you're looking for doesn't exist or has been removed."}
</p>
<Link
to="/customers"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Link>
</div>
</div>
</div>
);
}
const handleDelete = () => {
deleteMutation.mutate(customer.id, {
onSuccess: () => navigate("/customers"),
});
};
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: customer.name },
]}
/>
{/* Header */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
<User className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
{customer.name}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
<span className="text-slate-300"></span>
<span>{customer.company ?? "—"}</span>
<span className="text-slate-300"></span>
<StatusBadge status={customer.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setEditOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
Edit Customer
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</div>
</div>
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Company Information">
<DetailRow
icon={<Building2 className="h-4 w-4" />}
label="Company Name"
value={customer.company ?? "—"}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="Customer Type"
value={customer.customerType}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="TIN Number"
value={customer.tinNumber ?? "—"}
/>
</DetailCard>
<DetailCard title="Contact">
<DetailRow
icon={<User className="h-4 w-4" />}
label="Contact Person"
value={customer.name}
/>
<DetailRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={customer.email}
/>
<DetailRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={customer.phone}
/>
</DetailCard>
<DetailCard title="Location">
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="City"
value={customer.city ?? "—"}
/>
<DetailRow
icon={<Globe className="h-4 w-4" />}
label="Country"
value={customer.country ?? "—"}
/>
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="Address"
value={customer.address ?? "—"}
/>
</DetailCard>
<DetailCard title="Notes">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
</div>
</DetailCard>
</div>
</div>
<NewCustomerPage
mode="edit"
customer={customer}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteCustomerDialog
customerName={customer.name}
onConfirm={handleDelete}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,386 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Clock3,
Eye,
Filter,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
User,
UserCheck,
Users,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
import type { Customer, CustomerStatus } from "@/types/customers";
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" | "delete";
export default function CustomerPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
// Defer past the DropdownMenu close cycle so Radix doesn't leave
// `pointer-events: none` on <body>.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveCustomer(customer);
setActiveDialog(dialog);
});
});
};
const closeDialog = () => setActiveDialog(null);
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const { data, isLoading, isError, error } = useCustomers();
const deleteMutation = useDeleteCustomer();
const customers = useMemo<Customer[]>(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return customers;
return customers.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
(c.company ?? "").toLowerCase().includes(q) ||
c.phone.toLowerCase().includes(q),
);
}, [customers, 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 activeCount = customers.filter((c) => c.status === "Active").length;
const pendingCount = customers.filter((c) => c.status === "Pending").length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<Customer>[] = [
{
accessorKey: "name",
header: "Customer",
cell: ({ row }) => {
const customer = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
<User className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{customer.name}</p>
<p className="text-sm text-slate-500">
{customer.company ?? "—"}
</p>
</div>
</div>
);
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.email}</span>
),
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.phone}</span>
),
},
{
accessorKey: "customerType",
header: "Type",
cell: ({ row }) => (
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
{row.original.customerType}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const customer = 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
onSelect={() => navigate(`/customers/${customer.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("edit", customer)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", customer)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Customers" }]} />
<Card className="p-6 flex-row justify-between ">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Customers
</h1>
<p className="mt-1 text-sm text-secondary-foreground ">
Manage and monitor your customer records.
</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 customers..."
className="pl-8!"
/>
</div>
<NewCustomerPage>
<Button>
<Plus />
Add Customer
</Button>
</NewCustomerPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Customers"
value={customers.length}
icon={<Users className="h-5 w-5" />}
/>
<StatCard
title="Active Accounts"
value={activeCount}
icon={<UserCheck className="h-5 w-5" />}
/>
<StatCard
title="Pending Requests"
value={pendingCount}
icon={<Clock3 className="h-5 w-5" />}
/>
</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 customers.{" "}
{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>Customer List</CardTitle>
<CardDescription>
Recent customer activities and records.
</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 customers
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={(row) => navigate(`/customers/${row.id}`)}
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>
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
unmount + pointer-events conflict). */}
{activeCustomer ? (
<>
<NewCustomerPage
key={`edit-${activeCustomer.id}`}
mode="edit"
customer={activeCustomer}
open={activeDialog === "edit"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<DeleteCustomerDialog
key={`delete-${activeCustomer.id}`}
customerName={activeCustomer.name}
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent 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-primary text-white">
{icon}
</div>
</CardContent>
</Card>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,72 +0,0 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Button,
} from "@edr/ui-common";
export interface DeleteCustomerDialogProps {
customerName: string;
onConfirm?: () => void;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteCustomerDialog({
customerName,
onConfirm,
children,
open: openProp,
onOpenChange,
}: DeleteCustomerDialogProps) {
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>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete customer?
</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
from your records. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,384 +0,0 @@
import { useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
Button,
Textarea,
SmartFileInput,
} from "@edr/ui-common";
import {
Building2,
Mail,
Phone,
User,
Globe,
MapPin,
FileText,
} from "lucide-react";
import {
useCreateCustomer,
useUpdateCustomer,
} from "@/hooks/useCustomers";
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
import type {
CreateCustomerDto,
Customer,
CustomerStatus,
CustomerType,
} from "@/types/customers";
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
export interface NewCustomerPageProps {
mode?: "create" | "edit";
customer?: Customer;
children?: ReactNode;
/** Controlled open. When omitted, the dialog manages its own open state. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
type FormState = {
name: string;
email: string;
phone: string;
company: string;
customerType: CustomerType;
status: CustomerStatus;
tinNumber: string;
city: string;
country: string;
address: string;
notes: string;
};
const emptyForm = (): FormState => ({
name: "",
email: "",
phone: "",
company: "",
customerType: "Importer",
status: "Active",
tinNumber: "",
city: "",
country: "",
address: "",
notes: "",
});
const fromCustomer = (c: Customer): FormState => ({
name: c.name ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
company: c.company ?? "",
customerType: c.customerType ?? "Importer",
status: c.status ?? "Active",
tinNumber: c.tinNumber ?? "",
city: c.city ?? "",
country: c.country ?? "",
address: c.address ?? "",
notes: c.notes ?? "",
});
export default function NewCustomerPage({
mode = "create",
customer,
children,
open: openProp,
onOpenChange,
}: NewCustomerPageProps = {}) {
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 [form, setForm] = useState<FormState>(
customer ? fromCustomer(customer) : emptyForm(),
);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<Record<string, File | File[] | null>>({});
// Reset form whenever the dialog opens with a different customer.
useEffect(() => {
if (open) {
setForm(customer ? fromCustomer(customer) : emptyForm());
setError(null);
}
}, [open, customer]);
const { data: customerRegistrationFiles } = useQuery(
getFileUploadSettingByCode.queryOptions({
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
}),
);
const createMutation = useCreateCustomer();
const updateMutation = useUpdateCustomer();
const pending = createMutation.isPending || updateMutation.isPending;
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = () => {
setError(null);
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
setError("Name, email, and phone are required.");
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
setError("Please enter a valid email address.");
return;
}
const payload: CreateCustomerDto = {
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
customerType: form.customerType,
status: form.status,
company: form.company.trim() || undefined,
tinNumber: form.tinNumber.trim() || undefined,
city: form.city.trim() || undefined,
country: form.country.trim() || undefined,
address: form.address.trim() || undefined,
notes: form.notes.trim() || undefined,
};
const onDone = () => {
setOpen(false);
if (!isEdit) setForm(emptyForm());
};
const onError = (err: unknown) => {
setError(
err instanceof Error
? err.message
: "Something went wrong. Try again.",
);
};
if (isEdit && customer) {
updateMutation.mutate(
{ id: customer.id, dto: payload },
{ onSuccess: onDone, onError },
);
} else {
createMutation.mutate(payload, { onSuccess: onDone, onError });
}
};
const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit
? "Update existing customer information."
: "Create and manage customer information.";
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
return (
<Dialog open={open} onOpenChange={setOpen}>
{!isControlled ? (
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
) : null}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
<Field label="Company Name">
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.company}
onChange={(e) => set("company", e.target.value)}
placeholder="Enter company name"
className="pl-10"
/>
</Field>
<div className="space-y-2">
<Label>Customer Type *</Label>
<select
value={form.customerType}
onChange={(e) =>
set("customerType", e.target.value as CustomerType)
}
className="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"
>
{CUSTOMER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<Field label="Contact Person *">
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="Enter contact person"
className="pl-10"
/>
</Field>
<Field label="Email *">
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
type="email"
value={form.email}
onChange={(e) => set("email", e.target.value)}
placeholder="Enter email"
className="pl-10"
/>
</Field>
<Field label="Phone *">
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.phone}
onChange={(e) => set("phone", e.target.value)}
placeholder="Enter phone"
className="pl-10"
/>
</Field>
<Field label="TIN Number">
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.tinNumber}
onChange={(e) => set("tinNumber", e.target.value)}
placeholder="Enter TIN number"
className="pl-10"
/>
</Field>
<Field label="City">
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.city}
onChange={(e) => set("city", e.target.value)}
placeholder="Enter city"
className="pl-10"
/>
</Field>
<Field label="Country">
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.country}
onChange={(e) => set("country", e.target.value)}
placeholder="Enter country"
className="pl-10"
/>
</Field>
<div className="space-y-2">
<Label>Status</Label>
<select
value={form.status}
onChange={(e) => set("status", e.target.value as CustomerStatus)}
className="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"
>
{CUSTOMER_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Address</Label>
<Textarea
value={form.address}
onChange={(e) => set("address", e.target.value)}
placeholder="Enter address"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => set("notes", e.target.value)}
placeholder="Additional notes..."
/>
</div>
</div>
{customerRegistrationFiles ? (
<div>
<SmartFileInput
file={customerRegistrationFiles}
value={files}
onChange={setFiles}
/>
</div>
) : null}
{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">
<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" />
) : (
submitLabel
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="relative">{children}</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
export type CustomerStatus = "Active" | "Pending" | "Inactive";
export type CustomerType = "Importer" | "Exporter" | "Supplier";
export interface Customer {
id: number;
name: string;
email: string;
company: string;
status: CustomerStatus;
customerType: CustomerType;
phone: string;
tinNumber: string;
city: string;
country: string;
address: string;
notes: string;
}
const seedCustomers: Customer[] = [
{
id: 1,
name: "Abel Tesfaye",
email: "abel@example.com",
company: "Addis Logistics",
status: "Active",
customerType: "Importer",
phone: "+251 911 234 567",
tinNumber: "0012345678",
city: "Addis Ababa",
country: "Ethiopia",
address: "Bole Road, Sub-City 03, Building 17",
notes: "Top-tier importer. Prefers weekly invoicing.",
},
{
id: 2,
name: "Sara Bekele",
email: "sara@example.com",
company: "Blue Nile Trading",
status: "Pending",
customerType: "Exporter",
phone: "+251 922 345 678",
tinNumber: "0023456789",
city: "Dire Dawa",
country: "Ethiopia",
address: "Industrial Park, Zone B, Warehouse 4",
notes: "Awaiting compliance documents.",
},
{
id: 3,
name: "Henok Alemu",
email: "henok@example.com",
company: "Ethio Freight",
status: "Inactive",
customerType: "Supplier",
phone: "+251 933 456 789",
tinNumber: "0034567890",
city: "Djibouti City",
country: "Djibouti",
address: "Port Quarter, Avenue 26, Block 9",
notes: "Account paused since last quarter.",
},
];
const extras: Array<{ name: string; company: string; city: string; country: string }> = [
{ name: "Yohannes Girma", company: "Habesha Imports", city: "Addis Ababa", country: "Ethiopia" },
{ name: "Meron Asfaw", company: "Sheba Trading", city: "Adama", country: "Ethiopia" },
{ name: "Daniel Kebede", company: "Awash Cargo", city: "Hawassa", country: "Ethiopia" },
{ name: "Liya Tadesse", company: "Lalibela Logistics", city: "Bahir Dar", country: "Ethiopia" },
{ name: "Samuel Worku", company: "Rift Valley Freight", city: "Mekelle", country: "Ethiopia" },
{ name: "Hanna Mulugeta", company: "Simien Exports", city: "Gondar", country: "Ethiopia" },
{ name: "Bereket Hailu", company: "Omo River Co.", city: "Jimma", country: "Ethiopia" },
{ name: "Tigist Wolde", company: "Tana Shipping", city: "Dessie", country: "Ethiopia" },
{ name: "Kalkidan Mesfin", company: "Coffee Belt Traders", city: "Addis Ababa", country: "Ethiopia" },
{ name: "Nahom Solomon", company: "Highland Freight", city: "Harar", country: "Ethiopia" },
{ name: "Ali Mohamed", company: "Red Sea Cargo", city: "Djibouti City", country: "Djibouti" },
{ name: "Fatima Hassan", company: "Gulf Logistics", city: "Tadjoura", country: "Djibouti" },
{ name: "Omar Ibrahim", company: "Bab-el-Mandeb Trading", city: "Ali Sabieh", country: "Djibouti" },
{ name: "Amina Said", company: "Horn of Africa Imports", city: "Dikhil", country: "Djibouti" },
{ name: "Yusuf Abdulahi", company: "Saharan Exports", city: "Obock", country: "Djibouti" },
{ name: "Selam Negash", company: "Equator Freight", city: "Arba Minch", country: "Ethiopia" },
{ name: "Mikias Lemma", company: "Gibe Trading", city: "Sodo", country: "Ethiopia" },
];
const statuses: CustomerStatus[] = ["Active", "Pending", "Inactive"];
const types: CustomerType[] = ["Importer", "Exporter", "Supplier"];
const generated: Customer[] = extras.map((entry, i) => {
const id = seedCustomers.length + i + 1;
return {
id,
name: entry.name,
email: `${entry.name.toLowerCase().replace(/\s+/g, ".")}@example.com`,
company: entry.company,
status: statuses[i % statuses.length] as CustomerStatus,
customerType: types[i % types.length] as CustomerType,
phone: `+251 9${String(40 + i).padStart(2, "0")} ${String(100 + i * 13).slice(0, 3)} ${String(200 + i * 17).slice(0, 3)}`,
tinNumber: String(40000000 + i * 12345).padStart(10, "0"),
city: entry.city,
country: entry.country,
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
notes: `Mock customer #${id}.`,
};
});
export const customers: Customer[] = [...seedCustomers, ...generated];
export function getCustomerById(id: number | string): Customer | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return customers.find((c) => c.id === numericId);
}

View File

@@ -1,676 +0,0 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
ArrowDownRight,
ArrowRight,
ArrowUpRight,
CheckCircle2,
CircleDot,
DollarSign,
Package,
Truck,
Users,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import { bookings } from "../bookings/bookings.mock";
import { consignments } from "../consignments/consignments.mock";
import { customers } from "../customers/customers.mock";
import { invoices } from "../billing/invoices.mock";
import { shipments } from "../tracking/shipments.mock";
import { trains } from "../trains/trains.mock";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@edr/ui-common";
const BRAND = "#10B981";
const BRAND_LIGHT = "#6EE7B7";
const BRAND_LIGHTER = "#D1FAE5";
const STATUS_PALETTE: Record<string, string> = {
Pending: "#d97706",
Confirmed: "#0ea5e9",
"In Transit": "#6366f1",
Delivered: "#059669",
Cancelled: "#dc2626",
};
export default function DashboardPage() {
const totalRevenue = useMemo(
() =>
invoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0),
[],
);
const activeShipments = shipments.filter(
(s) => s.status === "In Transit",
).length;
const onTimeRate = Math.round(
(shipments.filter((s) => s.status !== "Delayed").length /
shipments.length) *
100,
);
const bookingsTrend = useMemo(() => {
const months = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
const total = bookings.length;
return months.map((label, i) => ({
month: label,
bookings: Math.round(total * (0.5 + i * 0.12) + i * 3),
delivered: Math.round(total * (0.3 + i * 0.1) + i * 2),
}));
}, []);
const revenueByCurrency = useMemo(() => {
const buckets = new Map<string, number>();
invoices.forEach((inv) => {
if (inv.status !== "Paid") return;
buckets.set(
inv.currency,
(buckets.get(inv.currency) ?? 0) + inv.amount,
);
});
return Array.from(buckets.entries()).map(([currency, value]) => ({
currency,
value: Math.round(value),
}));
}, []);
const bookingStatusData = useMemo(() => {
const buckets = new Map<string, number>();
bookings.forEach((b) => {
buckets.set(b.status, (buckets.get(b.status) ?? 0) + 1);
});
return Array.from(buckets.entries()).map(([status, count]) => ({
name: status,
value: count,
color: STATUS_PALETTE[status] ?? BRAND,
}));
}, []);
const cargoTypeData = useMemo(() => {
const buckets = new Map<string, number>();
bookings.forEach((b) => {
buckets.set(b.cargoType, (buckets.get(b.cargoType) ?? 0) + 1);
});
return Array.from(buckets.entries())
.map(([cargo, count]) => ({ cargo, count }))
.sort((a, b) => b.count - a.count);
}, []);
const corridorPerformance = useMemo(() => {
const weeks = ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"];
return weeks.map((label, i) => ({
week: label,
"Addis → Djibouti": 80 + Math.round(Math.sin(i / 2) * 8 + i * 1.2),
"Dire Dawa → Djibouti": 72 + Math.round(Math.cos(i / 2) * 6 + i * 0.8),
"Adama → Dire Dawa": 65 + Math.round(Math.sin(i / 3) * 10 + i * 1.4),
}));
}, []);
const fleetUtilization = useMemo(() => {
const total = trains.length;
return [
{
name: "Operational",
value: trains.filter((t) => t.status === "Operational").length,
color: "#059669",
},
{
name: "Idle",
value: trains.filter((t) => t.status === "Idle").length,
color: "#475569",
},
{
name: "Maintenance",
value: trains.filter((t) => t.status === "In Maintenance").length,
color: "#d97706",
},
{
name: "Out of Service",
value: trains.filter((t) => t.status === "Out of Service").length,
color: "#dc2626",
},
].filter((entry) => entry.value > 0);
}, []);
const recentShipments = useMemo(
() => [...shipments].slice(-5).reverse(),
[],
);
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Dashboard" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Freight Dashboard
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Operational overview · today · all corridors
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
Live
</span>
<span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
Last 30 days
</span>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="Total Revenue (USD)"
value={`$${totalRevenue.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`}
delta="+12.4%"
trend="up"
icon={<DollarSign />}
/>
<KpiCard
label="Active Bookings"
value={String(
bookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
).length,
)}
delta="+5.1%"
trend="up"
icon={<Package />}
/>
<KpiCard
label="Active Shipments"
value={String(activeShipments)}
delta="-2.3%"
trend="down"
icon={<Truck />}
/>
<KpiCard
label="On-time Rate"
value={`${onTimeRate}%`}
delta="+1.8%"
trend="up"
icon={<CheckCircle2 />}
/>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<ChartCard
title="Bookings vs Deliveries"
subtitle="Last 6 months"
className="lg:col-span-2"
>
<ResponsiveContainer width="100%" height={280}>
<AreaChart
data={bookingsTrend}
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
>
<defs>
<linearGradient id="brandFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BRAND} stopOpacity={0.5} />
<stop offset="100%" stopColor={BRAND} stopOpacity={0} />
</linearGradient>
<linearGradient id="lightFill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={BRAND_LIGHT}
stopOpacity={0.4}
/>
<stop
offset="100%"
stopColor={BRAND_LIGHT}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="month"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "12px" }}
iconType="circle"
/>
<Area
type="monotone"
dataKey="bookings"
stroke={BRAND}
strokeWidth={2}
fill="url(#brandFill)"
name="Bookings"
/>
<Area
type="monotone"
dataKey="delivered"
stroke={BRAND_LIGHT}
strokeWidth={2}
fill="url(#lightFill)"
name="Delivered"
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Booking Status" subtitle="Current distribution">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={bookingStatusData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={90}
paddingAngle={3}
dataKey="value"
>
{bookingStatusData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "11px" }}
iconType="circle"
/>
</PieChart>
</ResponsiveContainer>
</ChartCard>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<ChartCard
title="Corridor On-time %"
subtitle="Weekly performance, top 3 corridors"
className="lg:col-span-2"
>
<ResponsiveContainer width="100%" height={280}>
<LineChart
data={corridorPerformance}
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="week"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis
stroke="#94a3b8"
style={{ fontSize: "12px" }}
domain={[0, 100]}
unit="%"
/>
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "12px" }}
iconType="circle"
/>
<Line
type="monotone"
dataKey="Addis → Djibouti"
stroke={BRAND}
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="Dire Dawa → Djibouti"
stroke="#059669"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="Adama → Dire Dawa"
stroke="#d97706"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Cargo Mix" subtitle="Bookings by cargo type">
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={cargoTypeData}
layout="vertical"
margin={{ top: 0, right: 20, left: 30, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e2e8f0"
horizontal={false}
/>
<XAxis
type="number"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis
type="category"
dataKey="cargo"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
width={100}
/>
<Tooltip
cursor={{ fill: "#10B98114" }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Bar
dataKey="count"
fill={BRAND}
radius={[0, 8, 8, 0]}
/>
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<ChartCard
title="Revenue by Currency"
subtitle="Paid invoices, all time"
>
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={revenueByCurrency}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="currency"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
<Tooltip
cursor={{ fill: "#10B98114" }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
{revenueByCurrency.map((_, i) => (
<Cell
key={i}
fill={[BRAND, BRAND_LIGHT, BRAND_LIGHTER][i % 3]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Train Status" subtitle={`${trains.length} units`}>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={fleetUtilization}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={3}
dataKey="value"
>
{fleetUtilization.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "11px" }}
iconType="circle"
/>
</PieChart>
</ResponsiveContainer>
</ChartCard>
<Card className="p-6">
<CardHeader className="flex flex-row items-center justify-between px-0">
<div>
<CardTitle>Recent Shipments</CardTitle>
<CardDescription>Latest activity</CardDescription>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-xs font-medium text-primary transition hover:underline"
>
View all
<ArrowRight />
</Link>
</CardHeader>
<CardContent className="px-0">
<ul className="space-y-3">
{recentShipments.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 rounded-2xl border p-3"
>
<div className="flex items-center gap-3 overflow-hidden">
<CircleDot
style={{
color:
s.status === "Delivered"
? "#059669"
: s.status === "Delayed"
? "#dc2626"
: "#6366f1",
}}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900">
{s.reference}
</p>
<p className="truncate text-xs text-slate-500">
{s.originStation} {s.destinationStation}
</p>
</div>
</div>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{s.progress}%
</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-4">
<SummaryCard
label="Customers"
value={String(customers.length)}
href="/customers"
icon={<Users />}
/>
<SummaryCard
label="Consignments"
value={String(consignments.length)}
href="/consignments"
icon={<Package />}
/>
<SummaryCard
label="Trains in Fleet"
value={String(trains.length)}
href="/trains"
icon={<Truck />}
/>
<SummaryCard
label="Open Invoices"
value={String(
invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
).length,
)}
href="/billing"
icon={<DollarSign />}
/>
</div>
</div>
</div>
);
}
function KpiCard({
label,
value,
delta,
trend,
icon,
}: {
label: string;
value: string;
delta: string;
trend: "up" | "down";
icon: React.ReactNode;
}) {
const trendColor = trend === "up" ? "text-emerald-600" : "text-red-600";
const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight;
return (
<Card>
<CardContent className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
<div
className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${trendColor}`}
>
<TrendIcon />
{delta}
<span className="text-slate-400">vs last period</span>
</div>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
{icon}
</div>
</CardContent>
</Card>
);
}
function ChartCard({
title,
subtitle,
children,
className,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{subtitle ? <CardDescription>{subtitle}</CardDescription> : null}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function SummaryCard({
label,
value,
href,
icon,
}: {
label: string;
value: string;
href: string;
icon: React.ReactNode;
}) {
return (
<Link
to={href}
className="flex items-center justify-between rounded-xl border bg-card px-4 py-4 text-card-foreground shadow-xs transition hover:bg-accent"
>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary text-primary-foreground">
{icon}
</div>
<div>
<p className="text-xs text-slate-500">{label}</p>
<p className="text-lg font-bold text-slate-900">{value}</p>
</div>
</div>
<ArrowRight className="text-slate-400" />
</Link>
);
}

View File

@@ -1,61 +0,0 @@
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 DeleteDocumentDialogProps {
documentName: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteDocumentDialog({
documentName,
onConfirm,
children,
}: DeleteDocumentDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete document?
</DialogTitle>
<DialogDescription>
This will permanently delete{" "}
<span className="font-semibold text-slate-900">{documentName}</span>{" "}
and remove it from object storage. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,576 +0,0 @@
import { useMemo, useState } from "react";
import {
CheckCircle2,
Clock,
Download,
Eye,
File,
FileImage,
FileSpreadsheet,
FileText,
Filter,
HardDrive,
LayoutGrid,
List,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewDocumentPage from "./NewDocumentPage";
import DeleteDocumentDialog from "./DeleteDocumentDialog";
import {
documents,
formatBytes,
type DocumentFormat,
type DocumentRecord,
type DocumentStatus,
} from "./documents.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | DocumentStatus;
type ViewMode = "grid" | "table";
const FILTERS: FilterValue[] = [
"All",
"Draft",
"Pending Review",
"Approved",
"Rejected",
"Expired",
];
export default function DocumentsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return documents.filter((d) => {
if (filter !== "All" && d.status !== filter) return false;
if (!q) return true;
return (
d.name.toLowerCase().includes(q) ||
d.type.toLowerCase().includes(q) ||
d.linkedReference.toLowerCase().includes(q) ||
d.uploadedBy.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = 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 totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0);
const approvedCount = documents.filter((d) => d.status === "Approved").length;
const pendingCount = documents.filter(
(d) => d.status === "Pending Review",
).length;
const columns: ColumnDef<DocumentRecord>[] = [
{
id: "document",
header: "Document",
cell: ({ row }) => {
const doc = 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/10">
<FormatIcon format={doc.format} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-slate-900">{doc.name}</p>
<p className="text-xs text-slate-500">
{doc.format} · By {doc.uploadedBy}
</p>
</div>
</div>
);
},
},
{
accessorKey: "type",
header: "Type",
},
{
id: "linkedTo",
header: "Linked To",
cell: ({ row }) => (
<div className="text-sm text-slate-700">
<p>{row.original.linkedReference}</p>
<p className="text-xs text-slate-500">{row.original.linkedType}</p>
</div>
),
},
{
id: "size",
header: "Size",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatBytes(row.original.sizeBytes)}
</span>
),
},
{
accessorKey: "uploadedAt",
header: "Uploaded",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const doc = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
Preview
</DropdownMenuItem>
<DropdownMenuItem>
<Download />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<NewDocumentPage
mode="edit"
document={{
name: doc.name,
type: doc.type,
status: doc.status,
linkedType: doc.linkedType,
linkedReference: doc.linkedReference,
notes: doc.notes,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewDocumentPage>
<DropdownMenuSeparator />
<DeleteDocumentDialog documentName={doc.name}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteDocumentDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Documents" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Documents
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage freight documents linked to bookings, consignments, and
invoices.
</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 documents..."
className="pl-8!"
/>
</div>
<NewDocumentPage>
<Button>
<Plus />
Upload Document
</Button>
</NewDocumentPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Documents</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{documents.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<FileText />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Approved</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{approvedCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<CheckCircle2 />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Pending Review</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{pendingCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Storage Used</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatBytes(totalSize)}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<HardDrive />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-col gap-3 sm:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? documents.length
: documents.filter((d) => d.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
<ViewToggleButton
active={view === "grid"}
onClick={() => setView("grid")}
label="Grid view"
>
<LayoutGrid className="size-4" />
<span className="sm:inline">Grid</span>
</ViewToggleButton>
<ViewToggleButton
active={view === "table"}
onClick={() => setView("table")}
label="Table view"
>
<List className="size-4" />
<span className="sm:inline">Table</span>
</ViewToggleButton>
</div>
</div>
</Card>
{paginatedData.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-sm text-muted-foreground">
No documents match your filters.
</p>
</Card>
) : view === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{paginatedData.map((doc) => (
<DocumentCard key={doc.id} doc={doc} />
))}
</div>
) : (
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Document Library</CardTitle>
<CardDescription>
All freight documents stored in the system.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
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>
</div>
);
}
function ViewToggleButton({
active,
onClick,
label,
children,
}: {
active: boolean;
onClick: () => void;
label: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={active}
className={
active
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-primary shadow-sm"
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-primary"
}
>
{children}
</button>
);
}
function FormatIcon({ format }: { format: DocumentFormat }) {
if (format === "PDF") return <FileText />;
if (format === "DOCX") return <FileText />;
if (format === "XLSX") return <FileSpreadsheet />;
if (format === "PNG" || format === "JPG") return <FileImage />;
return <File />;
}
function DocumentCard({ doc }: { doc: DocumentRecord }) {
return (
<Card className="p-5">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
<FormatIcon format={doc.format} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">
{doc.name}
</p>
<p className="text-xs text-slate-500">{doc.type}</p>
</div>
</div>
<StatusBadge status={doc.status} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaRow
label="Linked to"
value={`${doc.linkedType} · ${doc.linkedReference}`}
/>
<MetaRow label="Format" value={doc.format} />
<MetaRow label="Size" value={formatBytes(doc.sizeBytes)} />
<MetaRow label="Uploaded" value={doc.uploadedAt} />
</div>
<p className="text-xs text-slate-500">By {doc.uploadedBy}</p>
<div
className="flex items-center justify-end gap-2 border-t pt-3"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreHorizontal />
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
Preview
</DropdownMenuItem>
<DropdownMenuItem>
<Download />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<NewDocumentPage
mode="edit"
document={{
name: doc.name,
type: doc.type,
status: doc.status,
linkedType: doc.linkedType,
linkedReference: doc.linkedReference,
notes: doc.notes,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewDocumentPage>
<DropdownMenuSeparator />
<DeleteDocumentDialog documentName={doc.name}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteDocumentDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-0.5 text-sm font-medium text-slate-900">{value}</p>
</div>
);
}
function StatusBadge({ status }: { status: DocumentStatus }) {
const styles: Record<DocumentStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
"Pending Review": "bg-amber-100 text-amber-700",
Approved: "bg-emerald-100 text-emerald-700",
Rejected: "bg-red-100 text-red-700",
Expired: "bg-slate-200 text-slate-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,242 +0,0 @@
import { useState, type ReactNode } from "react";
import { FileUp, Hash } from "lucide-react";
import {
Dialog,
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 { bookings } from "../bookings/bookings.mock";
import { consignments } from "../consignments/consignments.mock";
import { customers } from "../customers/customers.mock";
import type {
DocumentLinkType,
DocumentStatus,
DocumentType,
} from "./documents.mock";
export interface DocumentFormData {
name?: string;
type?: DocumentType;
status?: DocumentStatus;
linkedType?: DocumentLinkType;
linkedReference?: string;
notes?: string;
}
export interface NewDocumentPageProps {
mode?: "create" | "edit";
document?: DocumentFormData;
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";
export default function NewDocumentPage({
mode = "create",
document,
children,
}: NewDocumentPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Document" : "Upload Document";
const description = isEdit
? "Update document metadata."
: "Upload a freight document and link it to a booking, consignment, or invoice.";
const submitLabel = isEdit ? "Save Changes" : "Upload";
const [linkedType, setLinkedType] = useState<DocumentLinkType>(
document?.linkedType ?? "Booking",
);
const [fileName, setFileName] = useState<string>("");
const referenceOptions = (() => {
if (linkedType === "Booking") {
return bookings.map((b) => ({
value: b.reference,
label: `${b.reference}${b.customer}`,
}));
}
if (linkedType === "Consignment") {
return consignments.map((c) => ({
value: c.trackingNumber,
label: `${c.trackingNumber}${c.customer}`,
}));
}
if (linkedType === "Customer") {
return customers.map((c) => ({
value: c.company,
label: c.company,
}));
}
return [] as Array<{ value: string; label: string }>;
})();
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "Upload Document"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* File picker — drop zone */}
{!isEdit ? (
<div className="md:col-span-2">
<Label>File</Label>
<label
htmlFor="document-file-input"
className="mt-1 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-slate-200 bg-[#10B981]/5 p-8 text-center transition hover:border-[#10B981]/40 hover:bg-[#10B981]/10"
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
<FileUp className="h-6 w-6" />
</div>
<p className="text-sm font-medium text-slate-900">
{fileName || "Click to choose a file or drag it here"}
</p>
<p className="text-xs text-slate-500">
PDF, DOCX, XLSX, PNG, JPG · max 25 MB
</p>
<input
id="document-file-input"
type="file"
className="hidden"
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
onChange={(e) =>
setFileName(e.target.files?.[0]?.name ?? "")
}
/>
</label>
</div>
) : null}
{/* Document Name */}
<div className="space-y-2">
<Label>Document Name *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={document?.name ?? ""}
placeholder="e.g. bill-of-lading-bk-026003.pdf"
className="pl-10"
/>
</div>
</div>
{/* Document Type */}
<div className="space-y-2">
<Label>Document Type *</Label>
<select
defaultValue={document?.type ?? "Bill of Lading"}
className={selectClass}
>
<option>Bill of Lading</option>
<option>Commercial Invoice</option>
<option>Packing List</option>
<option>Customs Declaration</option>
<option>Certificate of Origin</option>
<option>Insurance Certificate</option>
<option>Delivery Receipt</option>
<option>Proof of Delivery</option>
<option>Contract</option>
<option>Other</option>
</select>
</div>
{/* Linked To */}
<div className="space-y-2">
<Label>Linked To</Label>
<select
value={linkedType}
onChange={(e) =>
setLinkedType(e.target.value as DocumentLinkType)
}
className={selectClass}
>
<option>Booking</option>
<option>Consignment</option>
<option>Shipment</option>
<option>Customer</option>
<option>Invoice</option>
<option>None</option>
</select>
</div>
{/* Reference */}
<div className="space-y-2">
<Label>Reference</Label>
{referenceOptions.length > 0 ? (
<select
defaultValue={document?.linkedReference ?? ""}
className={selectClass}
>
<option value="" disabled>
Select {linkedType.toLowerCase()}
</option>
{referenceOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Input
defaultValue={document?.linkedReference ?? ""}
placeholder={
linkedType === "None"
? "Not linked"
: `Enter ${linkedType.toLowerCase()} reference`
}
disabled={linkedType === "None"}
/>
)}
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={document?.status ?? "Draft"}
className={selectClass}
>
<option>Draft</option>
<option>Pending Review</option>
<option>Approved</option>
<option>Rejected</option>
<option>Expired</option>
</select>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={document?.notes ?? ""}
placeholder="Any extra context for this document..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,140 +0,0 @@
import { bookings } from "../bookings/bookings.mock";
import { consignments } from "../consignments/consignments.mock";
export type DocumentType =
| "Bill of Lading"
| "Commercial Invoice"
| "Packing List"
| "Customs Declaration"
| "Certificate of Origin"
| "Insurance Certificate"
| "Delivery Receipt"
| "Proof of Delivery"
| "Contract"
| "Other";
export type DocumentFormat = "PDF" | "DOCX" | "XLSX" | "PNG" | "JPG";
export type DocumentStatus =
| "Draft"
| "Pending Review"
| "Approved"
| "Rejected"
| "Expired";
export type DocumentLinkType =
| "Booking"
| "Consignment"
| "Shipment"
| "Customer"
| "Invoice"
| "None";
export interface DocumentRecord {
id: number;
name: string;
type: DocumentType;
format: DocumentFormat;
sizeBytes: number;
linkedType: DocumentLinkType;
linkedReference: string;
uploadedBy: string;
uploadedAt: string;
status: DocumentStatus;
notes: string;
/** Object key — meant for the future MinIO bucket. */
objectKey: string;
}
const types: DocumentType[] = [
"Bill of Lading",
"Commercial Invoice",
"Packing List",
"Customs Declaration",
"Certificate of Origin",
"Insurance Certificate",
"Delivery Receipt",
"Proof of Delivery",
"Contract",
"Other",
];
const formats: DocumentFormat[] = ["PDF", "DOCX", "XLSX", "PNG", "JPG"];
const statuses: DocumentStatus[] = [
"Draft",
"Pending Review",
"Approved",
"Rejected",
"Expired",
];
const uploaders = [
"John Doe",
"Sarah Bekele",
"Michael Chen",
"Aisha Mohamed",
"Daniel Worku",
];
function fileNameFor(type: DocumentType, ref: string, format: DocumentFormat) {
const slug = type.toLowerCase().replace(/\s+/g, "-");
return `${slug}-${ref.toLowerCase()}.${format.toLowerCase()}`;
}
export const documents: DocumentRecord[] = Array.from({ length: 24 }, (_, i) => {
const id = i + 1;
const type = types[i % types.length] as DocumentType;
const format = formats[i % formats.length] as DocumentFormat;
const status = statuses[i % statuses.length] as DocumentStatus;
const uploadedAt = new Date(2026, 4, 1 + (i % 14));
const linkPick = i % 3;
let linkedType: DocumentLinkType;
let linkedReference: string;
if (linkPick === 0) {
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
linkedType = "Booking";
linkedReference = booking.reference;
} else if (linkPick === 1) {
const consignment = consignments[
i % consignments.length
] as (typeof consignments)[number];
linkedType = "Consignment";
linkedReference = consignment.trackingNumber;
} else {
linkedType = "Invoice";
linkedReference = `INV-2026-${String(id).padStart(4, "0")}`;
}
const name = fileNameFor(type, linkedReference, format);
return {
id,
name,
type,
format,
sizeBytes: 50_000 + ((i * 73_421) % 4_000_000),
linkedType,
linkedReference,
uploadedBy: uploaders[i % uploaders.length] as string,
uploadedAt: uploadedAt.toISOString().slice(0, 10),
status,
notes:
i % 3 === 0
? "Original signed copy."
: i % 3 === 1
? "Scanned from physical document."
: "Generated by system.",
objectKey: `edr-freight/${linkedType.toLowerCase()}/${linkedReference}/${name}`,
};
});
export function getDocumentById(id: number | string): DocumentRecord | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return documents.find((d) => d.id === numericId);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -1,486 +0,0 @@
import { useEffect, useMemo } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
DollarSign,
Eye,
Mail,
MapPin,
Package,
Phone,
Plus,
Receipt,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
import { customersService } from "@/services/customers.service";
export default function MyPortalPage() {
const me = useMemo(() => getCurrentCustomer(), []);
const myBookings = useMemo(() => getMyBookings(), []);
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const userId = localStorage.getItem("userId");
useEffect(() => {
customersService.getByUserId(userId || "").then((res: any) => {
}).catch((err) => {
console.error(err)
})
}, [userId]);
const activeBookings = myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
);
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const totalSpent = myInvoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const recentBookings = [...myBookings].slice(0, 5);
const recentInvoices = [...myInvoices].slice(0, 4);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "My Portal" }]} />
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
{me.company.charAt(0)}
</div>
<div>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">
{me.name}
</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<Building2 className="h-4 w-4" />
{me.company}
<span className="text-white/40">·</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
{me.customerType}
</span>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link
to="/bookings/new"
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#10B981] transition hover:bg-slate-100"
>
<Plus className="h-4 w-4" />
New Booking
</Link>
<Link
to="/tracking"
className="inline-flex items-center gap-2 rounded-2xl border border-white/40 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
>
<Truck className="h-4 w-4" />
Track Shipment
</Link>
</div>
</div>
</div>
{/* My KPIs */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="My Active Bookings"
value={String(activeBookings.length)}
sub={`${myBookings.length} total`}
icon={<Package className="h-5 w-5" />}
href="/bookings"
/>
<KpiCard
label="In Transit"
value={String(activeShipments.length)}
sub={`${myShipments.length} shipments`}
icon={<Truck className="h-5 w-5" />}
href="/tracking"
/>
<KpiCard
label="Outstanding"
value={formatCurrency(totalOutstanding, "USD")}
sub={`${outstandingInvoices.length} invoices`}
icon={<DollarSign className="h-5 w-5" />}
href="/billing"
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
/>
<KpiCard
label="Total Spent"
value={formatCurrency(totalSpent, "USD")}
sub="All-time, paid invoices"
icon={<CheckCircle2 className="h-5 w-5" />}
/>
</div>
{/* Active Shipments */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Active Shipments
</h2>
<p className="text-sm text-slate-500">
Live tracking for your in-flight cargo
</p>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{activeShipments.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No shipments currently in transit.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeShipments.slice(0, 4).map((shipment) => (
<div
key={shipment.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-slate-900">
{shipment.reference}
</span>
<ShipmentBadge status={shipment.status} />
</div>
<p className="mt-1 text-sm text-slate-700">
{shipment.originStation}
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
{shipment.destinationStation}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3 text-[#10B981]" />
{shipment.currentLocation}
</span>
<span>ETA {shipment.eta}</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-[#10B981] transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Recent Bookings + Invoices + Profile */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Recent bookings */}
<div className="rounded-3xl bg-white p-6 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Recent Bookings
</h2>
<p className="text-sm text-slate-500">
Your latest freight requests
</p>
</div>
<Link
to="/bookings"
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{recentBookings.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
You haven't booked any freight yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Reference</th>
<th className="py-2 font-medium">Route</th>
<th className="py-2 font-medium">Cargo</th>
<th className="py-2 font-medium">Status</th>
<th className="py-2 text-right font-medium">Action</th>
</tr>
</thead>
<tbody>
{recentBookings.map((booking) => (
<tr
key={booking.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
>
<td className="py-3 font-medium text-slate-900">
{booking.reference}
</td>
<td className="py-3 text-slate-700">
{booking.originStation} {booking.destinationStation}
</td>
<td className="py-3 text-slate-700">
{booking.cargoType}
</td>
<td className="py-3">
<BookingBadge status={booking.status} />
</td>
<td className="py-3 text-right">
<Link
to={`/bookings/${booking.id}`}
aria-label="View booking"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Eye className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Profile card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
My Profile
</h2>
<p className="text-sm text-slate-500">Account information</p>
<div className="mt-4 space-y-3 text-sm">
<ProfileRow
icon={<Building2 className="h-4 w-4" />}
label="Company"
value={me.company}
/>
<ProfileRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={me.email}
/>
<ProfileRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={me.phone}
/>
<ProfileRow
icon={<MapPin className="h-4 w-4" />}
label="Location"
value={`${me.city}, ${me.country}`}
/>
</div>
<Link
to={`/customers/${me.id}`}
className="mt-4 inline-flex w-full items-center justify-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]"
>
View full profile
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
{/* Invoices */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Recent Invoices
</h2>
<p className="text-sm text-slate-500">
{outstandingInvoices.length} outstanding ·{" "}
{myInvoices.length} total
</p>
</div>
<Link
to="/billing"
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{recentInvoices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No invoices yet.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
>
<div className="flex items-center justify-between">
<Receipt className="h-4 w-4 text-[#10B981]" />
<InvoiceBadge status={invoice.status} />
</div>
<p className="mt-2 text-xs text-slate-500">
{invoice.number}
</p>
<p className="mt-0.5 text-lg font-bold text-slate-900">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
Due {invoice.dueDate}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
function KpiCard({
label,
value,
sub,
icon,
href,
tone = "brand",
}: {
label: string;
value: string;
sub: string;
icon: React.ReactNode;
href?: string;
tone?: "brand" | "danger";
}) {
const iconWrap =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#10B981] text-white";
const inner = (
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
<p className="mt-1 text-xs text-slate-500">{sub}</p>
</div>
<div
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconWrap}`}
>
{icon}
</div>
</div>
);
const className =
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
return href ? (
<Link to={href} className={`block ${className}`}>
{inner}
</Link>
) : (
<div className={className}>{inner}</div>
);
}
function ProfileRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Delayed: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function BookingBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,354 +0,0 @@
import {
useEffect,
useMemo,
useState,
} from "react";
import {
Link,
useNavigate,
} from "react-router-dom";
import {
Building2,
CheckCircle2,
DollarSign,
Package,
Plus,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { customersService } from "@/services/customers.service";
import { getMyInfo } from "@/services/account";
import NewCustomerPage from "../customers/NewCustomerPage";
import { Button } from "@edr/ui-common";
type Customer = {
id: string;
companyName: string;
firstName: string;
lastName: string;
email: string;
phone: string;
};
export default function MyPortalPage() {
const navigate = useNavigate();
// ------------------------------------------------------------
// STATE
// ------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<any>(null);
// ------------------------------------------------------------
// MOCK DATA
// ------------------------------------------------------------
const me = useMemo(() => getCurrentCustomer(), []);
const myBookings = useMemo(() => getMyBookings(), []);
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
// ------------------------------------------------------------
// FETCH CUSTOMER
// ------------------------------------------------------------
useEffect(() => {
const initialize = async () => {
try {
setLoading(true);
const userRes = await getMyInfo();
const userId = userRes?.data?.id;
localStorage.setItem("currentUser", JSON.stringify(userRes.data));
// if (!userId) {
// navigate("/login");
// return;
// }
const res = await customersService.getByUserId(userId);
if (res) {
setCustomer(res);
return;
}
// customer not found → onboarding
// navigate("/customers/register");
} catch (error: any) {
console.error("Customer fetch failed:", error);
const status = error?.response?.status;
if (status === 404) {
// navigate("/customers/register");
return;
}
if (status === 401) {
// navigate("/login");
return;
}
} finally {
setLoading(false);
}
};
initialize();
}, [navigate]);
// ------------------------------------------------------------
// LOADING
// ------------------------------------------------------------
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="rounded-2xl bg-white px-6 py-4 shadow-sm">
<p className="text-sm text-slate-600">
Loading portal...
</p>
</div>
</div>
);
}
// ------------------------------------------------------------
// CUSTOMER MISSING (extra safety)
// ------------------------------------------------------------
if (!customer) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="text-center space-y-4">
<p className="text-slate-600">
No customer profile found
</p>
{/* <Link
to="/customers/register"
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white"
>
<Plus className="h-4 w-4" />
Create Customer Profile
</Link> */}
<NewCustomerPage>
<Button
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white"
>
<Plus className="h-4 w-4" />
Create Customer Profile
</Button>
</NewCustomerPage>
</div>
</div>
);
}
// ------------------------------------------------------------
// KPI CALCULATIONS
// ------------------------------------------------------------
const activeBookings = myBookings.filter(
(b) =>
b.status === "Confirmed" ||
b.status === "In Transit"
);
const activeShipments = myShipments.filter(
(s) => s.status === "In Transit"
);
const outstandingInvoices = myInvoices.filter(
(i) => i.status === "Sent" || i.status === "Overdue"
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, i) =>
i.currency === "USD" ? sum + i.amount : sum,
0
);
const totalSpent = myInvoices.reduce(
(sum, i) =>
i.status === "Paid" && i.currency === "USD"
? sum + i.amount
: sum,
0
);
// ------------------------------------------------------------
// RENDER
// ------------------------------------------------------------
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "My Portal" }]} />
{/* HERO */}
<div className="rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white">
<div className="flex justify-between flex-col md:flex-row gap-6">
<div className="flex items-center gap-4">
<div className="h-14 w-14 flex items-center justify-center rounded-2xl bg-white/20 text-xl font-bold">
{customer.companyName?.charAt(0)}
</div>
<div>
<h1 className="text-2xl font-bold">
{customer.firstName} {customer.lastName}
</h1>
<p className="text-sm opacity-80 flex items-center gap-2">
<Building2 className="h-4 w-4" />
{customer.companyName}
</p>
</div>
</div>
<div className="flex gap-2">
<Link
to="/bookings/new"
className="bg-white text-[#10B981] px-4 py-2 rounded-xl font-semibold flex items-center gap-2"
>
<Plus className="h-4 w-4" />
New Booking
</Link>
<Link
to="/tracking"
className="border border-white px-4 py-2 rounded-xl flex items-center gap-2"
>
<Truck className="h-4 w-4" />
Track
</Link>
</div>
</div>
</div>
{/* KPI */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="Active Bookings"
value={String(activeBookings.length)}
sub={`${myBookings.length} total`}
icon={<Package className="h-5 w-5" />}
/>
<KpiCard
label="In Transit"
value={String(activeShipments.length)}
sub={`${myShipments.length} shipments`}
icon={<Truck className="h-5 w-5" />}
/>
<KpiCard
label="Outstanding"
value={formatCurrency(totalOutstanding, "USD")}
sub={`${outstandingInvoices.length} invoices`}
icon={<DollarSign className="h-5 w-5" />}
tone={
outstandingInvoices.some((i) => i.status === "Overdue")
? "danger"
: "brand"
}
/>
<KpiCard
label="Total Spent"
value={formatCurrency(totalSpent, "USD")}
sub="Paid invoices"
icon={<CheckCircle2 className="h-5 w-5" />}
/>
</div>
</div>
</div>
);
}
// ------------------------------------------------------------
// KPI CARD
// ------------------------------------------------------------
function KpiCard({
label,
value,
sub,
icon,
href,
tone = "brand",
}: {
label: string;
value: string;
sub: string;
icon: React.ReactNode;
href?: string;
tone?: "brand" | "danger";
}) {
const iconClassName =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#10B981] text-white";
const content = (
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">
{label}
</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">
{value}
</h3>
<p className="mt-1 text-xs text-slate-500">
{sub}
</p>
</div>
<div
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconClassName}`}
>
{icon}
</div>
</div>
);
const className =
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
if (href) {
return (
<Link
to={href}
className={`block ${className}`}
>
{content}
</Link>
);
}
return (
<div className={className}>
{content}
</div>
);
}

View File

@@ -1,61 +0,0 @@
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 DeleteTrainDialogProps {
trainCode: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteTrainDialog({
trainCode,
onConfirm,
children,
}: DeleteTrainDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Retire train?
</DialogTitle>
<DialogDescription>
This will permanently retire train{" "}
<span className="font-semibold text-slate-900">{trainCode}</span>{" "}
from the fleet. This action cannot be undone.
</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"
>
Retire
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,240 +0,0 @@
import type { ReactNode } from "react";
import {
Building2,
Calendar,
Factory,
Gauge,
MapPin,
Train as TrainIcon,
Weight,
} from "lucide-react";
import {
Dialog,
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 { TrainStatus, TrainType } from "./trains.mock";
export interface TrainFormData {
code?: string;
name?: string;
type?: TrainType;
status?: TrainStatus;
capacityTons?: number;
depot?: string;
manufacturer?: string;
mileageKm?: number;
lastMaintenance?: string;
nextMaintenance?: string;
currentAssignment?: string;
yearBuilt?: number;
}
export interface NewTrainPageProps {
mode?: "create" | "edit";
train?: TrainFormData;
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";
export default function NewTrainPage({
mode = "create",
train,
children,
}: NewTrainPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Train" : "New Train";
const description = isEdit
? "Update train fleet information."
: "Add a new train to the fleet roster.";
const submitLabel = isEdit ? "Save Changes" : "Add Train";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Train"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Code */}
<div className="space-y-2">
<Label>Train Code *</Label>
<div className="relative">
<TrainIcon className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.code ?? ""}
placeholder="e.g. LOC-001"
className="pl-10"
/>
</div>
</div>
{/* Name */}
<div className="space-y-2">
<Label>Name</Label>
<Input
defaultValue={train?.name ?? ""}
placeholder="e.g. Awash Express"
/>
</div>
{/* Type */}
<div className="space-y-2">
<Label>Type *</Label>
<select
defaultValue={train?.type ?? "Locomotive"}
className={selectClass}
>
<option>Locomotive</option>
<option>Freight Wagon</option>
<option>Tanker Wagon</option>
<option>Container Wagon</option>
<option>Reefer Wagon</option>
</select>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={train?.status ?? "Operational"}
className={selectClass}
>
<option>Operational</option>
<option>In Maintenance</option>
<option>Idle</option>
<option>Out of Service</option>
</select>
</div>
{/* Capacity */}
<div className="space-y-2">
<Label>Capacity (Tons)</Label>
<div className="relative">
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
defaultValue={train?.capacityTons ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Depot */}
<div className="space-y-2">
<Label>Depot</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.depot ?? ""}
placeholder="e.g. Addis Ababa"
className="pl-10"
/>
</div>
</div>
{/* Manufacturer */}
<div className="space-y-2">
<Label>Manufacturer</Label>
<div className="relative">
<Factory className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.manufacturer ?? ""}
placeholder="e.g. CRRC Zhuzhou"
className="pl-10"
/>
</div>
</div>
{/* Year Built */}
<div className="space-y-2">
<Label>Year Built</Label>
<Input
type="number"
min={1950}
max={2030}
defaultValue={train?.yearBuilt ?? 2020}
/>
</div>
{/* Mileage */}
<div className="space-y-2">
<Label>Mileage (km)</Label>
<div className="relative">
<Gauge className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
defaultValue={train?.mileageKm ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Current Assignment */}
<div className="space-y-2">
<Label>Current Assignment</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.currentAssignment ?? ""}
placeholder="e.g. BK-026003 or —"
className="pl-10"
/>
</div>
</div>
{/* Last Maintenance */}
<div className="space-y-2">
<Label>Last Maintenance</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={train?.lastMaintenance ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Next Maintenance */}
<div className="space-y-2">
<Label>Next Maintenance</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={train?.nextMaintenance ?? ""}
className="pl-10"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,372 +0,0 @@
import { useMemo, useState } from "react";
import {
Eye,
Filter,
Gauge,
MoreHorizontal,
Pencil,
Plus,
Search,
Train as TrainIcon,
Trash2,
Wrench,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewTrainPage from "./NewTrainPage";
import DeleteTrainDialog from "./DeleteTrainDialog";
import { trains, type TrainStatus } from "./trains.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | TrainStatus;
const FILTERS: FilterValue[] = [
"All",
"Operational",
"In Maintenance",
"Idle",
"Out of Service",
];
export default function TrainsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return trains.filter((t) => {
if (filter !== "All" && t.status !== filter) return false;
if (!q) return true;
return (
t.code.toLowerCase().includes(q) ||
t.name.toLowerCase().includes(q) ||
t.depot.toLowerCase().includes(q) ||
t.type.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = 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 operationalCount = trains.filter(
(t) => t.status === "Operational",
).length;
const maintenanceCount = trains.filter(
(t) => t.status === "In Maintenance",
).length;
const columns: ColumnDef<(typeof trains)[number]>[] = [
{
id: "train",
header: "Train",
cell: ({ row }) => {
const t = 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">
<TrainIcon />
</div>
<div>
<p className="font-medium text-slate-900">{t.code}</p>
<p className="text-sm text-slate-500">{t.name}</p>
</div>
</div>
);
},
},
{
accessorKey: "type",
header: "Type",
},
{
accessorKey: "capacityTons",
header: "Capacity",
cell: ({ row }) => <span>{row.original.capacityTons}t</span>,
},
{
accessorKey: "depot",
header: "Depot",
},
{
accessorKey: "mileageKm",
header: "Mileage",
cell: ({ row }) => (
<span>{row.original.mileageKm.toLocaleString()} km</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const train = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<NewTrainPage
mode="edit"
train={{
code: train.code,
name: train.name,
type: train.type,
status: train.status,
capacityTons: train.capacityTons,
depot: train.depot,
manufacturer: train.manufacturer,
mileageKm: train.mileageKm,
lastMaintenance: train.lastMaintenance,
nextMaintenance: train.nextMaintenance,
currentAssignment: train.currentAssignment,
yearBuilt: train.yearBuilt,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewTrainPage>
<DropdownMenuSeparator />
<DeleteTrainDialog trainCode={train.code}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Retire
</DropdownMenuItem>
</DeleteTrainDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Trains" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Trains
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Fleet roster, capacity, and maintenance status.
</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 trains..."
className="pl-8!"
/>
</div>
<NewTrainPage>
<Button>
<Plus />
New Train
</Button>
</NewTrainPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Trains</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{trains.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<TrainIcon />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Operational</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{operationalCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Gauge />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">In Maintenance</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{maintenanceCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Wrench />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? trains.length
: trains.filter((t) => t.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
</Card>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Fleet Roster</CardTitle>
<CardDescription>
All locomotives and wagons in service.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
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>
</div>
);
}
function StatusBadge({ status }: { status: TrainStatus }) {
const styles: Record<TrainStatus, string> = {
Operational: "bg-emerald-100 text-emerald-700",
"In Maintenance": "bg-amber-100 text-amber-700",
Idle: "bg-slate-100 text-slate-600",
"Out of Service": "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,263 +0,0 @@
export type TrainType =
| "Locomotive"
| "Freight Wagon"
| "Tanker Wagon"
| "Container Wagon"
| "Reefer Wagon";
export type TrainStatus =
| "Operational"
| "In Maintenance"
| "Idle"
| "Out of Service";
export interface Train {
id: number;
code: string;
name: string;
type: TrainType;
status: TrainStatus;
capacityTons: number;
depot: string;
manufacturer: string;
mileageKm: number;
lastMaintenance: string;
nextMaintenance: string;
currentAssignment: string;
yearBuilt: number;
}
const seedTrains: Array<Omit<Train, "id" | "code">> = [
{
name: "Awash Express",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Addis Ababa",
manufacturer: "CRRC Zhuzhou",
mileageKm: 184320,
lastMaintenance: "2026-04-12",
nextMaintenance: "2026-07-12",
currentAssignment: "BK-026001",
yearBuilt: 2018,
},
{
name: "Rift Valley Hauler",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Adama",
manufacturer: "CRRC Zhuzhou",
mileageKm: 156780,
lastMaintenance: "2026-03-28",
nextMaintenance: "2026-06-28",
currentAssignment: "BK-026004",
yearBuilt: 2019,
},
{
name: "Djibouti Freighter",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Dire Dawa",
manufacturer: "CRRC Yangtze",
mileageKm: 92110,
lastMaintenance: "2026-04-02",
nextMaintenance: "2026-08-02",
currentAssignment: "BK-026003",
yearBuilt: 2020,
},
{
name: "Highlander 1",
type: "Freight Wagon",
status: "In Maintenance",
capacityTons: 80,
depot: "Addis Ababa",
manufacturer: "CRRC Yangtze",
mileageKm: 211450,
lastMaintenance: "2026-05-10",
nextMaintenance: "2026-05-20",
currentAssignment: "—",
yearBuilt: 2017,
},
{
name: "Highlander 2",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Mojo",
manufacturer: "CRRC Yangtze",
mileageKm: 198020,
lastMaintenance: "2026-04-18",
nextMaintenance: "2026-07-18",
currentAssignment: "BK-026007",
yearBuilt: 2017,
},
{
name: "Sheba Tanker",
type: "Tanker Wagon",
status: "Operational",
capacityTons: 70,
depot: "Awash",
manufacturer: "CRRC Zhuzhou",
mileageKm: 132540,
lastMaintenance: "2026-04-05",
nextMaintenance: "2026-07-05",
currentAssignment: "BK-026010",
yearBuilt: 2019,
},
{
name: "Lalibela Cooler",
type: "Reefer Wagon",
status: "Idle",
capacityTons: 55,
depot: "Adama",
manufacturer: "CRRC Yangtze",
mileageKm: 67890,
lastMaintenance: "2026-03-22",
nextMaintenance: "2026-06-22",
currentAssignment: "—",
yearBuilt: 2021,
},
{
name: "Awash Express II",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Mieso",
manufacturer: "CRRC Zhuzhou",
mileageKm: 145600,
lastMaintenance: "2026-04-22",
nextMaintenance: "2026-07-22",
currentAssignment: "BK-026013",
yearBuilt: 2019,
},
{
name: "Coffee Belt Wagon",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Addis Ababa",
manufacturer: "CRRC Yangtze",
mileageKm: 88240,
lastMaintenance: "2026-04-09",
nextMaintenance: "2026-08-09",
currentAssignment: "BK-026016",
yearBuilt: 2020,
},
{
name: "Red Sea Hauler",
type: "Locomotive",
status: "Out of Service",
capacityTons: 240,
depot: "Djibouti City",
manufacturer: "CRRC Zhuzhou",
mileageKm: 264100,
lastMaintenance: "2026-02-14",
nextMaintenance: "2026-08-14",
currentAssignment: "—",
yearBuilt: 2015,
},
{
name: "Ali Sabieh Express",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Ali Sabieh",
manufacturer: "CRRC Yangtze",
mileageKm: 102330,
lastMaintenance: "2026-04-15",
nextMaintenance: "2026-07-15",
currentAssignment: "BK-026019",
yearBuilt: 2020,
},
{
name: "Holhol Tanker",
type: "Tanker Wagon",
status: "In Maintenance",
capacityTons: 70,
depot: "Holhol",
manufacturer: "CRRC Zhuzhou",
mileageKm: 178600,
lastMaintenance: "2026-05-08",
nextMaintenance: "2026-05-22",
currentAssignment: "—",
yearBuilt: 2018,
},
{
name: "Aysha Carrier",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Aysha",
manufacturer: "CRRC Yangtze",
mileageKm: 75940,
lastMaintenance: "2026-04-19",
nextMaintenance: "2026-08-19",
currentAssignment: "BK-026022",
yearBuilt: 2021,
},
{
name: "Mojo Reefer",
type: "Reefer Wagon",
status: "Idle",
capacityTons: 55,
depot: "Mojo",
manufacturer: "CRRC Yangtze",
mileageKm: 49870,
lastMaintenance: "2026-04-01",
nextMaintenance: "2026-07-01",
currentAssignment: "—",
yearBuilt: 2022,
},
{
name: "Simien Locomotive",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Addis Ababa",
manufacturer: "CRRC Zhuzhou",
mileageKm: 121340,
lastMaintenance: "2026-04-25",
nextMaintenance: "2026-07-25",
currentAssignment: "BK-026008",
yearBuilt: 2020,
},
{
name: "Gibe Freight",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Adama",
manufacturer: "CRRC Yangtze",
mileageKm: 168200,
lastMaintenance: "2026-04-11",
nextMaintenance: "2026-07-11",
currentAssignment: "BK-026011",
yearBuilt: 2018,
},
];
export const trains: Train[] = seedTrains.map((entry, i) => {
const id = i + 1;
const prefix =
entry.type === "Locomotive"
? "LOC"
: entry.type === "Tanker Wagon"
? "TNK"
: entry.type === "Reefer Wagon"
? "RFR"
: entry.type === "Container Wagon"
? "CNT"
: "WGN";
return {
id,
code: `${prefix}-${String(id).padStart(3, "0")}`,
...entry,
};
});
export function getTrainById(id: number | string): Train | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return trains.find((t) => t.id === numericId);
}

View File

@@ -1,92 +0,0 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { CreateUserPayload } from "@/types/createUser";
import { VerificationCodePayload } from "@/types/generateVerificationCode";
import { UserTypeRequest } from "@/types/userTypeRequest";
import { client } from "@/utils/api";
import { ApiResponse } from "@edr/types";
import { GenerateVerifcationCodePayload } from "node_modules/@tria-plc/iamui-common/dist/types/shared/services/authService";
// -----------------------------------------------------------------------------
// API
// -----------------------------------------------------------------------------
export const createUser = async (
body: CreateUserPayload
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.USERS.SIGN_UP,
body
);
return res.data;
};
export const getMyInfo = async () => {
const res =
await client.get<
ApiResponse<any>
>(
URL_CONSTANTS.USERS.ME
);
return res.data;
};
export const generateVerificationCode = async (
body: VerificationCodePayload
) => {
const res =
await client.patch<
ApiResponse<string>
>(
URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE,
body
);
return res.data.data;
};
export const setPassword = async (
body: any
) => {
const res =
await client.patch<
ApiResponse<string>
>(
URL_CONSTANTS.USERS.SET_PASSWORD,
body
);
return res.data.data;
};
export const createOTP = async (
body: any
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.OTP.SEND,
body
);
return res.data;
};
export const verifyOTP = async (
body: any
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.OTP.VERIFY,
body
);
return res.data;
};

View File

@@ -13,6 +13,8 @@ import { consignmentsService } from "./consignments.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { authService } from "./auth.service";
import { customersService } from "./customers.service";
import {
CreateDropdownOptionDto,
CreateDropdownSettingDto,
@@ -21,12 +23,101 @@ import {
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
CreateCustomerDto,
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import type {
AuthUser,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
OtpPayload,
OtpResponse,
SetPasswordPayload,
SignupPayload,
SignupResponse,
} from "@/types/auth";
// ---------------------------------------------------------------------------
// API definition
// ---------------------------------------------------------------------------
export const api = {
auth: {
login: endpoint<LoginPayload, LoginResponse>(
"auth",
"login",
authService.login,
),
createUser: endpoint<SignupPayload, SignupResponse>(
"auth",
"createUser",
authService.createUser,
),
getMyInfo: endpoint<void, AuthUser>(
"auth",
"getMyInfo",
authService.getMyInfo,
),
generateVerificationCode: endpoint<GenerateVerificationCodePayload, string>(
"auth",
"generateVerificationCode",
authService.generateVerificationCode,
),
setPassword: endpoint<SetPasswordPayload, void>(
"auth",
"setPassword",
authService.setPassword,
),
sendOTP: endpoint<OtpPayload, OtpResponse>(
"auth",
"sendOTP",
authService.sendOTP,
),
verifyOTP: endpoint<OtpPayload, OtpResponse>(
"auth",
"verifyOTP",
authService.verifyOTP,
),
logout: endpoint<void, void>("auth", "logout", authService.logout),
},
customers: {
list: endpoint<void, Customer[]>(
"customers",
"list",
customersService.list,
),
get: endpoint<{ id: string }, Customer>("customers", "get", ({ id }) =>
customersService.getById(id),
),
create: endpoint<CreateCustomerDto, Customer>(
"customers",
"create",
customersService.create,
),
update: endpoint<{ id: string; dto: UpdateCustomerDto }, Customer>(
"customers",
"update",
({ id, dto }) => customersService.update(id, dto),
),
remove: endpoint<{ id: string }, void>("customers", "remove", ({ id }) =>
customersService.remove(id),
),
getByUserId: endpoint<{ id: string }, Customer | null>(
"customers",
"getByUserId",
({ id }) => customersService.getByUserId(id),
),
},
bookings: {
list: endpoint<void, PaginatedResponse<Freight.IBooking>>(
"bookings",

View File

@@ -0,0 +1,90 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "@/utils/api";
import { ApiResponse } from "@edr/types";
import type {
AuthUser,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
OtpPayload,
OtpResponse,
SetPasswordPayload,
SignupPayload,
SignupResponse,
} from "@/types/auth";
export const authService = {
login: async (body: LoginPayload) => {
const res = await client.post<ApiResponse<LoginResponse>>(
URL_CONSTANTS.AUTH.LOGIN,
body,
);
return res.data.data;
},
createUser: async (body: SignupPayload) => {
const res = await client.post<ApiResponse<SignupResponse>>(
URL_CONSTANTS.USERS.SIGN_UP,
body,
);
return res.data.data;
},
getMyInfo: async () => {
const res = await client.get<ApiResponse<AuthUser>>(
URL_CONSTANTS.USERS.ME,
);
return res.data.data;
},
generateVerificationCode: async (body: GenerateVerificationCodePayload) => {
const res = await client.patch<ApiResponse<string>>(
URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE,
body,
);
return res.data.data;
},
setPassword: async (body: SetPasswordPayload) => {
const res = await client.patch<ApiResponse<void>>(
URL_CONSTANTS.USERS.SET_PASSWORD,
body,
);
return res.data.data;
},
sendOTP: async (body: OtpPayload) => {
const res = await client.post<ApiResponse<OtpResponse>>(
URL_CONSTANTS.OTP.SEND,
body,
);
return res.data.data;
},
verifyOTP: async (body: OtpPayload) => {
const res = await client.post<ApiResponse<OtpResponse>>(
URL_CONSTANTS.OTP.VERIFY,
body,
);
return res.data.data;
},
refreshToken: async () => {
const refreshTokenCookie = document.cookie
.split("; ")
.find((row) => row.startsWith("refresh-token="))
?.split("=")[1];
const res = await client.post<ApiResponse<LoginResponse>>(
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
{ refreshToken: refreshTokenCookie },
);
return res.data.data;
},
logout: async () => {
const res = await client.patch<ApiResponse<void>>(
URL_CONSTANTS.AUTH.LOGOUT,
);
return res.data.data;
},
};

View File

@@ -1,31 +1,23 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { client as api } from "@/utils/api";
import { client } from "../utils/api";
export type CreateBookingPayload = Freight.CreateBookingDto;
export const bookingsService = {
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
const { data } = await api.get("/bookings");
const { data } = await client.get("/bookings");
return data.data;
},
get: async (id: string): Promise<Freight.IBooking> => {
const { data } = await api.get(`/bookings/${id}`);
const { data } = await client.get(`/bookings/${id}`);
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const fd = new FormData();
for (const [key, value] of Object.entries(payload)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value) || typeof value === "object") {
fd.append(key, JSON.stringify(value));
} else {
fd.append(key, String(value));
}
}
const { data } = await api.post("/api/bookings", fd);
const { data } = await client.post("/api/bookings", payload);
return data.data;
},
remove: async (id: string): Promise<void> => {
await api.delete(`/bookings/${id}`);
await client.delete(`/bookings/${id}`);
},
};

View File

@@ -7,6 +7,7 @@ import type {
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import { isAxiosError } from "axios";
const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE;
@@ -23,22 +24,26 @@ export const customersService = {
return unwrap(response.data);
},
getByUserId: async (userId: string): Promise<Customer> => {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
);
getByUserId: async (userId: string): Promise<Customer | null> => {
try {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
);
return unwrap(response.data);
} catch (e) {
if (isAxiosError(e) && e.response?.status === 404) {
return null;
}
throw e;
}
},
create: async (payload: CreateCustomerDto): Promise<Customer> => {
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
return unwrap(response.data);
},
create: async (payload: any): Promise<any> => {
const response = await client.post<ApiResponse<any>>(BASE, payload);
return unwrap(response.data);
},
update: async (
id: string,
payload: UpdateCustomerDto,
): Promise<Customer> => {
update: async (id: string, payload: UpdateCustomerDto): Promise<Customer> => {
const response = await client.patch<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
payload,

View File

@@ -0,0 +1,65 @@
export interface AuthUser {
id: string;
name: { am: string; en: string };
email: string;
roles: string[];
status: string;
employee: any[];
userType: string;
username: string;
permissions: string[];
phoneNumber: string;
sharepointId: string | null;
hasSetPassword: boolean;
hasFinishedRegistration: boolean;
hasFinishedDMSOnboarding: boolean;
}
export interface SignupPayload {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: { en: string; am: string };
}
export interface SignupResponse {
token: string;
refreshToken: string;
otp: string;
userId: string;
}
export interface OtpPayload {
phone: string;
otp: string;
}
export interface OtpResponse {
success: boolean;
message: string;
}
export interface SetPasswordPayload {
newPassword: string;
confirmPassword: string;
userId: string;
email: string;
verificationCode: string;
}
export interface GenerateVerificationCodePayload {
email: string;
phoneNumber: string;
type: string;
}
export interface LoginPayload {
email: string;
password: string;
}
export interface LoginResponse {
token: string;
refreshToken: string;
}

View File

@@ -1,10 +0,0 @@
export type CreateUserPayload = {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
en: string;
am?: string;
};
};

View File

@@ -1,5 +0,0 @@
export type VerificationCodePayload = {
email: string;
phoneNumber: string;
type: string;
};

View File

@@ -2,38 +2,129 @@ import {
UseQueryOptions,
QueryObserverOptions,
} from "@tanstack/react-query";
import axios from "axios";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS";
// ---------------------------------------------------------------------------
// Axios client
// ---------------------------------------------------------------------------
export const client = axios.create({
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
function getCookie(name: string): string | undefined {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.split("=")[1];
}
function setCookie(name: string, value: string, days: number) {
const expires = new Date();
expires.setDate(expires.getDate() + days);
document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`;
}
function clearAuthCookies() {
["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach(
(name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
},
);
}
// Attach auth token to every request
client.interceptors.request.use((config) => {
// TODO: replace with secure storage (cookie/localStorage/auth provider)
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("auth-token="))
?.split("=")[1];
const token = getCookie("auth-token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle auth errors globally
// Token refresh state
let isRefreshing = false;
let failedQueue: {
resolve: (token: string) => void;
reject: (error: unknown) => void;
}[] = [];
function processQueue(error: unknown, token?: string) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token!);
}
});
failedQueue = [];
}
// Handle auth errors globally with token refresh
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.href = "/auth";
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Don't intercept if:
// - no response (network error)
// - status is not 401
// - already retried
// - it's the refresh endpoint itself
if (
!error.response ||
error.response.status !== 401 ||
originalRequest._retry ||
originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN
) {
return Promise.reject(error);
}
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = getCookie("refresh-token");
if (!refreshToken) {
isRefreshing = false;
clearAuthCookies();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return Promise.reject(error);
}
try {
const { data } = await client.post<{ data: { token: string; refreshToken: string } }>(
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
{ refreshToken },
);
const { token, refreshToken: newRefreshToken } = data.data;
setCookie("auth-token", token, 7);
setCookie("refresh-token", newRefreshToken, 7);
originalRequest.headers.Authorization = `Bearer ${token}`;
processQueue(null, token);
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError, undefined);
clearAuthCookies();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
export { client };
export type { UseQueryOptions, QueryObserverOptions };

View File

@@ -0,0 +1,29 @@
export type Result<T, E = { code: string; message: string; statusCode?: number }> =
| { success: true; data: T }
| { success: false; error: E };
export type ApiError = {
code: string;
message: string;
statusCode?: number;
};
export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") {
const obj = err as Record<string, unknown>;
const response = obj.response as Record<string, unknown> | undefined;
if (response) {
const statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | undefined;
return {
code: (data?.error as string) || (data?.message as string) || "api_error",
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
statusCode,
};
}
if (obj.message && typeof obj.message === "string") {
return { code: "client_error", message: obj.message };
}
}
return { code: "unknown_error", message: "An unexpected error occurred" };
}