mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix conflict
This commit is contained in:
@@ -20,7 +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: [
|
||||
@@ -48,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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||
import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine";
|
||||
|
||||
// Create a QueryClient instance
|
||||
@@ -56,11 +58,74 @@ const sidebarItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
@@ -99,7 +164,7 @@ const App = () => {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
{/* <Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
@@ -114,6 +179,24 @@ const App = () => {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
</Routes>
|
||||
======= */}
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<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>
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
FileText,
|
||||
Home,
|
||||
Loader2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
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";
|
||||
@@ -29,7 +31,6 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
import BillingPage from "./pages/billing/BillingPage";
|
||||
import DocumentsPage from "./pages/documents/DocumentsPage";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
@@ -37,7 +38,7 @@ const sidebarItems: SidebarItem[] = [
|
||||
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||
{ label: "Profile", href: "/profile", icon: <User /> },
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
@@ -97,7 +98,7 @@ const App = () => {
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</DashboardLayout>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
|
||||
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"),
|
||||
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
|
||||
phone: z
|
||||
.string()
|
||||
.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"),
|
||||
@@ -46,7 +48,6 @@ export default function SignupPage() {
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
countryCode: "+251",
|
||||
phone: "",
|
||||
userType: userType.individual,
|
||||
@@ -58,10 +59,12 @@ export default function SignupPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
|
||||
const normalizedPhone = data.phone.startsWith("0")
|
||||
? data.phone.slice(1)
|
||||
: data.phone;
|
||||
const payload: SignupPayload = {
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
username: data.email,
|
||||
phoneNumber: `${data.countryCode}${normalizedPhone}`,
|
||||
userType: data.userType,
|
||||
name: { en: data.name.en, am: data.name.am ?? "" },
|
||||
@@ -92,7 +95,12 @@ export default function SignupPage() {
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
stats: {
|
||||
label: "Active Corridors",
|
||||
value: "24+",
|
||||
footer: "Operational",
|
||||
progress: "w-[95%]",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
@@ -125,31 +133,17 @@ export default function SignupPage() {
|
||||
<FieldError errors={[errors.name?.en]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field data-invalid={Boolean(errors.username)}>
|
||||
<FieldLabel>Username</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="john_doe"
|
||||
disabled={loading}
|
||||
aria-invalid={Boolean(errors.username)}
|
||||
{...register("username")}
|
||||
/>
|
||||
<FieldError errors={[errors.username]} />
|
||||
</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>
|
||||
</div>
|
||||
<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}
|
||||
@@ -160,12 +154,7 @@ export default function SignupPage() {
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<Button type="submit" disabled={loading} size="lg" className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" data-icon="inline-start" />
|
||||
|
||||
@@ -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,15 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import {
|
||||
MOCK_VALID_CONTRACTS,
|
||||
STEPS,
|
||||
bookingFormSchema,
|
||||
calcWagons,
|
||||
@@ -22,11 +21,8 @@ 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";
|
||||
@@ -35,8 +31,6 @@ 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 { customer } = useAuth();
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: CreateBookingPayload) =>
|
||||
@@ -56,7 +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 direction = useMemo(
|
||||
() => getRouteDirection(originYard, destinationYard),
|
||||
@@ -68,63 +61,18 @@ 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));
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
if (data.contractType === "renewal" && renewalValid !== true) {
|
||||
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;
|
||||
@@ -149,19 +97,22 @@ export default function NewBookingPage() {
|
||||
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" as const)
|
||||
: ("WITHOUT_RETURN" as const),
|
||||
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,43 +171,22 @@ export default function NewBookingPage() {
|
||||
className="flex flex-col"
|
||||
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>
|
||||
@@ -277,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,123 +43,69 @@ 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(),
|
||||
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"]).optional(),
|
||||
@@ -171,142 +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 === "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({
|
||||
@@ -325,39 +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: "",
|
||||
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: "",
|
||||
@@ -365,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>> = {
|
||||
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
1: ["contractType", "previousContractRef"],
|
||||
2: ["serviceType"],
|
||||
3: [
|
||||
"firstMileEnabled",
|
||||
"pickUpAddress",
|
||||
"lastMileEnabled",
|
||||
"deliveryAddress",
|
||||
2: [
|
||||
"serviceType",
|
||||
"firstMile",
|
||||
"lastMile",
|
||||
"equipmentReturn",
|
||||
"customsClearingEnabled",
|
||||
],
|
||||
4: ["originYard", "destinationYard", "isHazardous", "isRefrigerated"],
|
||||
5: [
|
||||
3: [
|
||||
"originYard",
|
||||
"destinationYard",
|
||||
"isHazardous",
|
||||
"isRefrigerated",
|
||||
"shippingLine",
|
||||
],
|
||||
4: [
|
||||
"cargoType",
|
||||
"cargoWeight",
|
||||
"freightType",
|
||||
@@ -392,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,22 +1,19 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { FileText, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button, Field, FieldLabel, Input } from "@edr/ui-common";
|
||||
import { type BookingFormValues } 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 previousContractRef = form.watch("previousContractRef");
|
||||
|
||||
@@ -38,6 +35,7 @@ export function Step1ContractType({
|
||||
onClick={() => {
|
||||
field.onChange("new");
|
||||
form.clearErrors(["contractType", "previousContractRef"]);
|
||||
form.setValue("previousContractRef", "");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
@@ -45,7 +43,7 @@ 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>
|
||||
</OptionCard>
|
||||
|
||||
@@ -61,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>
|
||||
@@ -77,46 +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>
|
||||
</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,7 +1,12 @@
|
||||
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 {
|
||||
SHIPPING_LINES,
|
||||
type BookingFormValues,
|
||||
getRouteDirection,
|
||||
STATIONS,
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
SelectField,
|
||||
@@ -11,6 +16,7 @@ import {
|
||||
} from "./shared";
|
||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
||||
import { DropdownOption } from "@/types/dropdownSettings";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
@@ -39,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
|
||||
@@ -106,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">
|
||||
|
||||
@@ -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";
|
||||
@@ -81,7 +76,6 @@ export function Step5CargoDetails({
|
||||
form.setValue("freightType", undefined, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue("cargoWeight", "", { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
@@ -96,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">
|
||||
@@ -118,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;
|
||||
@@ -293,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"
|
||||
@@ -315,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">
|
||||
{[
|
||||
{
|
||||
@@ -352,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}
|
||||
@@ -407,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)}
|
||||
@@ -422,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 && (
|
||||
@@ -433,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,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,143 +0,0 @@
|
||||
import { bookings } from "../bookings/bookings.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) {
|
||||
linkedType = "Consignment";
|
||||
linkedReference = "s";
|
||||
} 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`;
|
||||
}
|
||||
@@ -73,17 +73,29 @@ export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||
|
||||
export interface ICustomer extends BaseEntity {
|
||||
name: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company?: string | null;
|
||||
customerType: CustomerType;
|
||||
status: CustomerStatus;
|
||||
tinNumber?: string | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
address?: string | null;
|
||||
taxId?: string | null;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber?: string | null;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string | null;
|
||||
poaPhone?: string | null;
|
||||
poaAddress?: string | null;
|
||||
poaEmail?: string | null;
|
||||
poaLocation?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
@@ -221,6 +233,7 @@ export interface CreateBookingDto {
|
||||
lastMileDeliveryAddress?: string;
|
||||
|
||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
||||
customsClearingEnabled?: boolean;
|
||||
originStation: string;
|
||||
destinationStation: string;
|
||||
cargoTotalWeightVgm: number;
|
||||
|
||||
5
tasks.md
Normal file
5
tasks.md
Normal file
@@ -0,0 +1,5 @@
|
||||
- In ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx the input field for the previous contract ref should be a select field with the list of contracts from the API
|
||||
- the "Equipment Return" field in ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx should be a toggle like Last Mile - Delivery and should appear only if the last mile is toggled. and also add "( Door to Port)" to first mile description and vice versa to the last mile
|
||||
- add Type of container- Dry container, high cubic containers , reefer containers, open top containers, flat rack, tank container, open side containers to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
|
||||
- merge the "Unpaired 20ft Container" from ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx to step 4 and fully remove the step 5
|
||||
- add Type of Shipping lines - MSC, CMA CGM, Evergreen, COSCO, Hapag-Lloyd, ONE, Yang Ming, ZIM, Messina Line, Safmarine, Wan Hai, Ethiopian Shipping Lines (ESLSE) to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
|
||||
Reference in New Issue
Block a user