mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
approve-delivery exit-gate fix + Import Loading Confirmation frontend panel — done this session, not yet committed
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
@@ -32,7 +33,11 @@ import {
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||
import {
|
||||
FreightDashboardLayout,
|
||||
type SidebarItem,
|
||||
type SidebarSection,
|
||||
} from "@/components/layout";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
import LoginPage from "./pages/auth/LoginPage";
|
||||
@@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
@@ -61,7 +68,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
hasPermission as hasFreightPermission,
|
||||
isDjiboutiGl,
|
||||
isEthiopianGl,
|
||||
isSuperAdmin,
|
||||
} from "./lib/permissions";
|
||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||
@@ -144,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
{
|
||||
label: "Invoices",
|
||||
href: "/dashboard/invoices",
|
||||
icon: <Receipt />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
@@ -159,12 +178,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
],
|
||||
},
|
||||
// {
|
||||
// label: "Shipment Requests",
|
||||
// href: "/dashboard/shipment-requests",
|
||||
// icon: <Send />,
|
||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
// },
|
||||
{
|
||||
label: "Shipment Requests",
|
||||
href: "/dashboard/shipment-requests",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
},
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
@@ -435,12 +454,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
];
|
||||
|
||||
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
||||
/** Hrefs of the two document-clearance menu items (stable identifiers). */
|
||||
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||
|
||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||
item.href === ET_CLEARANCE_HREF;
|
||||
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||
item.href === DJ_CLEARANCE_HREF;
|
||||
const isClearanceItem = (item: SidebarItem): boolean =>
|
||||
isEtClearanceItem(item) || isDjClearanceItem(item);
|
||||
|
||||
/**
|
||||
* Keep only items the user is permitted to see; drop now-empty sections.
|
||||
*
|
||||
* Position-scoped visibility (super_admin bypasses all of this):
|
||||
* - Ethiopian GL → sees ONLY the ET document-clearance page.
|
||||
* - Djibouti GL → sees ONLY the DJ clearance page.
|
||||
* - Everyone else → sees everything they have permission for, EXCEPT the two
|
||||
* clearance pages (those are GL-only).
|
||||
*/
|
||||
const filterSidebarByPermission = (
|
||||
sections: SidebarSection[],
|
||||
user: ReturnType<typeof useAuth>["user"],
|
||||
): SidebarSection[] => {
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
const superAdmin = isSuperAdmin(user);
|
||||
const etGl = !superAdmin && isEthiopianGl(user);
|
||||
const djGl = !superAdmin && isDjiboutiGl(user);
|
||||
|
||||
const permissionAllowed = (item: SidebarItem): boolean => {
|
||||
if (!item.permission) return true;
|
||||
const keys = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
@@ -448,6 +490,19 @@ const filterSidebarByPermission = (
|
||||
return keys.some((key) => hasFreightPermission(user, key));
|
||||
};
|
||||
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
if (superAdmin) return true;
|
||||
|
||||
// GL positions are locked to their single clearance page.
|
||||
if (etGl) return isEtClearanceItem(item);
|
||||
if (djGl) return isDjClearanceItem(item);
|
||||
|
||||
// Everyone else: hide the GL-only clearance pages entirely.
|
||||
if (isClearanceItem(item)) return false;
|
||||
|
||||
return permissionAllowed(item);
|
||||
};
|
||||
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
@@ -469,6 +524,22 @@ const DashboardShell = () => {
|
||||
);
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
|
||||
// GL positions are locked to their single clearance page: if they navigate
|
||||
// (or deep-link) anywhere else, send them back to their clearance hub.
|
||||
// Super admin is exempt. Allow the clearance path + its detail sub-routes.
|
||||
const superAdmin = isSuperAdmin(user);
|
||||
const glClearanceHome = !superAdmin
|
||||
? isEthiopianGl(user)
|
||||
? ET_CLEARANCE_HREF
|
||||
: isDjiboutiGl(user)
|
||||
? DJ_CLEARANCE_HREF
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
return <Navigate to={glClearanceHome} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FreightDashboardLayout
|
||||
sidebarSections={sidebarSections}
|
||||
@@ -506,7 +577,10 @@ const App = () => {
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="/health" element={<HealthCheck />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" 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="profile" element={<MyProfilePage />} />
|
||||
@@ -522,8 +596,27 @@ const App = () => {
|
||||
/>
|
||||
<Route path="customers" element={<CustomersPage />} />
|
||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||
<InvoicesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||
<InvoiceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||
<Route
|
||||
path="booking-requests/:id"
|
||||
element={<BookingRequestDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
@@ -536,7 +629,9 @@ const App = () => {
|
||||
<Route
|
||||
path="clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.bookings.clearanceView}
|
||||
>
|
||||
<DocumentClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -570,7 +665,9 @@ const App = () => {
|
||||
<Route
|
||||
path="bookings/:bookingId/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.bookings.clearanceView}
|
||||
>
|
||||
<DocumentClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -578,7 +675,9 @@ const App = () => {
|
||||
<Route
|
||||
path="shipment-requests"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||
>
|
||||
<ShipmentRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -586,7 +685,9 @@ const App = () => {
|
||||
<Route
|
||||
path="shipment-requests/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||
>
|
||||
<ShipmentRequestDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -618,12 +719,20 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||
<Route
|
||||
path="gl-ethiopia/clearance"
|
||||
element={<LegacyGlEthiopiaClearanceRedirect />}
|
||||
/>
|
||||
<Route
|
||||
path="gl-ethiopia/clearance/:id"
|
||||
element={<LegacyGlEthiopiaClearanceRedirect />}
|
||||
/>
|
||||
<Route
|
||||
path="gl-djibouti/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
|
||||
>
|
||||
<GlDjiboutiClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -631,7 +740,9 @@ const App = () => {
|
||||
<Route
|
||||
path="gl-djibouti/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
|
||||
>
|
||||
<GlClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -644,7 +755,9 @@ const App = () => {
|
||||
<Route
|
||||
path="contracts/:id/create-booking"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||
>
|
||||
<GlCreateBookingForm />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -655,155 +768,174 @@ const App = () => {
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route
|
||||
path="warehouse-inventory"
|
||||
element={<WarehouseInventoryPage />}
|
||||
/>
|
||||
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
||||
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="export-djibouti-unloading" element={<ExportDjiboutiUnloadingQueuePage />} />
|
||||
<Route path="interchange-documents" element={<InterchangeDocumentsPage />} />
|
||||
<Route
|
||||
path="export-djibouti-unloading"
|
||||
element={<ExportDjiboutiUnloadingQueuePage />}
|
||||
/>
|
||||
<Route
|
||||
path="interchange-documents"
|
||||
element={<InterchangeDocumentsPage />}
|
||||
/>
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
<Route
|
||||
path="warehouse-fee-invoices"
|
||||
element={<WarehouseInvoicesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse-dashboard"
|
||||
element={<WarehouseDashboardPage />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchBoardPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/first-mile"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<FirstMilePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/last-mile"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<LastMilePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchScheduleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2ListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2DetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleTrackPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="vehicles"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="drivers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
element={
|
||||
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchBoardPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/first-mile"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<FirstMilePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/last-mile"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<LastMilePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchScheduleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2ListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2DetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleTrackPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="vehicles"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="drivers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={
|
||||
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
@@ -953,9 +1085,15 @@ const App = () => {
|
||||
{/* Legacy embedded user management routes */}
|
||||
<Route path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="user-management/users" element={<UsersPage />} />
|
||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||
<Route
|
||||
path="user-management/position-types"
|
||||
element={<PositionTypesPage />}
|
||||
/>
|
||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route
|
||||
path="user-management/permissions"
|
||||
element={<PermissionsPage />}
|
||||
/>
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
|
||||
<Route
|
||||
@@ -977,7 +1115,9 @@ const App = () => {
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
element={
|
||||
<Navigate to="/dashboard/configuration/cargo-types" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/train-scheduling-rules"
|
||||
@@ -996,8 +1136,14 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
<Route
|
||||
path="configuration/cargo-types/:id"
|
||||
element={<CargoTypesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/:resource"
|
||||
element={<RuleEngineResourcePage />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="rules"
|
||||
@@ -1007,9 +1153,14 @@ const App = () => {
|
||||
|
||||
<Route
|
||||
path="rule-engine"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
element={
|
||||
<Navigate to="/dashboard/configuration/cargo-types" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="rule-engine/:resource"
|
||||
element={<RuleEngineLegacyRedirect />}
|
||||
/>
|
||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||
|
||||
<Route path="user1" element={<DemoUser1Page />} />
|
||||
<Route path="user2" element={<DemoUser2Page />} />
|
||||
@@ -1026,9 +1177,7 @@ const App = () => {
|
||||
/** Redirect removed milestones page to document clearance. */
|
||||
function BookingMilestonesRedirect() {
|
||||
const { id } = useParams();
|
||||
return (
|
||||
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
|
||||
);
|
||||
return <Navigate to={`/dashboard/bookings/${id}/clearance`} replace />;
|
||||
}
|
||||
|
||||
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
@@ -88,7 +89,13 @@ export function ProfileChips({
|
||||
}) {
|
||||
if (!profiles.length) {
|
||||
return (
|
||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||
<Badge
|
||||
color="gray"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
style={badgeStyle}
|
||||
>
|
||||
No profiles
|
||||
</Badge>
|
||||
);
|
||||
@@ -118,7 +125,13 @@ export function ProfileChips({
|
||||
</Tooltip>
|
||||
))}
|
||||
{extra > 0 ? (
|
||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||
<Badge
|
||||
color="gray"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
style={badgeStyle}
|
||||
>
|
||||
+{extra}
|
||||
</Badge>
|
||||
) : null}
|
||||
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
|
||||
export function BookingStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: CustomerBookingStatus;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
||||
@@ -194,7 +211,11 @@ const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
|
||||
refunded: "grape",
|
||||
};
|
||||
|
||||
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
|
||||
export function PaymentStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: CustomerPaymentStatus;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
||||
@@ -210,6 +231,38 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }
|
||||
);
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
|
||||
DRAFT: "gray",
|
||||
ISSUED: "cyan",
|
||||
PENDING: "yellow",
|
||||
PARTIALLY_PAID: "orange",
|
||||
PAID: "edr-green",
|
||||
OVERDUE: "red",
|
||||
CANCELLED: "gray",
|
||||
REFUNDED: "grape",
|
||||
EXPIRED: "red",
|
||||
};
|
||||
|
||||
export function InvoiceStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: Freight.InvoiceStatus;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
color={INVOICE_STATUS_COLOR[status] ?? "gray"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{humanize(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline approval action buttons for a profile row.
|
||||
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
||||
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
|
||||
const act = (next: ProfileStatus) =>
|
||||
mutate({ profileId, status: next });
|
||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
BookingStatusBadge,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
|
||||
@@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({
|
||||
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
||||
|
||||
const setField = (name: string, value: unknown) => {
|
||||
setValues((current) => ({ ...current, [name]: value }));
|
||||
setValues((current) => {
|
||||
const next = { ...current, [name]: value };
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||
// allowed set instead of submitting a stale, rejected unit.
|
||||
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
|
||||
next.rateUnit = "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
@@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({
|
||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||
|
||||
if (field.type === "select") {
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
@@ -261,7 +273,7 @@ const RuleEngineFormDialog = ({
|
||||
value={resolveSelectValue(field, values)}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
data={(field.options ?? [])
|
||||
data={options
|
||||
.filter((opt) => opt.value !== "")
|
||||
.map((opt) => ({
|
||||
label: opt.label,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import type { CompanyListFilter } from "@/types/customer";
|
||||
import type { InvoiceListFilter } from "@/types/invoice";
|
||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
||||
|
||||
@@ -16,7 +17,8 @@ export const QUERY_KEYS = {
|
||||
ROOT: ["file-upload-settings"] as const,
|
||||
list: () => ["file-upload-settings", "list"] as const,
|
||||
byId: (id: string) => ["file-upload-settings", "detail", id] as const,
|
||||
byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
|
||||
byCode: (code: string) =>
|
||||
["file-upload-settings", "by-code", code] as const,
|
||||
},
|
||||
|
||||
DROPDOWN_SETTINGS: {
|
||||
@@ -33,10 +35,18 @@ export const QUERY_KEYS = {
|
||||
["customers", "list", filter ?? {}] as const,
|
||||
byId: (id: string) => ["customers", "detail", id] as const,
|
||||
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
|
||||
documents: (id: string) => ["customers", "detail", id, "documents"] as const,
|
||||
documents: (id: string) =>
|
||||
["customers", "detail", id, "documents"] as const,
|
||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||
},
|
||||
|
||||
INVOICES: {
|
||||
ROOT: ["invoices"] as const,
|
||||
list: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "list", filter ?? {}] as const,
|
||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||
},
|
||||
|
||||
BOOKINGS: {
|
||||
ROOT: ["bookings"] as const,
|
||||
list: (filter?: BookingListFilter) =>
|
||||
@@ -80,7 +90,12 @@ export const QUERY_KEYS = {
|
||||
TRAIN_SCHEDULING: {
|
||||
ROOT: ["train-scheduling"] as const,
|
||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
||||
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
|
||||
[
|
||||
"train-scheduling",
|
||||
"eligible-bookings",
|
||||
freightType ?? "CONTAINER",
|
||||
filters ?? {},
|
||||
] as const,
|
||||
locomotives: (routeId?: string) =>
|
||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
@@ -100,31 +115,37 @@ export const QUERY_KEYS = {
|
||||
|
||||
FLEET: {
|
||||
ROOT: ["fleet"] as const,
|
||||
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
|
||||
list: (resource: FleetResourceSlug | string) =>
|
||||
["fleet", "list", resource] as const,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
ROOT: ["vehicles"] as const,
|
||||
list: (filter?: Record<string, unknown>) => ["vehicles", "list", filter ?? {}] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
["vehicles", "list", filter ?? {}] as const,
|
||||
byId: (id: string) => ["vehicles", "detail", id] as const,
|
||||
},
|
||||
|
||||
FIRST_MILE: {
|
||||
ROOT: ["first-mile"] as const,
|
||||
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
["first-mile", "list", filter ?? {}] as const,
|
||||
byId: (id: string) => ["first-mile", "detail", id] as const,
|
||||
},
|
||||
|
||||
LAST_MILE: {
|
||||
ROOT: ["last-mile"] as const,
|
||||
list: (filter?: Record<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
["last-mile", "list", filter ?? {}] as const,
|
||||
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
ROOT: ["rule-engine"] as const,
|
||||
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
||||
["rule-engine", "list", resource, params ?? {}] as const,
|
||||
list: (
|
||||
resource: RuleEngineResourceSlug | string,
|
||||
params?: RuleEngineListParams,
|
||||
) => ["rule-engine", "list", resource, params ?? {}] as const,
|
||||
detail: (resource: RuleEngineResourceSlug | string, id: string) =>
|
||||
["rule-engine", "detail", resource, id] as const,
|
||||
chain: ["rule-engine", "approval-rules", "chain"] as const,
|
||||
@@ -138,27 +159,39 @@ export const QUERY_KEYS = {
|
||||
|
||||
OVERVIEW: {
|
||||
ROOT: ["overview"] as const,
|
||||
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
||||
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
|
||||
contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const,
|
||||
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
|
||||
dashboard: (range?: string) =>
|
||||
["overview", "dashboard", range ?? "30d"] as const,
|
||||
bookingsTab: (range?: string) =>
|
||||
["overview", "bookings", range ?? "30d"] as const,
|
||||
contractsTab: (range?: string) =>
|
||||
["overview", "contracts", range ?? "30d"] as const,
|
||||
billingTab: (range?: string) =>
|
||||
["overview", "billing", range ?? "30d"] as const,
|
||||
operationsTab: () => ["overview", "operations"] as const,
|
||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
||||
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
|
||||
customersTab: (range?: string) =>
|
||||
["overview", "customers", range ?? "30d"] as const,
|
||||
staffTab: (range?: string) =>
|
||||
["overview", "staff", range ?? "30d"] as const,
|
||||
},
|
||||
|
||||
FUEL: {
|
||||
ROOT: ["fuel"] as const,
|
||||
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
|
||||
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
|
||||
purchases: (vehicleId?: string) =>
|
||||
["fuel", "purchases", vehicleId ?? "all"] as const,
|
||||
stats: (vehicleId?: string) =>
|
||||
["fuel", "stats", vehicleId ?? "all"] as const,
|
||||
},
|
||||
|
||||
MAINTENANCE: {
|
||||
ROOT: ["maintenance"] as const,
|
||||
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
|
||||
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
|
||||
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||
schedules: (vehicleId?: string) =>
|
||||
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||
upcoming: (vehicleId?: string) =>
|
||||
["maintenance", "upcoming", vehicleId ?? "all"] as const,
|
||||
history: (vehicleId?: string) =>
|
||||
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||
stats: (vehicleId?: string) =>
|
||||
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||
},
|
||||
|
||||
FINANCIAL_REPORTS: {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const URL_CONSTANTS = {
|
||||
BASE: "/users",
|
||||
BY_ID: (id: string | number) => `/users/${id}`,
|
||||
SET_PASSWORD: "/api/auth/set-password",
|
||||
ME: "/api/auth/me"
|
||||
ME: "/api/auth/me",
|
||||
},
|
||||
|
||||
ROLES: {
|
||||
@@ -74,9 +74,18 @@ export const URL_CONSTANTS = {
|
||||
STATS: "/companies/stats",
|
||||
BY_ID: (id: string | number) => `/companies/${id}`,
|
||||
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
||||
PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`,
|
||||
PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`,
|
||||
PROFILE_STATUS: (profileId: string) =>
|
||||
`/companies/company-profiles/${profileId}/status`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||
`/bookings/by-company/${id}/customer-view`,
|
||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||
`/payments/by-company/${id}/customer-view`,
|
||||
},
|
||||
|
||||
BILLING: {
|
||||
INVOICES: "/billing/invoices",
|
||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||
},
|
||||
|
||||
CUSTOMERS_API: {
|
||||
@@ -124,15 +133,21 @@ export const URL_CONSTANTS = {
|
||||
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
||||
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
|
||||
CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`,
|
||||
CLEARANCE_DECLARATION: (id: string) =>
|
||||
`/bookings/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`,
|
||||
CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`,
|
||||
CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`,
|
||||
CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||
`/bookings/${id}/clearance/transit-permit`,
|
||||
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
||||
`/bookings/${id}/clearance/delivery-order`,
|
||||
CLEARANCE_RELEASE_ORDER: (id: string) =>
|
||||
`/bookings/${id}/clearance/release-order`,
|
||||
CLEARANCE_RO_AMENDMENT: (id: string) =>
|
||||
`/bookings/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||
`/bookings/${id}/clearance/export-release`,
|
||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||
},
|
||||
@@ -157,7 +172,8 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||
`/contracts/${id}/clearance/output-documents`,
|
||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
||||
CLEARANCE_DECLARATION: (id: string) =>
|
||||
`/contracts/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
||||
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||
@@ -247,7 +263,7 @@ export const URL_CONSTANTS = {
|
||||
},
|
||||
|
||||
ROUTES: {
|
||||
BASE: '/routes',
|
||||
BASE: "/routes",
|
||||
BY_ID: (id: string) => `/routes/${id}`,
|
||||
},
|
||||
|
||||
@@ -261,12 +277,15 @@ export const URL_CONSTANTS = {
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
`/train-scheduling/batch-board/${scheduleId}`,
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
RUN_ALLOCATION: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/run-allocation`,
|
||||
DOC_REVIEW_COMPLETE: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
||||
BOOKING_WINDOW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/booking-window`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||
EXPIRE_BOOKING: (bookingId: string) =>
|
||||
@@ -275,12 +294,14 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||
PREVIEW: "/train-scheduling/preview",
|
||||
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
|
||||
ASSIGN_BOOKINGS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/assign-bookings`,
|
||||
CONTAINER: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
|
||||
PREVIEW: "/train-scheduling/container/preview",
|
||||
SCHEDULES: "/train-scheduling/container/schedules",
|
||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
||||
SCHEDULE_BY_ID: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}`,
|
||||
ASSIGN_BOOKINGS: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}/assign-bookings`,
|
||||
CANCEL_SCHEDULE: (id: string) =>
|
||||
@@ -323,15 +344,18 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||
RESCHEDULE_EXECUTE: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/execute`,
|
||||
MAINTENANCE: (id: string) => `/train-scheduling/schedules/${id}/maintenance`,
|
||||
MAINTENANCE: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/maintenance`,
|
||||
SCHEDULES: "/train-scheduling/container/schedules",
|
||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
||||
SCHEDULE_BY_ID: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}`,
|
||||
CANCEL_SCHEDULE: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}/cancel`,
|
||||
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
||||
@@ -379,175 +403,193 @@ export const URL_CONSTANTS = {
|
||||
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
||||
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
||||
},
|
||||
RATE_MATRIX: {
|
||||
BASE: '/api/rate-matrices',
|
||||
DRAFT: '/api/rate-matrices/draft',
|
||||
RATE_MATRIX: {
|
||||
BASE: "/api/rate-matrices",
|
||||
DRAFT: "/api/rate-matrices/draft",
|
||||
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
|
||||
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
|
||||
LIST: '/api/rate-matrices',
|
||||
LIST: "/api/rate-matrices",
|
||||
DETAIL: (id: string) => `/api/rate-matrices/${id}`,
|
||||
},
|
||||
REFERENCE: {
|
||||
PORTS: '/api/reference/ports',
|
||||
CITIES: '/api/reference/cities',
|
||||
CONTAINER_TYPES: '/api/reference/container-types',
|
||||
CURRENCIES: '/api/reference/currencies',
|
||||
PORTS: "/api/reference/ports",
|
||||
CITIES: "/api/reference/cities",
|
||||
CONTAINER_TYPES: "/api/reference/container-types",
|
||||
CURRENCIES: "/api/reference/currencies",
|
||||
},
|
||||
|
||||
FACILITIES: {
|
||||
BASE: '/facilities',
|
||||
BASE: "/facilities",
|
||||
BY_ID: (id: string) => `/facilities/${id}`,
|
||||
},
|
||||
|
||||
WAREHOUSES: {
|
||||
BASE: '/warehouses',
|
||||
DASHBOARD: '/warehouses/dashboard',
|
||||
BASE: "/warehouses",
|
||||
DASHBOARD: "/warehouses/dashboard",
|
||||
BY_ID: (id: string) => `/warehouses/${id}`,
|
||||
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
||||
},
|
||||
|
||||
WAREHOUSE_YARDS: {
|
||||
BASE: '/warehouse-yards',
|
||||
BASE: "/warehouse-yards",
|
||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONES: {
|
||||
BASE: '/warehouse-zones',
|
||||
BASE: "/warehouse-zones",
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVENTORY: {
|
||||
BASE: '/warehouse-inventory',
|
||||
RECEIVE: '/warehouse-inventory/receive',
|
||||
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
BASE: "/warehouse-inventory",
|
||||
RECEIVE: "/warehouse-inventory/receive",
|
||||
DASHBOARD_SUMMARY: "/warehouse-inventory/dashboard/summary",
|
||||
READY_FOR_LOADING: "/warehouse-inventory/ready-for-loading",
|
||||
INQUIRY: "/warehouse-inventory/inquiry",
|
||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: '/warehouse-inventory/reserve',
|
||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
||||
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
||||
UNLOAD_BOOKING: (bookingId: string) =>
|
||||
`/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||
INSPECTION_REPORTS: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||
LOADABLE_WAGONS: "/warehouse-inventory/loadable-wagons",
|
||||
BOOKING_SCHEDULE: (bookingId: string) =>
|
||||
`/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||
// Import branch
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
MARK_READY_PICKUP: (id: string) =>
|
||||
`/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||
RELEASE_DOCUMENT: (id: string) =>
|
||||
`/warehouse-inventory/${id}/release-document`,
|
||||
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
||||
HANDOVER_DOCUMENT: (id: string) =>
|
||||
`/warehouse-inventory/${id}/handover-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||
direction
|
||||
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
||||
: `/warehouse-inventory/eligible-bookings`,
|
||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
||||
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
|
||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
||||
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
|
||||
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
|
||||
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
|
||||
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
|
||||
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
|
||||
READY_TO_LOAD_EXPORT: "/warehouse-inventory/ready-to-load-export",
|
||||
LOADED_EXPORT: "/warehouse-inventory/loaded-export",
|
||||
BULK_DISPATCH_EXPORT: "/warehouse-inventory/bulk-dispatch-export",
|
||||
IMPORT_ARRIVE_QUEUE: "/warehouse-inventory/import/arrive-queue",
|
||||
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
||||
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
||||
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
|
||||
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
|
||||
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
|
||||
EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue',
|
||||
IMPORT_AUTO_UNLOAD_ARRIVED:
|
||||
"/warehouse-inventory/import/auto-unload-arrived-bookings",
|
||||
IMPORT_UNLOADED_QUEUE: "/warehouse-inventory/import/unloaded-queue",
|
||||
IMPORT_PICKUP_READY_QUEUE: "/warehouse-inventory/import/pickup-ready-queue",
|
||||
EXPORT_DJIBOUTI_ARRIVAL_QUEUE:
|
||||
"/warehouse-inventory/export/djibouti-arrival-queue",
|
||||
EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
|
||||
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`,
|
||||
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti',
|
||||
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI:
|
||||
"/warehouse-inventory/export/auto-unload-at-djibouti",
|
||||
},
|
||||
|
||||
WAREHOUSE_LOADINGS: {
|
||||
BASE: '/warehouse-loadings',
|
||||
BASE: "/warehouse-loadings",
|
||||
},
|
||||
|
||||
WAREHOUSE_INSPECTION: {
|
||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
||||
ATTACHMENTS: (id: string) =>
|
||||
`/warehouse-inspection-reports/${id}/attachments`,
|
||||
},
|
||||
|
||||
WAREHOUSE_RULES: {
|
||||
ALLOCATION: '/warehouse-allocation-rules',
|
||||
ALLOCATION: "/warehouse-allocation-rules",
|
||||
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
||||
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
|
||||
FEES: '/warehouse-fee-rules',
|
||||
ALLOCATION_PREVIEW: "/warehouse-allocation/preview",
|
||||
FEES: "/warehouse-fee-rules",
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
FEE_PREVIEW: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
BASE: '/warehouse-fee-invoices',
|
||||
BASE: "/warehouse-fee-invoices",
|
||||
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
||||
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
|
||||
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
|
||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
|
||||
GENERATE: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||
FOR_INVENTORY: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||
FOR_BOOKING: (bookingId: string) =>
|
||||
`/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
GATE_CLEARANCE: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/gate-clearance`,
|
||||
},
|
||||
|
||||
INTERCHANGE_DOCUMENTS: {
|
||||
BASE: '/interchange-documents',
|
||||
BASE: "/interchange-documents",
|
||||
BY_ID: (id: string) => `/interchange-documents/${id}`,
|
||||
GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule',
|
||||
GENERATE_FROM_SCHEDULE: "/interchange-documents/generate-from-schedule",
|
||||
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
|
||||
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
|
||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||
},
|
||||
|
||||
IMPORT_OPERATIONS: {
|
||||
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
|
||||
DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
|
||||
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
||||
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
|
||||
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
|
||||
CUSTOMS_DOCUMENTS: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/documents`,
|
||||
CUSTOMS_DECLARATION: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/declaration`,
|
||||
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
||||
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
||||
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
|
||||
CUSTOMS_RISK: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/risk`,
|
||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
|
||||
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
|
||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||
`/import-operations/empty-container-returns/${id}/status`,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
BASE: '/vehicles',
|
||||
BASE: "/vehicles",
|
||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||
},
|
||||
|
||||
FIRST_MILE: {
|
||||
BASE: '/first-mile',
|
||||
BASE: "/first-mile",
|
||||
BY_ID: (id: string) => `/first-mile/${id}`,
|
||||
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
|
||||
},
|
||||
|
||||
LAST_MILE: {
|
||||
BASE: '/last-mile',
|
||||
BASE: "/last-mile",
|
||||
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||
},
|
||||
|
||||
DRIVERS: {
|
||||
BASE: '/drivers',
|
||||
BASE: "/drivers",
|
||||
BY_ID: (id: string) => `/drivers/${id}`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
||||
* from any file row to open the document inline (pdf / image / video / office /
|
||||
* text); render `viewer` once near the page root.
|
||||
*
|
||||
* const { view, viewer } = useFileViewer();
|
||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
||||
* {viewer}
|
||||
*/
|
||||
export function useFileViewer() {
|
||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
||||
|
||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
||||
const close = useCallback(() => setFile(null), []);
|
||||
|
||||
const viewer = (
|
||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
||||
);
|
||||
|
||||
return { view, close, viewer };
|
||||
}
|
||||
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
|
||||
// `@/hooks/useFileViewer` imports keep working.
|
||||
export { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
@@ -33,6 +33,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
createBooking: "edr_freight_app:contracts:create_booking",
|
||||
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
@@ -46,6 +47,9 @@ export const FREIGHT_PERMS = {
|
||||
manage: "edr_freight_app:fleet:manage",
|
||||
},
|
||||
admin: "edr_freight_app:admin",
|
||||
allocation: {
|
||||
manage: "edr_freight_app:allocation:manage",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||
@@ -69,6 +73,38 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
|
||||
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const emp of user.employee ?? []) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
if (pos.key) keys.add(pos.key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function hasPosition(
|
||||
user: AuthUser | null | undefined,
|
||||
positionKey: string,
|
||||
): boolean {
|
||||
return getPositionKeys(user).includes(positionKey);
|
||||
}
|
||||
|
||||
export const POSITION_KEYS = {
|
||||
ethiopianGl: "ethiopian_gl",
|
||||
djiboutiGl: "djibouti_gl",
|
||||
} as const;
|
||||
|
||||
export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
|
||||
return hasPosition(user, POSITION_KEYS.ethiopianGl);
|
||||
}
|
||||
|
||||
export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
|
||||
return hasPosition(user, POSITION_KEYS.djiboutiGl);
|
||||
}
|
||||
|
||||
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
||||
if (user?.isSuperAdmin) return true;
|
||||
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -17,10 +18,13 @@ import {
|
||||
ArrowRight,
|
||||
Banknote,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
BookingStatusBadge,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
@@ -50,7 +55,13 @@ import type {
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
} from "@/types/customer";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
useFileViewer,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
@@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: company, isLoading } = useQuery(
|
||||
api.customers.getById.queryOptions({
|
||||
@@ -104,9 +116,34 @@ export default function CustomerDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
|
||||
usePagination({
|
||||
pageSize: 10,
|
||||
});
|
||||
const invoiceFilter = useMemo(
|
||||
() => ({
|
||||
companyId: id ?? "",
|
||||
page: invoicePagination.pageIndex + 1,
|
||||
pageSize: invoicePagination.pageSize,
|
||||
}),
|
||||
[id, invoicePagination.pageIndex, invoicePagination.pageSize],
|
||||
);
|
||||
const invoicesQuery = useQuery(
|
||||
api.invoices.list.queryOptions({
|
||||
input: { filter: invoiceFilter },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
|
||||
const bookings = bookingsQuery.data ?? [];
|
||||
const documents = documentsQuery.data ?? [];
|
||||
const payments = paymentsQuery.data ?? [];
|
||||
const invoices = invoicesQuery.data?.items ?? [];
|
||||
const invoiceTotal = invoicesQuery.data?.total ?? 0;
|
||||
const invoicePageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(invoiceTotal / invoicePagination.pageSize),
|
||||
);
|
||||
|
||||
const totalPaid = useMemo(
|
||||
() =>
|
||||
@@ -285,20 +322,37 @@ export default function CustomerDetailPage() {
|
||||
header: "",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={fileViewUrl(row.original.id, true)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download"
|
||||
data-stop-row-click
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="View"
|
||||
data-stop-row-click
|
||||
onClick={() =>
|
||||
view({
|
||||
name: row.original.name,
|
||||
url: fileViewUrl(row.original.id),
|
||||
mimeType: row.original.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={fileViewUrl(row.original.id, true)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download"
|
||||
data-stop-row-click
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[view],
|
||||
);
|
||||
|
||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||
@@ -358,6 +412,59 @@ export default function CustomerDetailPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const invoiceColumns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoiceNumber",
|
||||
header: "Invoice",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.invoiceNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: "Source",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(row.original.source)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: "Amount",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dueAt",
|
||||
header: "Due",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.dueAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih="60vh">
|
||||
@@ -392,8 +499,9 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
@@ -416,6 +524,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
||||
Payments
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
@@ -528,9 +639,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -539,23 +650,63 @@ export default function CustomerDetailPage() {
|
||||
|
||||
{/* DOCUMENTS */}
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<TableCard minWidth={760}>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
status={tableStatus(documentsQuery)}
|
||||
emptyMessage="No documents uploaded."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCard>
|
||||
<Stack gap="lg">
|
||||
<TableCard minWidth={760}>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
status={tableStatus(documentsQuery)}
|
||||
emptyMessage="No documents uploaded."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCard>
|
||||
|
||||
{licenseProfiles.length > 0 && (
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
Business licenses
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{humanize(p.type)} · {p.reference}
|
||||
</Text>
|
||||
{(p.licenseFiles ?? []).map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* PAYMENTS */}
|
||||
@@ -570,15 +721,53 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* INVOICES */}
|
||||
<Tabs.Panel value="invoices" pt="lg">
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={860}>
|
||||
<DataTable
|
||||
columns={invoiceColumns}
|
||||
data={invoices}
|
||||
status={tableStatus(invoicesQuery)}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage="No invoices for this customer."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
error={
|
||||
invoicesQuery.isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: invoicePagination.pageIndex,
|
||||
pageSize: invoicePagination.pageSize,
|
||||
pageCount: invoicePageCount,
|
||||
totalCount: invoiceTotal,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination: invoicePagination },
|
||||
onPaginationChange: setInvoicePagination,
|
||||
manualPagination: true,
|
||||
pageCount: invoicePageCount,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
InvoiceStatusBadge,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
|
||||
function openPdfBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-muted"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.04em" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text">
|
||||
{value && value.trim() ? value : "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading } = useQuery(
|
||||
api.invoices.getById.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
|
||||
const downloadDocument = async () => {
|
||||
if (!id) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { data } = await invoicesService.downloadDocument(id);
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih="60vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Text fw={700}>Invoice not found</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/invoices")}
|
||||
>
|
||||
Back to invoices
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Invoices", href: "/dashboard/invoices" },
|
||||
{ label: invoice.invoiceNumber },
|
||||
]}
|
||||
backTo="/dashboard/invoices"
|
||||
title={invoice.invoiceNumber}
|
||||
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
|
||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
loading={downloading}
|
||||
onClick={() => void downloadDocument()}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Summary
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<InfoField label="Billed to" value={invoice.company?.name} />
|
||||
<InfoField
|
||||
label="Profile"
|
||||
value={invoice.companyProfile?.reference}
|
||||
/>
|
||||
<InfoField label="Type" value={humanize(invoice.type)} />
|
||||
<InfoField label="Currency" value={invoice.currency} />
|
||||
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
||||
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
||||
<InfoField
|
||||
label="Total"
|
||||
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Balance"
|
||||
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
Line items
|
||||
</Text>
|
||||
<Table striped withRowBorders={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Charge type</Table.Th>
|
||||
<Table.Th ta="right">Quantity</Table.Th>
|
||||
<Table.Th ta="right">Unit rate</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(invoice.lines ?? []).map((line) => (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(line.chargeType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.unitRate, line.currency)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.amount, line.currency)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{(invoice.lines ?? []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No line items.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap="xl"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Subtotal
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Tax
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.taxAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Paid
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.paidAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" fw={700}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RefreshCw, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
InvoiceStatusBadge,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.invoices.list.queryOptions({ input: { filter } }),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoiceNumber",
|
||||
header: "Invoice",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.invoiceNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "billedTo",
|
||||
header: "Billed to",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text">
|
||||
{row.original.company?.name ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: "Source",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(row.original.source)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: "Amount",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "balance",
|
||||
header: "Balance",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatMoney(row.original.balanceAmount, row.original.currency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dueAt",
|
||||
header: "Due",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.dueAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Refresh"
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search by invoice number…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(
|
||||
v === "all" ? "" : (v as Freight.InvoiceStatus),
|
||||
);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending", value: "PENDING" },
|
||||
{ label: "Paid", value: "PAID" },
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={920}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: "No invoices yet."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,14 @@ export interface FormFieldDef {
|
||||
* showWhen and not match hideWhen.
|
||||
*/
|
||||
showWhen?: { field: string; equals: string[] };
|
||||
/**
|
||||
* Select options computed from other fields' current values. When set, the
|
||||
* form resolves the option list at render time from the live form state
|
||||
* instead of the static `options` list. Used for the rate unit selector,
|
||||
* whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly
|
||||
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
|
||||
*/
|
||||
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export interface RuleEngineOrderConfig {
|
||||
@@ -116,12 +124,54 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
];
|
||||
|
||||
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
|
||||
(v) => ({
|
||||
label: v.replace(/_/g, " "),
|
||||
value: v,
|
||||
}),
|
||||
);
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
* Valid weighting units for a rate shape — mirrors the API's
|
||||
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
|
||||
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||
*/
|
||||
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||
if (appliesTo === "OTHER") {
|
||||
switch (trigger) {
|
||||
case "OVERWEIGHT":
|
||||
return ["PER_TON"];
|
||||
case "REEFER":
|
||||
case "HAZARDOUS":
|
||||
case "DEMURRAGE":
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
case "CONSOLIDATION":
|
||||
case "SHIPPING_LINE":
|
||||
case "PIL_EXTRA_FEE":
|
||||
return ["PER_CONTAINER", "FLAT"];
|
||||
default:
|
||||
return ["FLAT", "PER_TON", "PER_CONTAINER"];
|
||||
}
|
||||
}
|
||||
switch (appliesTo) {
|
||||
case "CONTAINER":
|
||||
return ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "BULK":
|
||||
return ["PER_TON", "PER_WAGON"];
|
||||
case "INTERCITY":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||
case "FIRST_MILE":
|
||||
case "LAST_MILE":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||
default:
|
||||
return ["FLAT"];
|
||||
}
|
||||
};
|
||||
|
||||
const rateUnitOptions = (values: Record<string, unknown>) => {
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
return allowedRateUnits(appliesTo, trigger).map(unitOption);
|
||||
};
|
||||
|
||||
const CURRENCIES = [
|
||||
{ label: "USD", value: "USD" },
|
||||
@@ -324,8 +374,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
|
||||
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
{
|
||||
@@ -343,8 +391,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: TRADE_DIRECTIONS,
|
||||
},
|
||||
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -407,7 +453,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
{
|
||||
@@ -457,9 +502,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
// is always per excess ton, so the unit field is hidden for it — the API
|
||||
// forces PER_TON regardless.
|
||||
{
|
||||
name: "rateUnit",
|
||||
label: "Rate unit",
|
||||
type: "select",
|
||||
required: true,
|
||||
optionsFromValues: rateUnitOptions,
|
||||
description: "Weighting basis — options depend on what the rate applies to.",
|
||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
@@ -242,6 +243,25 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
{b.consolidationPartnerRef ? (
|
||||
<Tooltip
|
||||
label={`Consolidated — shares one wagon with ${b.consolidationPartnerRef}`}
|
||||
withArrow
|
||||
multiline
|
||||
maw={260}
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ArrowLeftRight size={10} />}
|
||||
style={{ textTransform: "none" }}
|
||||
>
|
||||
shared wagon · {b.consolidationPartnerRef}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,10 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
|
||||
// Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save.
|
||||
const [form, setForm] = useState<
|
||||
Partial<Record<keyof TrainSchedulingGlobalRules, number | string>>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -65,7 +68,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Sum of all wagon lengths must not exceed this"
|
||||
value={form.maxTrainLengthMeters ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -75,7 +78,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Total container and bulk cargo weight must not exceed this"
|
||||
value={form.maxTrainWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -84,7 +87,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
label="Max wagons per train"
|
||||
value={form.maxWagonsPerTrain ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -96,7 +99,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftContainerWeightTons: Number(value),
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
min={0.001}
|
||||
@@ -109,7 +112,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftPairWeightDiffTons: Number(value),
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
@@ -129,7 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="The single booking day opens this many days before departure"
|
||||
value={form.importWindowLeadDays ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
@@ -139,7 +142,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||
value={form.exportBookingLeadHours ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -149,7 +152,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
||||
value={form.windowOpenHour ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
}
|
||||
min={0}
|
||||
max={23}
|
||||
@@ -159,7 +162,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
label="Window duration (hours)"
|
||||
value={form.windowDurationHours ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
|
||||
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||
}
|
||||
min={0.25}
|
||||
max={12}
|
||||
@@ -171,7 +174,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Max staff time to accept booking documents after the window closes"
|
||||
value={form.docReviewMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
|
||||
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
@@ -181,7 +184,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Time a selected customer has to pay before the slot expires"
|
||||
value={form.paymentWindowMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -191,7 +194,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||
value={form.reopenDelayMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
|
||||
@@ -28,6 +28,11 @@ import type {
|
||||
UpdateFileUploadFieldDto,
|
||||
UpdateFileUploadSettingDto,
|
||||
} from "@/types/fileUploadSettings";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
} from "@/types/invoice";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import {
|
||||
RuleEngineListResult,
|
||||
@@ -130,6 +135,7 @@ import {
|
||||
import { containerTypesService } from "./container-types.service";
|
||||
import { containerService, type Container } from "./containerService";
|
||||
import { customersService } from "./customers.service";
|
||||
import { invoicesService } from "./invoices.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
import {
|
||||
@@ -199,7 +205,10 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
export const api = {
|
||||
trainScheduling: {
|
||||
// ── Queries ────────────────────────────────────────────────────────────
|
||||
scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>(
|
||||
scheduleList: endpoint<
|
||||
{ freightType?: FreightType },
|
||||
TrainScheduleListItem[]
|
||||
>(
|
||||
"train-scheduling",
|
||||
"schedules",
|
||||
({ freightType }) => trainSchedulingService.listSchedules(freightType),
|
||||
@@ -213,11 +222,16 @@ export const api = {
|
||||
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
),
|
||||
|
||||
batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>(
|
||||
batchBoardDetail: endpoint<
|
||||
{ scheduleId: string },
|
||||
BatchBoardScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"batch-board-detail",
|
||||
({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId),
|
||||
({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
({ scheduleId }) =>
|
||||
trainSchedulingService.getBatchBoardDetail(scheduleId),
|
||||
({ scheduleId }) =>
|
||||
QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
),
|
||||
|
||||
scheduleDetail: endpoint<
|
||||
@@ -365,7 +379,10 @@ export const api = {
|
||||
),
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────
|
||||
runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>(
|
||||
runAllocation: endpoint<
|
||||
{ scheduleId: string },
|
||||
WagonAllocationAttemptResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"run-allocation",
|
||||
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
|
||||
@@ -483,7 +500,10 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>(
|
||||
pinWagons: endpoint<
|
||||
{ id: string; payload: PinWagonsPayload },
|
||||
TrainScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"pin-wagons",
|
||||
({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
|
||||
@@ -579,8 +599,10 @@ export const api = {
|
||||
({ id }) => warehouseService.getById(id).then((r) => r.data),
|
||||
),
|
||||
|
||||
dashboard: endpoint<void, WarehouseDashboard>("warehouses", "dashboard", () =>
|
||||
warehouseService.dashboard().then((r) => r.data),
|
||||
dashboard: endpoint<void, WarehouseDashboard>(
|
||||
"warehouses",
|
||||
"dashboard",
|
||||
() => warehouseService.dashboard().then((r) => r.data),
|
||||
),
|
||||
|
||||
create: endpoint<SaveWarehousePayload, Warehouse>(
|
||||
@@ -671,10 +693,8 @@ export const api = {
|
||||
listInventory: endpoint<
|
||||
{ filter?: InventoryFilter },
|
||||
WarehouseInventoryItem[]
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"list",
|
||||
({ filter }) => warehouseService.listInventory(filter).then((r) => r.data),
|
||||
>("warehouse-inventory", "list", ({ filter }) =>
|
||||
warehouseService.listInventory(filter).then((r) => r.data),
|
||||
),
|
||||
|
||||
inquiry: endpoint<
|
||||
@@ -687,11 +707,19 @@ export const api = {
|
||||
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
||||
),
|
||||
|
||||
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
|
||||
eligibleBookings: endpoint<
|
||||
{ direction?: "IMPORT" | "EXPORT" } | void,
|
||||
EligibleBooking[]
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"eligible-bookings",
|
||||
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
||||
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
|
||||
(input) =>
|
||||
warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
||||
(input) => [
|
||||
"warehouse-inventory",
|
||||
"eligible-bookings",
|
||||
input?.direction ?? "ALL",
|
||||
],
|
||||
),
|
||||
|
||||
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
||||
@@ -727,7 +755,11 @@ export const api = {
|
||||
"import-train-items",
|
||||
({ scheduleId }) =>
|
||||
warehouseService.importTrainItems(scheduleId).then((r) => r.data),
|
||||
({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId],
|
||||
({ scheduleId }) => [
|
||||
"warehouse-inventory",
|
||||
"import-train-items",
|
||||
scheduleId,
|
||||
],
|
||||
),
|
||||
|
||||
importUnloadedQueue: endpoint<void, ImportUnloadedItem[]>(
|
||||
@@ -795,8 +827,11 @@ export const api = {
|
||||
"inspection-reports",
|
||||
({ inventoryId }) =>
|
||||
warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
|
||||
({ inventoryId }) =>
|
||||
["warehouse-inventory", inventoryId, "inspection-reports"],
|
||||
({ inventoryId }) => [
|
||||
"warehouse-inventory",
|
||||
inventoryId,
|
||||
"inspection-reports",
|
||||
],
|
||||
),
|
||||
|
||||
allocationRules: endpoint<void, AllocationRule[]>(
|
||||
@@ -813,15 +848,28 @@ export const api = {
|
||||
() => ["warehouse-fee-rules"],
|
||||
),
|
||||
|
||||
feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>(
|
||||
feePreview: endpoint<
|
||||
{ inventoryId: string; billingCurrency?: "ETB" | "USD" },
|
||||
FeePreview[]
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"fee-preview",
|
||||
({ inventoryId, billingCurrency }) =>
|
||||
warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data),
|
||||
({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'],
|
||||
warehouseService
|
||||
.feePreview(inventoryId, billingCurrency)
|
||||
.then((r) => r.data),
|
||||
({ inventoryId, billingCurrency }) => [
|
||||
"warehouse-inventory",
|
||||
inventoryId,
|
||||
"fee-preview",
|
||||
billingCurrency ?? "USD",
|
||||
],
|
||||
),
|
||||
|
||||
invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>(
|
||||
invoices: endpoint<
|
||||
{ filter?: WarehouseInvoiceFilter },
|
||||
WarehouseFeeInvoice[]
|
||||
>(
|
||||
"warehouse-fee-invoices",
|
||||
"list",
|
||||
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
|
||||
@@ -850,7 +898,8 @@ export const api = {
|
||||
receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>(
|
||||
"warehouse-inventory",
|
||||
"receive",
|
||||
(payload) => warehouseService.receiveInventory(payload).then((r) => r.data),
|
||||
(payload) =>
|
||||
warehouseService.receiveInventory(payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["warehouse-inventory"], ["warehouses"]],
|
||||
),
|
||||
@@ -885,7 +934,8 @@ export const api = {
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"load",
|
||||
({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data),
|
||||
({ id, payload }) =>
|
||||
warehouseService.load(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
@@ -904,7 +954,8 @@ export const api = {
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"move",
|
||||
({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data),
|
||||
({ id, payload }) =>
|
||||
warehouseService.move(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
@@ -960,7 +1011,8 @@ export const api = {
|
||||
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
||||
"warehouse-inventory",
|
||||
"bulk-mark-inspected",
|
||||
(payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data),
|
||||
(payload) =>
|
||||
warehouseService.bulkMarkInspected(payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
@@ -978,7 +1030,12 @@ export const api = {
|
||||
{
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
assignments?: {
|
||||
bookingId: string;
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
}[];
|
||||
},
|
||||
AutoUnloadArrivedResult
|
||||
>(
|
||||
@@ -1050,11 +1107,11 @@ export const api = {
|
||||
),
|
||||
|
||||
// ── Allocation + fee rules ─────────────────────────────────────────────
|
||||
previewAllocation: endpoint<AllocationCriteria, AllocationPreviewResult | null>(
|
||||
"warehouse-allocation-rules",
|
||||
"preview",
|
||||
(criteria) =>
|
||||
warehouseService.previewAllocation(criteria).then((r) => r.data),
|
||||
previewAllocation: endpoint<
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult | null
|
||||
>("warehouse-allocation-rules", "preview", (criteria) =>
|
||||
warehouseService.previewAllocation(criteria).then((r) => r.data),
|
||||
),
|
||||
|
||||
createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>(
|
||||
@@ -1116,7 +1173,11 @@ export const api = {
|
||||
|
||||
// ── Invoices ───────────────────────────────────────────────────────────
|
||||
generateInvoice: endpoint<
|
||||
{ inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' },
|
||||
{
|
||||
inventoryId: string;
|
||||
confirmZero?: boolean;
|
||||
billingCurrency?: "ETB" | "USD";
|
||||
},
|
||||
WarehouseFeeInvoice
|
||||
>(
|
||||
"warehouse-fee-invoices",
|
||||
@@ -1172,7 +1233,10 @@ export const api = {
|
||||
},
|
||||
|
||||
routes: {
|
||||
list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>(
|
||||
list: endpoint<
|
||||
{ status?: import("./routes.service").RouteStatus } | void,
|
||||
RouteRecord[]
|
||||
>(
|
||||
"routes",
|
||||
"list",
|
||||
(input) =>
|
||||
@@ -1197,7 +1261,10 @@ export const api = {
|
||||
() => [["routes"]],
|
||||
),
|
||||
|
||||
update: endpoint<{ id: string; data: Partial<SaveRoutePayload> }, RouteRecord>(
|
||||
update: endpoint<
|
||||
{ id: string; data: Partial<SaveRoutePayload> },
|
||||
RouteRecord
|
||||
>(
|
||||
"routes",
|
||||
"update",
|
||||
({ id, data }) => routesService.update(id, data).then((r) => r.data),
|
||||
@@ -1310,10 +1377,8 @@ export const api = {
|
||||
({ trainId }) => ["wagons", "train", trainId],
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, Wagon>(
|
||||
"wagons",
|
||||
"getById",
|
||||
({ id }) => wagonService.getById(id).then((r) => r.data),
|
||||
getById: endpoint<{ id: string }, Wagon>("wagons", "getById", ({ id }) =>
|
||||
wagonService.getById(id).then((r) => r.data),
|
||||
),
|
||||
|
||||
assignToTrain: endpoint<
|
||||
@@ -1692,7 +1757,8 @@ export const api = {
|
||||
>(
|
||||
"file-upload-settings",
|
||||
"addField",
|
||||
({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto),
|
||||
({ settingId, dto }) =>
|
||||
fileUploadSettingsService.addField(settingId, dto),
|
||||
undefined,
|
||||
() => [["file-upload-settings"]],
|
||||
),
|
||||
@@ -1791,7 +1857,8 @@ export const api = {
|
||||
>(
|
||||
"dropdown-settings",
|
||||
"updateOption",
|
||||
({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto),
|
||||
({ optionId, dto }) =>
|
||||
dropdownSettingsService.updateOption(optionId, dto),
|
||||
undefined,
|
||||
() => [["dropdown-settings"]],
|
||||
),
|
||||
@@ -1844,11 +1911,10 @@ export const api = {
|
||||
ruleEngineService.update(resource, id, payload),
|
||||
),
|
||||
|
||||
remove: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; id: string },
|
||||
void
|
||||
>("rule-engine", "remove", ({ resource, id }) =>
|
||||
ruleEngineService.remove(resource, id),
|
||||
remove: endpoint<{ resource: RuleEngineResourceSlug; id: string }, void>(
|
||||
"rule-engine",
|
||||
"remove",
|
||||
({ resource, id }) => ruleEngineService.remove(resource, id),
|
||||
),
|
||||
|
||||
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
|
||||
@@ -1871,14 +1937,21 @@ export const api = {
|
||||
),
|
||||
|
||||
reorder: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } },
|
||||
{
|
||||
resource: RuleEngineResourceSlug;
|
||||
payload: { ids: string[]; requiresDirectorApproval?: boolean };
|
||||
},
|
||||
void
|
||||
>("rule-engine", "reorder", ({ resource, payload }) =>
|
||||
ruleEngineService.reorder(resource, payload),
|
||||
),
|
||||
|
||||
moveOrder: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" },
|
||||
{
|
||||
resource: RuleEngineResourceSlug;
|
||||
id: string;
|
||||
direction: "up" | "down";
|
||||
},
|
||||
void
|
||||
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
|
||||
ruleEngineService.moveOrder(resource, id, direction),
|
||||
@@ -1900,10 +1973,8 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>(
|
||||
"bookings",
|
||||
"remove",
|
||||
({ id }) => bookingsService.remove(id),
|
||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||
bookingsService.remove(id),
|
||||
),
|
||||
|
||||
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
|
||||
@@ -1953,10 +2024,11 @@ export const api = {
|
||||
({ id }) => bookingsService.generateContract(id),
|
||||
),
|
||||
|
||||
getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
|
||||
"bookings",
|
||||
"getContractView",
|
||||
({ id }) => bookingsService.getContractView(id),
|
||||
getContractView: endpoint<
|
||||
{ id: string },
|
||||
import("./bookings.service").ContractView
|
||||
>("bookings", "getContractView", ({ id }) =>
|
||||
bookingsService.getContractView(id),
|
||||
),
|
||||
|
||||
signContract: endpoint<
|
||||
@@ -2040,7 +2112,8 @@ export const api = {
|
||||
>(
|
||||
"customers",
|
||||
"setProfileStatus",
|
||||
({ profileId, status }) => customersService.setProfileStatus(profileId, status),
|
||||
({ profileId, status }) =>
|
||||
customersService.setProfileStatus(profileId, status),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
@@ -2061,6 +2134,22 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
invoices: {
|
||||
list: endpoint<{ filter: InvoiceListFilter }, PaginatedInvoices>(
|
||||
"invoices",
|
||||
"list",
|
||||
({ filter }) => invoicesService.list(filter),
|
||||
({ filter }) => QUERY_KEYS.INVOICES.list(filter),
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, Invoice>(
|
||||
"invoices",
|
||||
"getById",
|
||||
({ id }) => invoicesService.getById(id),
|
||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||
),
|
||||
},
|
||||
|
||||
overview: {
|
||||
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
|
||||
"overview",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
} from "@/types/invoice";
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
([, value]) => value !== undefined && value !== "" && value !== null,
|
||||
),
|
||||
);
|
||||
|
||||
export const invoicesService = {
|
||||
list(filter: InvoiceListFilter): Promise<PaginatedInvoices> {
|
||||
return apiClient
|
||||
.get<PaginatedInvoices>(URL_CONSTANTS.BILLING.INVOICES, {
|
||||
params: cleanParams(filter),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
getById(id: string): Promise<Invoice> {
|
||||
return apiClient
|
||||
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
downloadDocument(id: string) {
|
||||
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -15,8 +15,6 @@ export interface Rate {
|
||||
proposedByStaffId: string;
|
||||
approvedByCeoId: string | null;
|
||||
approvedAt: string | null;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
|
||||
@@ -32,6 +32,14 @@ export type ProfileType =
|
||||
/** Mirrors backend `ProfileStatus`. */
|
||||
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||
|
||||
/** A business-license document uploaded for a company profile. */
|
||||
export interface LicenseFile {
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** A single role a company is registered for, with its reference code. */
|
||||
export interface CompanyProfile {
|
||||
id: string;
|
||||
@@ -39,7 +47,10 @@ export interface CompanyProfile {
|
||||
type: ProfileType;
|
||||
reference: string;
|
||||
status: ProfileStatus;
|
||||
/** @deprecated Superseded by licenseFiles (file model). */
|
||||
businessLicense?: string | null;
|
||||
/** Business-license documents uploaded for this profile. */
|
||||
licenseFiles?: LicenseFile[];
|
||||
attributes?: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
22
apps/edr-freight-web/backoffice/src/types/invoice.ts
Normal file
22
apps/edr-freight-web/backoffice/src/types/invoice.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */
|
||||
export interface Invoice extends Freight.IInvoice {
|
||||
subtotalAmount: number;
|
||||
taxAmount: number;
|
||||
}
|
||||
|
||||
/** Query parameters for the invoice list. */
|
||||
export interface InvoiceListFilter {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/** Standard paginated list envelope (matches the customers/bookings service shape). */
|
||||
export interface PaginatedInvoices {
|
||||
items: Invoice[];
|
||||
total: number;
|
||||
}
|
||||
@@ -277,6 +277,8 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
allocationIssue: string | null;
|
||||
consolidationPartnerId: string | null;
|
||||
consolidationPartnerRef: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
Globe2,
|
||||
@@ -42,44 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Form steps rendered by CompanyProfileForm. */
|
||||
type FormStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
|
||||
const FORM_STEPS: FormStep[] = [
|
||||
"company",
|
||||
"personnel",
|
||||
"contact",
|
||||
"verify",
|
||||
"poa",
|
||||
"documents",
|
||||
"additional",
|
||||
];
|
||||
|
||||
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
||||
type WizardStep = "nationality" | "role" | FormStep;
|
||||
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
|
||||
type WizardStep = "nationality-role" | FormStep;
|
||||
const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS];
|
||||
|
||||
/** Icon + title + description shown in the global dialog header per step. */
|
||||
const STEP_META: Record<
|
||||
WizardStep,
|
||||
{ icon: ReactNode; title: string; description: string }
|
||||
> = {
|
||||
nationality: {
|
||||
"nationality-role": {
|
||||
icon: <Globe2 size={20} />,
|
||||
title: "Where is your company registered?",
|
||||
title: "Tell us about your company",
|
||||
description: "This determines the documents we'll ask you to provide.",
|
||||
},
|
||||
role: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "What does your company do?",
|
||||
description:
|
||||
"Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.",
|
||||
},
|
||||
company: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
@@ -95,11 +78,6 @@ const STEP_META: Record<
|
||||
title: "Contact Person",
|
||||
description: "Who should we reach out to about this account?",
|
||||
},
|
||||
verify: {
|
||||
icon: <ShieldCheck size={20} />,
|
||||
title: "Verify Contact Person",
|
||||
description: "Confirm the contact phone with a one-time SMS code.",
|
||||
},
|
||||
poa: {
|
||||
icon: <FileText size={20} />,
|
||||
title: "Power of Attorney",
|
||||
@@ -110,11 +88,6 @@ const STEP_META: Record<
|
||||
title: "Upload Documents",
|
||||
description: "Provide the required company documents.",
|
||||
},
|
||||
additional: {
|
||||
icon: <CheckCircle2 size={20} />,
|
||||
title: "Business License",
|
||||
description: "Upload a business license for each operational profile.",
|
||||
},
|
||||
};
|
||||
|
||||
interface OnboardingWizardDialogProps {
|
||||
@@ -172,12 +145,8 @@ export default function OnboardingWizardDialog({
|
||||
|
||||
// Phases: nationality → role → form. If a draft already exists, resume
|
||||
// straight into the form with nationality + roles pre-selected.
|
||||
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
|
||||
companyAlreadyStarted
|
||||
? hasOperationalProfiles
|
||||
? "form"
|
||||
: "role"
|
||||
: "nationality",
|
||||
const [phase, setPhase] = useState<"nationality-role" | "form">(
|
||||
companyAlreadyStarted ? "form" : "nationality-role",
|
||||
);
|
||||
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
||||
savedNationality,
|
||||
@@ -302,16 +271,12 @@ export default function OnboardingWizardDialog({
|
||||
setNationality(savedNationality);
|
||||
// Resume into the form only when profiles exist; otherwise send the user to
|
||||
// role selection so the missing operational profiles get created.
|
||||
setPhase(hasOperationalProfiles ? "form" : "role");
|
||||
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
||||
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
||||
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyAlreadyStarted, resumeFormStep]);
|
||||
|
||||
const handleNationalityContinue = useCallback(() => {
|
||||
if (nationality) setPhase("role");
|
||||
}, [nationality]);
|
||||
|
||||
const handleRolesContinue = useCallback(() => {
|
||||
setStartError(null);
|
||||
startMutation.mutate({
|
||||
@@ -394,6 +359,7 @@ export default function OnboardingWizardDialog({
|
||||
// The active step across the whole journey, driving the header + progress pill.
|
||||
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
||||
const stepMeta = STEP_META[activeStep];
|
||||
console.log({ stepMeta, activeStep, STEP_META });
|
||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||
|
||||
// Closing from the congratulations panel also clears the completed flag so a
|
||||
@@ -425,7 +391,7 @@ export default function OnboardingWizardDialog({
|
||||
);
|
||||
const effectiveResumeStep: FormStep =
|
||||
requiredDocsMissing &&
|
||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||
? "documents"
|
||||
: resumeFormStep;
|
||||
|
||||
@@ -497,26 +463,19 @@ export default function OnboardingWizardDialog({
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
{phase === "nationality" ? (
|
||||
{phase === "nationality-role" ? (
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
Where is your company registered?
|
||||
</Text>
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
What does your company do?(multiple)
|
||||
</Text>
|
||||
<OnboardingRoleSelect
|
||||
value={roles}
|
||||
onChange={setRoles}
|
||||
@@ -527,14 +486,7 @@ export default function OnboardingWizardDialog({
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
@@ -616,7 +568,7 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
</Stack>
|
||||
|
||||
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
||||
Go to my dashboard
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -126,6 +126,8 @@ export const URL_CONSTANTS = {
|
||||
`/api/contracts/${id}/clearance/documents`,
|
||||
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||
VALIDATE_SHIPMENT: (id: string) =>
|
||||
`/api/contracts/${id}/validate-shipment`,
|
||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||
BOOKING_MILESTONES: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
||||
* from any file row to open the document inline (pdf / image / video / office /
|
||||
* text); render `viewer` once near the page root.
|
||||
*
|
||||
* const { view, viewer } = useFileViewer();
|
||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
||||
* {viewer}
|
||||
*/
|
||||
export function useFileViewer() {
|
||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
||||
|
||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
||||
const close = useCallback(() => setFile(null), []);
|
||||
|
||||
const viewer = (
|
||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
||||
);
|
||||
|
||||
return { view, close, viewer };
|
||||
}
|
||||
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
|
||||
// `@/hooks/useFileViewer` imports keep working.
|
||||
export { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
PinInput,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -12,15 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
RotateCw,
|
||||
Smartphone,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -35,7 +26,6 @@ import RoleLicenseStep, {
|
||||
type RoleLicenseProfile,
|
||||
} from "@/components/onboarding/RoleLicenseStep";
|
||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import {
|
||||
type CompanyStep,
|
||||
type FormData,
|
||||
@@ -44,8 +34,6 @@ import {
|
||||
} from "./companyProfileForm/schema";
|
||||
import {
|
||||
buildPayload,
|
||||
maskPhone,
|
||||
samePhone,
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
@@ -295,6 +283,7 @@ export default function CompanyProfileForm({
|
||||
const useOwnerAsManager = () => {
|
||||
if (!etradeOwner) return;
|
||||
setValue("generalManagerName", etradeOwner.name);
|
||||
setValue("generalManagerEmail", user.email);
|
||||
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
@@ -350,85 +339,6 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
// --- Contact-phone SMS OTP verification -----------------------------------
|
||||
// The phone we verify is the contact-person phone, normalised to E.164 so it
|
||||
// matches what the backend persists as `contactVerifiedPhone`.
|
||||
const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? "");
|
||||
// Source of truth for "already verified" comes from the onboarding/profile
|
||||
// info (rehydrate) — so a refresh resumes the verify step's "done" state.
|
||||
const [verifiedPhone, setVerifiedPhone] = useState<string | null>(
|
||||
rehydrate?.contactVerifiedPhone ?? null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (rehydrate?.contactVerifiedPhone) {
|
||||
setVerifiedPhone(rehydrate.contactVerifiedPhone);
|
||||
}
|
||||
}, [rehydrate?.contactVerifiedPhone]);
|
||||
const phoneVerified = samePhone(verifiedPhone, contactPhoneE164);
|
||||
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [sendingOtp, setSendingOtp] = useState(false);
|
||||
const [verifyingOtp, setVerifyingOtp] = useState(false);
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
|
||||
// Resend cooldown countdown (no Date.now needed — pure setTimeout ticks).
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [resendIn]);
|
||||
|
||||
// A changed contact phone invalidates any in-flight code entry (the previous
|
||||
// code was for a different number). Verified state is handled separately via
|
||||
// the phone comparison, so this only resets the send/enter UI.
|
||||
useEffect(() => {
|
||||
setOtpSent(false);
|
||||
setOtpCode("");
|
||||
setOtpError(null);
|
||||
}, [contactPhoneE164]);
|
||||
|
||||
const sendContactOtp = async () => {
|
||||
setOtpError(null);
|
||||
if (!contactPhoneE164) {
|
||||
setOtpError("Enter a valid contact phone number first.");
|
||||
return;
|
||||
}
|
||||
setSendingOtp(true);
|
||||
try {
|
||||
await api.auth.sendOTP.call({ phone: contactPhoneE164 });
|
||||
setOtpSent(true);
|
||||
setOtpCode("");
|
||||
setResendIn(60);
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSendingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyContactOtp = async () => {
|
||||
setOtpError(null);
|
||||
if (otpCode.length !== 6) {
|
||||
setOtpError("Enter the 6-digit code we sent you.");
|
||||
return;
|
||||
}
|
||||
setVerifyingOtp(true);
|
||||
try {
|
||||
await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode });
|
||||
setVerifiedPhone(contactPhoneE164);
|
||||
setOtpSent(false);
|
||||
// Persist the verified phone so the step resumes as "done" after a refresh
|
||||
// (best-effort — the OTP itself already succeeded server-side).
|
||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifyingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||
|
||||
// The registration/license details come straight from the eTrade lookup and
|
||||
@@ -451,10 +361,8 @@ export default function CompanyProfileForm({
|
||||
"company",
|
||||
"personnel",
|
||||
"contact",
|
||||
"verify",
|
||||
"poa",
|
||||
"documents",
|
||||
"additional",
|
||||
];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
@@ -485,30 +393,6 @@ export default function CompanyProfileForm({
|
||||
|
||||
const nextStep = async () => {
|
||||
userNavigatedRef.current = true;
|
||||
if (step === "additional") {
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
"Please upload a business license for each of your operational profiles.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
// Contact-phone verification gates advancing past the verify step. The
|
||||
// verified phone is already persisted (on verify success), so there's
|
||||
// nothing extra to save here.
|
||||
if (step === "verify") {
|
||||
if (!phoneVerified) {
|
||||
setSaveError(
|
||||
"Please verify the contact person's phone number to continue.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
return;
|
||||
}
|
||||
// The documents step auto-uploads whatever the user selected as they
|
||||
// continue (partial uploads are allowed — required-doc completeness is
|
||||
// re-checked on resume). A failed upload holds them on the step.
|
||||
@@ -525,8 +409,15 @@ export default function CompanyProfileForm({
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
"Please upload a business license for each of your operational profiles.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
// Field steps validate + save before advancing.
|
||||
@@ -551,10 +442,7 @@ export default function CompanyProfileForm({
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
<>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Enter your TIN to auto-fill company information from eTrade
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
@@ -693,7 +581,7 @@ export default function CompanyProfileForm({
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step === "personnel" && (
|
||||
@@ -783,107 +671,6 @@ export default function CompanyProfileForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "verify" && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
We'll text a one-time code to the contact person's phone to
|
||||
confirm it's reachable. This is required before you continue.
|
||||
</Text>
|
||||
|
||||
{!contactPhoneE164 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
>
|
||||
Add a valid contact phone number on the previous step first.
|
||||
</Alert>
|
||||
) : phoneVerified ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
title="Phone verified"
|
||||
>
|
||||
{maskPhone(contactPhoneE164)} has been verified.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" align="center">
|
||||
<Smartphone
|
||||
size={16}
|
||||
className="text-[var(--mantine-color-edr-muted)]"
|
||||
/>
|
||||
<Text size="sm" c="edr-text">
|
||||
{maskPhone(contactPhoneE164)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!otpSent ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
onClick={sendContactOtp}
|
||||
loading={sendingOtp}
|
||||
leftSection={<Smartphone size={16} />}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Send code via SMS
|
||||
</Button>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={verifyContactOtp}
|
||||
loading={verifyingOtp}
|
||||
disabled={otpCode.length !== 6}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
onClick={sendContactOtp}
|
||||
loading={sendingOtp}
|
||||
disabled={resendIn > 0 || sendingOtp}
|
||||
leftSection={<RotateCw size={14} />}
|
||||
>
|
||||
{resendIn > 0
|
||||
? `Resend in ${resendIn}s`
|
||||
: "Resend code"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{otpError && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
>
|
||||
{otpError}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
@@ -954,15 +741,13 @@ export default function CompanyProfileForm({
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "additional" && (
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => { })}
|
||||
/>
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => { })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
@@ -970,11 +755,7 @@ export default function CompanyProfileForm({
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
title={
|
||||
step === "additional"
|
||||
? "Business license required"
|
||||
: "Couldn't save this step"
|
||||
}
|
||||
title={"Couldn't save this step"}
|
||||
>
|
||||
{saveError}
|
||||
</Alert>
|
||||
@@ -998,7 +779,7 @@ export default function CompanyProfileForm({
|
||||
onClick={prevStep}
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
>
|
||||
{step === "additional" ? "Back to Documents" : "Back"}
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
@@ -1009,17 +790,14 @@ export default function CompanyProfileForm({
|
||||
disabled={
|
||||
isPending ||
|
||||
saving ||
|
||||
(step === "documents" && !hasDocuments && loadingDocuments) ||
|
||||
(step === "verify" && !phoneVerified)
|
||||
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||
}
|
||||
loading={isPending || saving}
|
||||
rightSection={
|
||||
!isPending && !saving && step !== "additional" ? (
|
||||
<ArrowRight size={16} />
|
||||
) : undefined
|
||||
!isPending && !saving ? <ArrowRight size={16} /> : undefined
|
||||
}
|
||||
>
|
||||
{step === "additional" ? "Submit for review" : "Continue"}
|
||||
{step === "documents" ? "Submit for review" : "Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Mail,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import RPNInput from "react-phone-number-input";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
import { isValidPhone } from "@/components/PhoneField";
|
||||
import "@/components/phone-field.css";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
@@ -50,16 +70,46 @@ const userSchema = z
|
||||
|
||||
type FormData = z.infer<typeof userSchema>;
|
||||
|
||||
const errorText = (msg?: string) =>
|
||||
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
/** Mask the local part of an email for display (j***e@example.com). */
|
||||
const maskEmail = (email: string) => {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!local || !domain) return email;
|
||||
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
|
||||
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
||||
};
|
||||
|
||||
type OtpChannel = "phone" | "email";
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signup } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
|
||||
// phone number before the account is actually created. The account is only
|
||||
// created after the code is verified — the OTP is a hard requirement.
|
||||
const [stage, setStage] = useState<"form" | "otp">("form");
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
||||
// Which contact method the code was sent to — chosen on the form, locked in
|
||||
// once the challenge is sent.
|
||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
||||
const [otpChannel, setOtpChannel] = useState<OtpChannel>("phone");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
|
||||
// Resend cooldown countdown (pure setTimeout ticks — no Date.now needed).
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [resendIn]);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -80,226 +130,329 @@ export default function SignupPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
// Step 1 — form is valid: send a fresh code to the chosen channel, then
|
||||
// move to the OTP challenge.
|
||||
const requestOtp = async (data: FormData) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setSending(true);
|
||||
try {
|
||||
await api.auth.sendOTP.call(
|
||||
channel === "email" ? { email: data.email } : { phone: data.phone },
|
||||
);
|
||||
setPendingData(data);
|
||||
setOtpChannel(channel);
|
||||
setOtpCode("");
|
||||
setOtpError(null);
|
||||
setResendIn(60);
|
||||
setStage("otp");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resendOtp = async () => {
|
||||
if (!pendingData) return;
|
||||
setOtpError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await api.auth.sendOTP.call(
|
||||
otpChannel === "email"
|
||||
? { email: pendingData.email }
|
||||
: { phone: pendingData.phone },
|
||||
);
|
||||
setOtpCode("");
|
||||
setResendIn(60);
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2 — verify the code, then (only on success) create the account.
|
||||
const confirmOtp = async () => {
|
||||
if (!pendingData) return;
|
||||
setOtpError(null);
|
||||
if (otpCode.trim().length !== 6) {
|
||||
setOtpError("Enter the 6-digit code we sent you.");
|
||||
return;
|
||||
}
|
||||
setVerifying(true);
|
||||
try {
|
||||
await api.auth.verifyOTP.call({
|
||||
...(otpChannel === "email"
|
||||
? { email: pendingData.email }
|
||||
: { phone: pendingData.phone }),
|
||||
otp: otpCode.trim(),
|
||||
});
|
||||
const payload: SignupPayload = {
|
||||
email: data.email,
|
||||
username: data.email,
|
||||
email: pendingData.email,
|
||||
username: pendingData.email,
|
||||
// Already a canonical E.164 string from the phone field (e.g. +251912345678).
|
||||
phoneNumber: data.phone,
|
||||
userType: data.userType,
|
||||
phoneNumber: pendingData.phone,
|
||||
userType: pendingData.userType,
|
||||
name: {
|
||||
en: `${data.firstName.en} ${data.lastName.en}`,
|
||||
am: `${data.firstName.en} ${data.lastName.en}`,
|
||||
en: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
|
||||
am: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
|
||||
},
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
password: pendingData.password,
|
||||
confirmPassword: pendingData.confirmPassword,
|
||||
};
|
||||
const result = await signup(payload);
|
||||
if (result.success) {
|
||||
navigate("/portal");
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
setOtpError(result.error.message);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred");
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Smart Freight Operations"
|
||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
>
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
{errorText(errors.firstName?.en?.message)}
|
||||
{stage === "form" ? (
|
||||
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("lastName.en")}
|
||||
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="First name"
|
||||
placeholder="John"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.firstName?.en?.message}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last name"
|
||||
placeholder="Doe"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.lastName?.en?.message}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errorText(errors.lastName?.en?.message)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errorText(errors.email?.message)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<div
|
||||
className={`edr-phone-wrapper${
|
||||
errors.phone ? " edr-phone-wrapper--error" : ""
|
||||
}`}
|
||||
>
|
||||
<RPNInput
|
||||
international
|
||||
defaultCountry="ET"
|
||||
countryCallingCodeEditable={false}
|
||||
addInternationalOption
|
||||
id="signup-phone"
|
||||
placeholder="912 345 678"
|
||||
disabled={loading}
|
||||
value={field.value || undefined}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorText(errors.phone?.message)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
{...register("password")}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phone"
|
||||
label="Phone"
|
||||
required
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errorText(errors.password?.message)}
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Send verification code via
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={sending}
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as OtpChannel)}
|
||||
data={[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Smartphone size={14} /> Phone
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Mail size={14} /> Email
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={sending}
|
||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
{errorText(errors.confirmPassword?.message)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a 6-digit code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
{otpChannel === "email"
|
||||
? maskEmail(pendingData?.email ?? "")
|
||||
: maskPhone(pendingData?.phone ?? "")}
|
||||
</span>
|
||||
. Enter it to finish creating your account.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
||||
>
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
{otpError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{otpError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || otpCode.trim().length !== 6}
|
||||
onClick={confirmOtp}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
Verify & create account
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={sending || verifying}
|
||||
onClick={() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || sending || verifying}
|
||||
onClick={resendOtp}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export type CompanyStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
@@ -103,7 +102,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
verify: [],
|
||||
poa: [],
|
||||
documents: [],
|
||||
additional: [],
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function InvoiceDetailPage() {
|
||||
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">(
|
||||
"TELEBIRR",
|
||||
);
|
||||
console.log(paymentMethod)
|
||||
|
||||
const {
|
||||
data: invoice,
|
||||
@@ -127,7 +128,7 @@ export default function InvoiceDetailPage() {
|
||||
|
||||
const payable = isPayable(invoice.status);
|
||||
const lines = invoice.lines ?? [];
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
// const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
|
||||
const handlePay = () => {
|
||||
setPayModalOpen(true);
|
||||
|
||||
@@ -197,6 +197,15 @@ export function BookingPaymentPanel({
|
||||
: priceTotal(pricing);
|
||||
const items = priceLineItems(pricing);
|
||||
|
||||
// Consolidation: this shipment shares a wagon with a partner booking, and the
|
||||
// wagon is only scheduled once both partners have paid. Surface a note while
|
||||
// payment is still pending (pay-window open, or a deadline set and not paid).
|
||||
const showConsolidationNote =
|
||||
!paid &&
|
||||
Boolean(booking.consolidationPartnerId) &&
|
||||
(booking.status === "SELECTED_FOR_BATCH" ||
|
||||
Boolean(booking.paymentDeadline));
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
@@ -268,6 +277,12 @@ export function BookingPaymentPanel({
|
||||
onPay={onPay}
|
||||
paying={paying}
|
||||
/>
|
||||
{showConsolidationNote && (
|
||||
<Text mt={12} fz="12px" c="#9AA8B5" lh={1.5}>
|
||||
This shipment shares a wagon with a consolidation partner — both
|
||||
shipments must be paid for the wagon to be scheduled.
|
||||
</Text>
|
||||
)}
|
||||
<Divider />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -147,6 +147,31 @@ async function searchPlaces(
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the address label for a picked place.
|
||||
*
|
||||
* For an establishment / POI (e.g. "Bole Medhanialem") Google's
|
||||
* `formatted_address` is the *postal* address, which for many Ethiopian places
|
||||
* collapses to just the city ("Addis Ababa, Ethiopia") — so taking it verbatim
|
||||
* silently replaces the specific place the user picked with a broad city. The
|
||||
* place `name` carries the specific label, so we lead with it and only append
|
||||
* the formatted address for context when it doesn't already contain the name.
|
||||
* Falls back to the prediction's own description (what the user saw and clicked).
|
||||
*/
|
||||
function placeDisplayName(
|
||||
place: google.maps.places.PlaceResult | null,
|
||||
prediction: PlacePrediction,
|
||||
): string {
|
||||
const name = place?.name?.trim();
|
||||
const formatted = place?.formatted_address?.trim();
|
||||
if (name && formatted) {
|
||||
return formatted.toLowerCase().includes(name.toLowerCase())
|
||||
? formatted
|
||||
: `${name}, ${formatted}`;
|
||||
}
|
||||
return name || formatted || prediction.displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a picked prediction to its coordinates via Place Details. Runs once
|
||||
* per selection (closes the Autocomplete session), so billing stays on the
|
||||
@@ -174,10 +199,7 @@ async function resolvePrediction(
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
displayName:
|
||||
place?.formatted_address ||
|
||||
place?.name ||
|
||||
prediction.displayName,
|
||||
displayName: placeDisplayName(place, prediction),
|
||||
lat: loc.lat(),
|
||||
lng: loc.lng(),
|
||||
});
|
||||
|
||||
@@ -11,17 +11,27 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
FileSignature,
|
||||
Printer,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const CONSENT_TEXT =
|
||||
"I have read the entire contract and agree to its terms.";
|
||||
@@ -34,9 +44,13 @@ export default function ContractViewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [otpOpen, setOtpOpen] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
@@ -44,6 +58,15 @@ export default function ContractViewPage() {
|
||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
// The signed-in customer's registered phone — where the sudo-mode OTP is sent.
|
||||
const customerPhone = user?.phoneNumber ?? "";
|
||||
const maskedPhone =
|
||||
customerPhone.length > 4
|
||||
? `${customerPhone.slice(0, 4)}${"*".repeat(
|
||||
Math.max(customerPhone.length - 6, 0),
|
||||
)}${customerPhone.slice(-2)}`
|
||||
: customerPhone;
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["contract-view", id],
|
||||
queryFn: () => contractsService.getContractView(id!),
|
||||
@@ -95,6 +118,18 @@ export default function ContractViewPage() {
|
||||
};
|
||||
}, [checkScrollBottom]);
|
||||
|
||||
// Send (or resend) the fresh OTP challenge to the customer's phone. On success
|
||||
// we swap the signature modal for the OTP entry modal.
|
||||
const sendOtpMutation = useMutation({
|
||||
mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }),
|
||||
onSuccess: () => {
|
||||
setSignOpen(false);
|
||||
setOtpError(null);
|
||||
setOtpOpen(true);
|
||||
},
|
||||
onError: () => toast.error("Failed to send verification code"),
|
||||
});
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.signContract(id!, {
|
||||
@@ -104,16 +139,22 @@ export default function ContractViewPage() {
|
||||
: (signatureData as string),
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: CONSENT_TEXT,
|
||||
otp: otpCode.trim(),
|
||||
otpPhone: customerPhone,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setSignOpen(false);
|
||||
setOtpOpen(false);
|
||||
setOtpCode("");
|
||||
setSuccessOpen(true);
|
||||
void refetch();
|
||||
void qc.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to sign contract"),
|
||||
onError: (err) =>
|
||||
setOtpError(
|
||||
extractApiError(err).message ?? "Failed to verify code and sign",
|
||||
),
|
||||
});
|
||||
|
||||
const openSign = () => {
|
||||
@@ -128,6 +169,17 @@ export default function ContractViewPage() {
|
||||
if (!signerName.trim()) return;
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
if (!customerPhone) {
|
||||
toast.error("No phone number on file to verify your signature.");
|
||||
return;
|
||||
}
|
||||
setOtpCode("");
|
||||
sendOtpMutation.mutate();
|
||||
};
|
||||
|
||||
const confirmOtp = () => {
|
||||
if (otpCode.trim().length !== 6) return;
|
||||
setOtpError(null);
|
||||
signMutation.mutate();
|
||||
};
|
||||
|
||||
@@ -315,20 +367,114 @@ export default function ContractViewPage() {
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={signMutation.isPending}
|
||||
loading={sendOtpMutation.isPending}
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
sendOtpMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData)
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||
Continue to verification
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={otpOpen}
|
||||
onClose={() => setOtpOpen(false)}
|
||||
title="Verify it's you"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
w={40}
|
||||
h={40}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ShieldCheck
|
||||
size={20}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
For security, enter the 6-digit code we sent by SMS to{" "}
|
||||
<Text span fw={600} c="edr-text">
|
||||
{maskedPhone}
|
||||
</Text>{" "}
|
||||
to confirm and apply your signature.
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{otpError && (
|
||||
<Alert color="red" variant="light" radius="md">
|
||||
{otpError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
disabled={signMutation.isPending}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
loading={sendOtpMutation.isPending}
|
||||
disabled={sendOtpMutation.isPending || signMutation.isPending}
|
||||
onClick={() => {
|
||||
setOtpError(null);
|
||||
sendOtpMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setOtpOpen(false)}
|
||||
disabled={signMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
loading={signMutation.isPending}
|
||||
disabled={signMutation.isPending || otpCode.trim().length !== 6}
|
||||
onClick={confirmOtp}
|
||||
>
|
||||
Verify & sign
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ContractSignSuccessModal
|
||||
opened={successOpen}
|
||||
reference={data.reference}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
import { OperationDatePicker } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import type { ShipmentValidation } from "@/services/contracts.service";
|
||||
import {
|
||||
SelectField,
|
||||
StepCard,
|
||||
@@ -149,6 +151,8 @@ function NewShipmentBookingForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const isContainerContract = contract.freightType === "CONTAINER";
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||
@@ -161,6 +165,14 @@ function NewShipmentBookingForm({
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-submit validation (container contracts only): warns on overweight
|
||||
// containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the
|
||||
// price modal opens so re-reviewing after an edit re-checks.
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
api.contracts.validateShipment.call({ id: contractId, dto }),
|
||||
});
|
||||
|
||||
function buildDto(
|
||||
values: ShipmentFormValues,
|
||||
): Freight.CreateBookingUnderContractDto {
|
||||
@@ -207,13 +219,22 @@ function NewShipmentBookingForm({
|
||||
};
|
||||
}
|
||||
|
||||
// Submit validates the whole form, then opens the price modal for confirmation.
|
||||
// Submit validates the whole form, then opens the price modal for
|
||||
// confirmation. For container contracts we also run the server-side shipment
|
||||
// validation (overweight warnings + 20ft pairing hard-blocks) so the modal
|
||||
// can surface them before the booking is created.
|
||||
const handleReview = form.handleSubmit((values) => {
|
||||
setPendingValues(values);
|
||||
if (isContainerContract) {
|
||||
validateMutation.reset();
|
||||
validateMutation.mutate(buildDto(values));
|
||||
}
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pendingValues) return;
|
||||
// Guard: never let a booking with unresolved 20ft pairing errors submit.
|
||||
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
|
||||
submitMutation.mutate(buildDto(pendingValues));
|
||||
};
|
||||
|
||||
@@ -221,6 +242,7 @@ function NewShipmentBookingForm({
|
||||
const handleReject = () => {
|
||||
if (submitMutation.isPending) return;
|
||||
setPendingValues(null);
|
||||
validateMutation.reset();
|
||||
};
|
||||
|
||||
const routes = contract.routes ?? [];
|
||||
@@ -323,6 +345,8 @@ function NewShipmentBookingForm({
|
||||
contract={contract}
|
||||
values={pendingValues}
|
||||
loading={submitMutation.isPending}
|
||||
validation={validateMutation.data ?? null}
|
||||
validationLoading={validateMutation.isPending}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
@@ -334,20 +358,54 @@ function PriceConfirmModal({
|
||||
contract,
|
||||
values,
|
||||
loading,
|
||||
validation,
|
||||
validationLoading,
|
||||
onConfirm,
|
||||
onReject,
|
||||
}: {
|
||||
contract: Freight.IContract;
|
||||
values: ShipmentFormValues | null;
|
||||
loading: boolean;
|
||||
validation: ShipmentValidation | null;
|
||||
validationLoading: boolean;
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const total = useMemo(
|
||||
const baseTotal = useMemo(
|
||||
() => (values ? computeShipmentTotal(contract, values) : null),
|
||||
[contract, values],
|
||||
);
|
||||
|
||||
const overweightLines = validation?.overweightLines ?? [];
|
||||
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
|
||||
const pairingErrors = validation?.pairingErrors ?? [];
|
||||
const hasPairingBlock = pairingErrors.length > 0;
|
||||
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
||||
|
||||
// The contract's frozen unit rates (computeShipmentTotal) don't carry an
|
||||
// overweight line — that surcharge only exists in the live rule engine. Fold
|
||||
// the real amount from validateShipment into the displayed total so the
|
||||
// customer sees the actual charge the overweight warning refers to, not just
|
||||
// the warning text.
|
||||
const total = useMemo(() => {
|
||||
if (!baseTotal) return null;
|
||||
if (!(overweightSurchargeAmount > 0)) return baseTotal;
|
||||
return {
|
||||
...baseTotal,
|
||||
lines: [
|
||||
...baseTotal.lines,
|
||||
{
|
||||
label: "Overweight surcharge",
|
||||
unitPrice: overweightSurchargeAmount,
|
||||
unit: "flat" as const,
|
||||
quantity: 1,
|
||||
amount: overweightSurchargeAmount,
|
||||
},
|
||||
],
|
||||
total: baseTotal.total + overweightSurchargeAmount,
|
||||
};
|
||||
}, [baseTotal, overweightSurchargeAmount]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(values)}
|
||||
@@ -376,6 +434,63 @@ function PriceConfirmModal({
|
||||
>
|
||||
{total ? (
|
||||
<Stack gap="md">
|
||||
{validationLoading && (
|
||||
<Group gap={8} c="dimmed">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
<Text fz="sm" c="dimmed">
|
||||
Checking container weights and wagon pairing…
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{hasPairingBlock && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Cannot create booking — 20ft wagon pairing"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{pairingErrors.map((msg, i) => (
|
||||
<Text key={i} fz="sm" c="red.8">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="red.7" mt={2}>
|
||||
Adjust the 20ft container weights or quantities so pairs differ
|
||||
by no more than 10 tons.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overweightLines.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Overweight containers"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{overweightLines.map((line, i) => (
|
||||
<Text key={i} fz="sm" c="#9A5B00">
|
||||
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
|
||||
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||
{overweightSurchargeAmount > 0
|
||||
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
|
||||
validation?.currency ?? total?.currency ?? ""
|
||||
} applies (included in the total below). You can still submit, or go back and adjust weights.`
|
||||
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Stack gap={10}>
|
||||
{total.lines.map((line, i) => (
|
||||
@@ -442,6 +557,7 @@ function PriceConfirmModal({
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
Confirm & book
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { api } from "@/services/api";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
|
||||
import {
|
||||
// Anchor,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
@@ -17,10 +20,19 @@ import {
|
||||
CheckCircle2,
|
||||
FileCheck,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
UploadCloud,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
importer: "Importer",
|
||||
exporter: "Exporter",
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
interface TabDocumentsProps {
|
||||
profile: ProfileResponse;
|
||||
@@ -28,21 +40,63 @@ interface TabDocumentsProps {
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
|
||||
function documentSettingCode(nationality: string | null | undefined): string {
|
||||
return nationality === "foreign"
|
||||
? "company_onboarding_documents_foreign"
|
||||
: "company_onboarding_documents_ethiopian";
|
||||
}
|
||||
|
||||
export default function TabDocuments({
|
||||
profile,
|
||||
mode = "edit",
|
||||
onContinue,
|
||||
}: TabDocumentsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
|
||||
const docSettingQuery = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: "customer_file_documents" },
|
||||
input: { code: documentSettingCode(profile.nationality) },
|
||||
}),
|
||||
);
|
||||
|
||||
const docsQuery = useQuery(
|
||||
api.companies.documents.queryOptions({
|
||||
input: { companyId: profile.companyId },
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadedKeys = useMemo(
|
||||
() => (docsQuery.data ?? []).map((d) => d.code),
|
||||
[docsQuery.data],
|
||||
);
|
||||
|
||||
const existingFilesByKey = useMemo(() => {
|
||||
const map: Record<
|
||||
string,
|
||||
{ name: string; url: string; size?: number; mimeType?: string | null }[]
|
||||
> = {};
|
||||
for (const doc of docsQuery.data ?? []) {
|
||||
(map[doc.code] ??= []).push({
|
||||
name: doc.name,
|
||||
url: fileViewUrl(doc.id),
|
||||
size: doc.size,
|
||||
mimeType: doc.mimeType,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [docsQuery.data]);
|
||||
|
||||
const docUploadMutation = useMutation({
|
||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||
companiesService.uploadDocuments(profile.companyId, files),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,6 +127,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
||||
for (const field of docSettingQuery.data?.fields ?? []) {
|
||||
const min = getMinFiles(field);
|
||||
if (min <= 0) continue;
|
||||
if (uploadedKeys.includes(field.fileKey)) continue;
|
||||
const v = documentFiles[field.fileKey];
|
||||
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
|
||||
if (count < min) {
|
||||
@@ -82,94 +137,139 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
||||
return errs;
|
||||
};
|
||||
|
||||
const licenseProfiles = profile.companyProfiles.filter(
|
||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<FileCheck size={20} />
|
||||
<Title order={3}>Documents</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Upload and manage required business documents
|
||||
</Text>
|
||||
|
||||
{docSettingQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
</Center>
|
||||
) : !docSettingQuery.data ? (
|
||||
<Text c="edr-muted" size="sm" ta="center" py="md">
|
||||
No document requirements configured for your account.
|
||||
<>
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<FileCheck size={20} />
|
||||
<Title order={3}>Documents</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Upload and manage required business documents
|
||||
</Text>
|
||||
) : (
|
||||
<SmartFileInput
|
||||
file={docSettingQuery.data}
|
||||
value={documentFiles}
|
||||
onChange={handleFilesChange}
|
||||
errors={fieldErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{docSettingQuery.data && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="lg"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{docUploadMutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{docUploadMutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>Upload failed</Text>
|
||||
</Group>
|
||||
{docSettingQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
</Center>
|
||||
) : !docSettingQuery.data ? (
|
||||
<Text c="edr-muted" size="sm" ta="center" py="md">
|
||||
No document requirements configured for your account.
|
||||
</Text>
|
||||
) : (
|
||||
<SmartFileInput
|
||||
file={docSettingQuery.data}
|
||||
value={documentFiles}
|
||||
onChange={handleFilesChange}
|
||||
errors={fieldErrors}
|
||||
uploadedKeys={uploadedKeys}
|
||||
existingFiles={existingFilesByKey}
|
||||
onViewFile={view}
|
||||
/>
|
||||
)}
|
||||
|
||||
{docSettingQuery.data && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="lg"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{docUploadMutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{mode === "onboarding"
|
||||
? "Saved successfully"
|
||||
: "Documents uploaded successfully"}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{docUploadMutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Upload failed
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{mode === "onboarding" ? (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<ArrowRight size={16} />}
|
||||
loading={docUploadMutation.isPending}
|
||||
onClick={() => {
|
||||
const validationErrors = validateRequired();
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setFieldErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
if (hasFiles) {
|
||||
docUploadMutation.mutate(documentFiles, {
|
||||
onSuccess: () => onContinue?.(),
|
||||
});
|
||||
} else {
|
||||
onContinue?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<UploadCloud size={16} />}
|
||||
loading={docUploadMutation.isPending}
|
||||
disabled={!hasFiles}
|
||||
onClick={() => {
|
||||
if (!hasFiles) return;
|
||||
docUploadMutation.mutate(documentFiles);
|
||||
}}
|
||||
>
|
||||
Upload Documents
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{mode === "onboarding" ? (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<ArrowRight size={16} />}
|
||||
loading={docUploadMutation.isPending}
|
||||
onClick={() => {
|
||||
const validationErrors = validateRequired();
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setFieldErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
if (hasFiles) {
|
||||
docUploadMutation.mutate(documentFiles, {
|
||||
onSuccess: () => onContinue?.(),
|
||||
});
|
||||
} else {
|
||||
onContinue?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<UploadCloud size={16} />}
|
||||
loading={docUploadMutation.isPending}
|
||||
disabled={!hasFiles}
|
||||
onClick={() => {
|
||||
if (!hasFiles) return;
|
||||
docUploadMutation.mutate(documentFiles);
|
||||
}}
|
||||
>
|
||||
Upload Documents
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{licenseProfiles.length > 0 && (
|
||||
<Card padding="lg" mt="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<Paperclip size={20} />
|
||||
<Title order={3}>Business licenses</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
License documents uploaded per operational profile
|
||||
</Text>
|
||||
|
||||
<Stack gap="md">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{ROLE_LABELS[p.type] ?? p.type} · {p.reference}
|
||||
</Text>
|
||||
{p.licenseFiles.map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Text component="button" type="button" size="xs">
|
||||
{f.name}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ContractDocuments,
|
||||
GenerateContractPriceResponse,
|
||||
SubmitContractResponse,
|
||||
ShipmentValidation,
|
||||
} from "./contracts.service";
|
||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
UpdateDropdownSettingDto,
|
||||
} from "@/types/dropdownSettings";
|
||||
import type {
|
||||
CompanyDocument,
|
||||
CompanyInfoResponse,
|
||||
CompanyNationality,
|
||||
CompanyProfileResponse,
|
||||
@@ -158,7 +160,11 @@ export const api = {
|
||||
createCompanyProfile: endpoint<
|
||||
{ type: ProfileTypeValue; businessLicense?: string },
|
||||
CompanyProfileResponse
|
||||
>("companies", "createCompanyProfile", companiesService.createCompanyProfile),
|
||||
>(
|
||||
"companies",
|
||||
"createCompanyProfile",
|
||||
companiesService.createCompanyProfile,
|
||||
),
|
||||
|
||||
startOnboarding: endpoint<
|
||||
{
|
||||
@@ -192,6 +198,12 @@ export const api = {
|
||||
"onboardingRequirements",
|
||||
companiesService.getOnboardingRequirements,
|
||||
),
|
||||
|
||||
documents: endpoint<{ companyId: string }, CompanyDocument[]>(
|
||||
"companies",
|
||||
"documents",
|
||||
({ companyId }) => companiesService.getDocuments(companyId),
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
@@ -228,7 +240,8 @@ export const api = {
|
||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||
"bookings",
|
||||
"downloadHandoverDocument",
|
||||
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
|
||||
({ inventoryId }) =>
|
||||
bookingsService.downloadHandoverDocument(inventoryId),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
@@ -312,11 +325,8 @@ export const api = {
|
||||
proceedToOperation: endpoint<
|
||||
{ id: string; scheduledDate: string },
|
||||
Freight.IBooking
|
||||
>(
|
||||
"bookings",
|
||||
"proceedToOperation",
|
||||
({ id, scheduledDate }) =>
|
||||
bookingsService.proceedToOperation(id, scheduledDate),
|
||||
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
|
||||
bookingsService.proceedToOperation(id, scheduledDate),
|
||||
),
|
||||
|
||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||
@@ -354,10 +364,11 @@ export const api = {
|
||||
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||
),
|
||||
|
||||
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>(
|
||||
"train-scheduling",
|
||||
"availableDaysForCargo",
|
||||
(input) => bookingsService.getAvailableDaysForCargo(input),
|
||||
getAvailableDaysForCargo: endpoint<
|
||||
Freight.AvailableDaysForCargoQuery,
|
||||
string[]
|
||||
>("train-scheduling", "availableDaysForCargo", (input) =>
|
||||
bookingsService.getAvailableDaysForCargo(input),
|
||||
),
|
||||
|
||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||
@@ -462,6 +473,13 @@ export const api = {
|
||||
contractsService.createBookingUnderContract(id, dto),
|
||||
),
|
||||
|
||||
validateShipment: endpoint<
|
||||
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||
ShipmentValidation
|
||||
>("contracts", "validateShipment", ({ id, dto }) =>
|
||||
contractsService.validateShipment(id, dto),
|
||||
),
|
||||
|
||||
getContractMilestones: endpoint<
|
||||
{ id: string },
|
||||
Freight.IClearanceMilestone[]
|
||||
|
||||
@@ -89,6 +89,10 @@ export interface SignContractPayload {
|
||||
signatureImageBase64: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
|
||||
otp?: string;
|
||||
/** Phone the OTP was sent to; required when role=CUSTOMER. */
|
||||
otpPhone?: string;
|
||||
}
|
||||
|
||||
export interface ApproveDeliveryResponse {
|
||||
|
||||
@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
|
||||
company: CompanyResponse;
|
||||
}
|
||||
|
||||
/** A single company-level document uploaded against a `file_upload_settings` field. */
|
||||
export interface CompanyDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The `fileKey` of the setting field it was uploaded against. */
|
||||
code: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** A single onboarding document field, as resolved and described by the backend. */
|
||||
export interface OnboardingDocumentField {
|
||||
fileKey: string;
|
||||
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
|
||||
}
|
||||
|
||||
export interface CompanyProfileInput {
|
||||
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
||||
type:
|
||||
| "importer"
|
||||
| "exporter"
|
||||
| "freight_forwarder"
|
||||
| "dj_freight_forwarder"
|
||||
| "transporter";
|
||||
businessLicense?: string;
|
||||
}
|
||||
|
||||
@@ -180,7 +197,9 @@ export const companiesService = {
|
||||
}
|
||||
},
|
||||
|
||||
create: async (payload: CreateCompanyPayload): Promise<CompanyInfoResponse> => {
|
||||
create: async (
|
||||
payload: CreateCompanyPayload,
|
||||
): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.CREATE,
|
||||
payload,
|
||||
@@ -195,7 +214,9 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => {
|
||||
updateProfile: async (
|
||||
payload: UpdateProfilePayload,
|
||||
): Promise<ProfileResponse> => {
|
||||
const response = await client.patch<ApiResponse<ProfileResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.PROFILE,
|
||||
payload,
|
||||
@@ -293,7 +314,18 @@ export const companiesService = {
|
||||
formData.append(fieldName, fileOrFiles);
|
||||
}
|
||||
}
|
||||
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
|
||||
await client.post(
|
||||
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
||||
formData,
|
||||
);
|
||||
},
|
||||
|
||||
/** List documents already uploaded for a company (settings-driven, by fileKey). */
|
||||
getDocuments: async (companyId: string): Promise<CompanyDocument[]> => {
|
||||
const response = await client.get<ApiResponse<CompanyDocument[]>>(
|
||||
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Upload business-license document(s) for a company profile (multi-file). */
|
||||
|
||||
@@ -33,6 +33,29 @@ export interface SubmitContractResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** A container line whose total VGM exceeds the weight-limit rule. */
|
||||
export interface OverweightLine {
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-submit validation for a shipment booking under a CONTAINER contract.
|
||||
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
||||
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
||||
* that cannot be balanced onto wagons) and must prevent booking.
|
||||
* `overweightSurchargeAmount` is the real overweight charge (same rate the
|
||||
* booking is billed at on submit) so the confirm-modal total can include it.
|
||||
*/
|
||||
export interface ShipmentValidation {
|
||||
overweightLines: OverweightLine[];
|
||||
overweightSurchargeAmount: number;
|
||||
currency: string | null;
|
||||
pairingErrors: string[];
|
||||
}
|
||||
|
||||
export interface ContractListFilter {
|
||||
status?: string;
|
||||
statuses?: string;
|
||||
@@ -285,6 +308,20 @@ export const contractsService = {
|
||||
return data.data.booking ?? data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pre-submit validation of a shipment booking (same DTO as
|
||||
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
||||
* 20ft wagon-pairing errors so the customer can be warned/blocked before the
|
||||
* booking is created.
|
||||
*/
|
||||
validateShipment: async (
|
||||
id: string,
|
||||
dto: Freight.CreateBookingUnderContractDto,
|
||||
): Promise<ShipmentValidation> => {
|
||||
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
// ── Milestones ──
|
||||
getContractMilestones: async (
|
||||
id: string,
|
||||
|
||||
@@ -33,7 +33,9 @@ export interface SignupResponse {
|
||||
}
|
||||
|
||||
export interface OtpPayload {
|
||||
phone: string;
|
||||
/** Exactly one of phone/email — the channel the code is sent through. */
|
||||
phone?: string;
|
||||
email?: string;
|
||||
/** Required on verify; omitted on send (the server generates the code). */
|
||||
otp?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user