mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
complete rule engine and booking flow
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { DemoPermissionsController } from "./demo-permissions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [DemoPermissionsController],
|
||||
})
|
||||
export class DemoPermissionsModule {}
|
||||
213
apps/edr-freight-api/src/seed/demo-users.seeder.ts
Normal file
213
apps/edr-freight-api/src/seed/demo-users.seeder.ts
Normal 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ContractTypePage, } from "@/components/ruleEngine/ContractType";
|
||||
|
||||
export const RuleEnginePage = () => {
|
||||
return <div>
|
||||
<h3>
|
||||
Rule Engine Page
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<ContractTypePage />
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
185
apps/edr-freight-web/portal/src/hooks/useAuth.ts
Normal file
185
apps/edr-freight-web/portal/src/hooks/useAuth.ts
Normal 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;
|
||||
@@ -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>,
|
||||
|
||||
@@ -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">
|
||||
We’ll 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
360
apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
Normal file
360
apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
326
apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
Normal file
326
apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
Normal file
190
apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
Didn’t 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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} m³`}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
90
apps/edr-freight-web/portal/src/services/auth.service.ts
Normal file
90
apps/edr-freight-web/portal/src/services/auth.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
65
apps/edr-freight-web/portal/src/types/auth.ts
Normal file
65
apps/edr-freight-web/portal/src/types/auth.ts
Normal 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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export type CreateUserPayload = {
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
userType: string;
|
||||
name: {
|
||||
en: string;
|
||||
am?: string;
|
||||
};
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export type VerificationCodePayload = {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
type: string;
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
29
apps/edr-freight-web/portal/src/utils/result.ts
Normal file
29
apps/edr-freight-web/portal/src/utils/result.ts
Normal 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user