Merge pull request #415 from Tria-plc/freight/feat/fixes-v1

Implement invoice pagination and enhance file viewing features
This commit is contained in:
Nathnael Wondisha
2026-07-03 11:39:47 +03:00
committed by GitHub
24 changed files with 1936 additions and 538 deletions

View File

@@ -1,21 +1,31 @@
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards"; import { BookingView } from "../../common/booking-guards";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing") @ApiTags("billing")
@Controller("billing") @Controller("billing")
@FreightAdmin() @BookingView()
@ApiBearerAuth() @ApiBearerAuth()
export class BillingController { export class BillingController {
constructor(private readonly billingService: BillingService) { } constructor(private readonly billingService: BillingService) {}
@Get("invoices") @Get("invoices")
@ApiOperation({ summary: "List all invoices" }) @ApiOperation({
findAll() { summary: "List invoices (paginated, filterable by company/status/search)",
return this.billingService.findAll(); })
findAll(@Query() query: FilterInvoiceDto) {
return this.billingService.findAllPaginated(query);
} }
@Get("invoices/:id") @Get("invoices/:id")

View File

@@ -125,7 +125,7 @@ export class BillingService {
private readonly payment: PaymentService, private readonly payment: PaymentService,
private readonly companies: CompaniesService, private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService, private readonly invoiceDocuments: InvoiceDocumentService,
) { } ) {}
// ── Reads ────────────────────────────────────────────────────────────────── // ── Reads ──────────────────────────────────────────────────────────────────
@@ -134,9 +134,56 @@ export class BillingService {
return this.invoices.findAll({ order: { issuedAt: "DESC" } }); return this.invoices.findAll({ order: { issuedAt: "DESC" } });
} }
/**
* Paginated invoice list for the backoffice — optionally narrowed to a
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
async findAllPaginated(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{ items: Invoice[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
}
/** Invoice header plus its line items. */ /** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> { async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id); const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({ const lines = await this.invoiceLines.findAll({
where: { invoiceId: id }, where: { invoiceId: id },
@@ -375,7 +422,7 @@ export class BillingService {
input.dueAt ?? input.dueAt ??
new Date( new Date(
Date.now() + Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
); );
const invoiceNumber = await this.nextInvoiceNumber(mg); const invoiceNumber = await this.nextInvoiceNumber(mg);

View File

@@ -0,0 +1,42 @@
import { Freight } from "@edr/types";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
export class FilterInvoiceDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
pageSize?: number = 20;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: Freight.InvoiceStatus })
@IsOptional()
@IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus;
}

View File

@@ -93,9 +93,8 @@ export class PaymentRepository {
p.paid_at, p.paid_at,
p.created_at p.created_at
FROM freight.payments p FROM freight.payments p
JOIN freight.bookings b ON b.id = p.ref_id JOIN freight.bookings b ON b.id = p.ref_id::uuid
WHERE b.company_id = $1 WHERE b.company_id = $1
AND p.deleted_at IS NULL
AND b.deleted_at IS NULL AND b.deleted_at IS NULL
ORDER BY p.created_at DESC`, ORDER BY p.created_at DESC`,
[companyId], [companyId],

View File

@@ -12,6 +12,7 @@ import {
PackageCheck, PackageCheck,
PackageOpen, PackageOpen,
Paperclip, Paperclip,
Receipt,
Send, Send,
Settings, Settings,
ShieldCheck, ShieldCheck,
@@ -32,7 +33,11 @@ import {
useParams, useParams,
} from "react-router-dom"; } 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 { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage"; import LoginPage from "./pages/auth/LoginPage";
@@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage"; 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 DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import MyProfilePage from "./pages/dashboard/MyProfilePage"; import MyProfilePage from "./pages/dashboard/MyProfilePage";
@@ -61,7 +68,10 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
import PaymentsPage from "./pages/payments/PaymentsPage"; import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission"; import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; import {
FREIGHT_PERMS,
hasPermission as hasFreightPermission,
} from "./lib/permissions";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -144,6 +154,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Wallet />, icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view, permission: FREIGHT_PERMS.bookings.view,
}, },
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.bookings.view,
},
...demoItems, ...demoItems,
], ],
}, },
@@ -506,7 +522,10 @@ const App = () => {
<Route path="/um/*" element={<UserManagementHostPage />} /> <Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} /> <Route path="/health" element={<HealthCheck />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} /> <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="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} /> <Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} /> <Route path="profile" element={<MyProfilePage />} />
@@ -522,8 +541,27 @@ const App = () => {
/> />
<Route path="customers" element={<CustomersPage />} /> <Route path="customers" element={<CustomersPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} /> <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/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} /> <Route
path="booking-requests/:id"
element={<BookingRequestDetailPage />}
/>
<Route <Route
path="booking-requests/:id/contract" path="booking-requests/:id/contract"
element={<BookingContractPage />} element={<BookingContractPage />}
@@ -536,7 +574,9 @@ const App = () => {
<Route <Route
path="clearance/:id" path="clearance/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}> <RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage /> <DocumentClearanceDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -570,7 +610,9 @@ const App = () => {
<Route <Route
path="bookings/:bookingId/clearance" path="bookings/:bookingId/clearance"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}> <RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage /> <DocumentClearanceDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -578,7 +620,9 @@ const App = () => {
<Route <Route
path="shipment-requests" path="shipment-requests"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}> <RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<ShipmentRequestsPage /> <ShipmentRequestsPage />
</RequirePermission> </RequirePermission>
} }
@@ -586,7 +630,9 @@ const App = () => {
<Route <Route
path="shipment-requests/:id" path="shipment-requests/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}> <RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<ShipmentRequestDetailPage /> <ShipmentRequestDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -618,12 +664,20 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} /> <Route
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} /> path="gl-ethiopia/clearance"
element={<LegacyGlEthiopiaClearanceRedirect />}
/>
<Route
path="gl-ethiopia/clearance/:id"
element={<LegacyGlEthiopiaClearanceRedirect />}
/>
<Route <Route
path="gl-djibouti/clearance" path="gl-djibouti/clearance"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}> <RequirePermission
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
>
<GlDjiboutiClearanceListPage /> <GlDjiboutiClearanceListPage />
</RequirePermission> </RequirePermission>
} }
@@ -631,7 +685,9 @@ const App = () => {
<Route <Route
path="gl-djibouti/clearance/:id" path="gl-djibouti/clearance/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}> <RequirePermission
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
>
<GlClearanceDetailPage /> <GlClearanceDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -644,7 +700,9 @@ const App = () => {
<Route <Route
path="contracts/:id/create-booking" path="contracts/:id/create-booking"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}> <RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<GlCreateBookingForm /> <GlCreateBookingForm />
</RequirePermission> </RequirePermission>
} }
@@ -655,155 +713,174 @@ const App = () => {
/> />
<Route path="warehouses" element={<WarehouseListPage />} /> <Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} /> <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="import-warehouse" element={<ImportWarehouseFlowPage />} />
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} /> <Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} /> <Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} /> <Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} /> <Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} /> <Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="export-djibouti-unloading" element={<ExportDjiboutiUnloadingQueuePage />} /> <Route
<Route path="interchange-documents" element={<InterchangeDocumentsPage />} /> path="export-djibouti-unloading"
element={<ExportDjiboutiUnloadingQueuePage />}
/>
<Route
path="interchange-documents"
element={<InterchangeDocumentsPage />}
/>
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} /> <Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} /> <Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} /> <Route
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} /> 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 <Route
path="operations/train-scheduling" 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 <Route
path="operations/batch-board" path="operations/batch-board"
@@ -953,9 +1030,15 @@ const App = () => {
{/* Legacy embedded user management routes */} {/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} /> <Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} /> <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/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 path="user-management/roles" element={<RolesPage />} />
<Route <Route
@@ -977,7 +1060,9 @@ const App = () => {
<Route <Route
path="configuration" path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />} element={
<Navigate to="/dashboard/configuration/cargo-types" replace />
}
/> />
<Route <Route
path="configuration/train-scheduling-rules" path="configuration/train-scheduling-rules"
@@ -996,8 +1081,14 @@ const App = () => {
} }
/> />
<Route path="configuration/cargo-types" element={<CargoTypesPage />} /> <Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} /> <Route
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} /> path="configuration/cargo-types/:id"
element={<CargoTypesPage />}
/>
<Route
path="configuration/:resource"
element={<RuleEngineResourcePage />}
/>
<Route <Route
path="rules" path="rules"
@@ -1007,9 +1098,14 @@ const App = () => {
<Route <Route
path="rule-engine" 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="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} /> <Route path="user2" element={<DemoUser2Page />} />
@@ -1026,9 +1122,7 @@ const App = () => {
/** Redirect removed milestones page to document clearance. */ /** Redirect removed milestones page to document clearance. */
function BookingMilestonesRedirect() { function BookingMilestonesRedirect() {
const { id } = useParams(); const { id } = useParams();
return ( return <Navigate to={`/dashboard/bookings/${id}/clearance`} replace />;
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
);
} }
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ /** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { Badge, Button, Group, Tooltip } from "@mantine/core"; import { Badge, Button, Group, Tooltip } from "@mantine/core";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -88,7 +89,13 @@ export function ProfileChips({
}) { }) {
if (!profiles.length) { if (!profiles.length) {
return ( 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 No profiles
</Badge> </Badge>
); );
@@ -118,7 +125,13 @@ export function ProfileChips({
</Tooltip> </Tooltip>
))} ))}
{extra > 0 ? ( {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} +{extra}
</Badge> </Badge>
) : null} ) : null}
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
CANCELLED: "red", CANCELLED: "red",
}; };
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) { export function BookingStatusBadge({
status,
}: {
status: CustomerBookingStatus;
}) {
return ( return (
<Badge <Badge
color={BOOKING_STATUS_COLOR[status] ?? "gray"} color={BOOKING_STATUS_COLOR[status] ?? "gray"}
@@ -194,7 +211,11 @@ const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
refunded: "grape", refunded: "grape",
}; };
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) { export function PaymentStatusBadge({
status,
}: {
status: CustomerPaymentStatus;
}) {
return ( return (
<Badge <Badge
color={PAYMENT_STATUS_COLOR[status] ?? "gray"} 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. * Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
api.customers.setProfileStatus.mutationOptions(), api.customers.setProfileStatus.mutationOptions(),
); );
const act = (next: ProfileStatus) => const act = (next: ProfileStatus) => mutate({ profileId, status: next });
mutate({ profileId, status: next });
if (status === "pending") { if (status === "pending") {
return ( return (

View File

@@ -2,6 +2,7 @@ export {
BookingStatusBadge, BookingStatusBadge,
CompanyStatusBadge, CompanyStatusBadge,
CompanyTypeBadge, CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge, PaymentStatusBadge,
ProfileApprovalActions, ProfileApprovalActions,
ProfileChips, ProfileChips,

View File

@@ -3,6 +3,7 @@ import type { BookingListFilter } from "@/services/bookings.service";
import type { ContractListFilter } from "@/services/contracts.service"; import type { ContractListFilter } from "@/services/contracts.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { CompanyListFilter } from "@/types/customer"; import type { CompanyListFilter } from "@/types/customer";
import type { InvoiceListFilter } from "@/types/invoice";
import type { RuleEngineResourceSlug } from "@/types/rule-engine"; import type { RuleEngineResourceSlug } from "@/types/rule-engine";
import type { TrainScheduleFilters } from "@/types/trainScheduling"; import type { TrainScheduleFilters } from "@/types/trainScheduling";
@@ -16,7 +17,8 @@ export const QUERY_KEYS = {
ROOT: ["file-upload-settings"] as const, ROOT: ["file-upload-settings"] as const,
list: () => ["file-upload-settings", "list"] as const, list: () => ["file-upload-settings", "list"] as const,
byId: (id: string) => ["file-upload-settings", "detail", id] 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: { DROPDOWN_SETTINGS: {
@@ -33,10 +35,18 @@ export const QUERY_KEYS = {
["customers", "list", filter ?? {}] as const, ["customers", "list", filter ?? {}] as const,
byId: (id: string) => ["customers", "detail", id] as const, byId: (id: string) => ["customers", "detail", id] as const,
bookings: (id: string) => ["customers", "detail", id, "bookings"] 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, 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: { BOOKINGS: {
ROOT: ["bookings"] as const, ROOT: ["bookings"] as const,
list: (filter?: BookingListFilter) => list: (filter?: BookingListFilter) =>
@@ -80,7 +90,12 @@ export const QUERY_KEYS = {
TRAIN_SCHEDULING: { TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const, ROOT: ["train-scheduling"] as const,
eligible: (freightType?: string, filters?: TrainScheduleFilters) => 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) => locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const, ["train-scheduling", "locomotives", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const, stations: () => ["train-scheduling", "stations"] as const,
@@ -98,31 +113,37 @@ export const QUERY_KEYS = {
FLEET: { FLEET: {
ROOT: ["fleet"] as const, ROOT: ["fleet"] as const,
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, list: (resource: FleetResourceSlug | string) =>
["fleet", "list", resource] as const,
}, },
VEHICLES: { VEHICLES: {
ROOT: ["vehicles"] as const, 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, byId: (id: string) => ["vehicles", "detail", id] as const,
}, },
FIRST_MILE: { FIRST_MILE: {
ROOT: ["first-mile"] as const, 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, byId: (id: string) => ["first-mile", "detail", id] as const,
}, },
LAST_MILE: { LAST_MILE: {
ROOT: ["last-mile"] as const, 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, byId: (id: string) => ["last-mile", "detail", id] as const,
}, },
RULE_ENGINE: { RULE_ENGINE: {
ROOT: ["rule-engine"] as const, ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) => list: (
["rule-engine", "list", resource, params ?? {}] as const, resource: RuleEngineResourceSlug | string,
params?: RuleEngineListParams,
) => ["rule-engine", "list", resource, params ?? {}] as const,
detail: (resource: RuleEngineResourceSlug | string, id: string) => detail: (resource: RuleEngineResourceSlug | string, id: string) =>
["rule-engine", "detail", resource, id] as const, ["rule-engine", "detail", resource, id] as const,
chain: ["rule-engine", "approval-rules", "chain"] as const, chain: ["rule-engine", "approval-rules", "chain"] as const,
@@ -136,27 +157,39 @@ export const QUERY_KEYS = {
OVERVIEW: { OVERVIEW: {
ROOT: ["overview"] as const, ROOT: ["overview"] as const,
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const, dashboard: (range?: string) =>
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const, ["overview", "dashboard", range ?? "30d"] as const,
contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const, bookingsTab: (range?: string) =>
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const, ["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, operationsTab: () => ["overview", "operations"] as const,
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const, customersTab: (range?: string) =>
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const, ["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) =>
["overview", "staff", range ?? "30d"] as const,
}, },
FUEL: { FUEL: {
ROOT: ["fuel"] as const, ROOT: ["fuel"] as const,
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const, purchases: (vehicleId?: string) =>
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const, ["fuel", "purchases", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) =>
["fuel", "stats", vehicleId ?? "all"] as const,
}, },
MAINTENANCE: { MAINTENANCE: {
ROOT: ["maintenance"] as const, ROOT: ["maintenance"] as const,
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, schedules: (vehicleId?: string) =>
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const, ["maintenance", "schedules", vehicleId ?? "all"] as const,
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const, upcoming: (vehicleId?: string) =>
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, ["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: { FINANCIAL_REPORTS: {

View File

@@ -13,7 +13,7 @@ export const URL_CONSTANTS = {
BASE: "/users", BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`, BY_ID: (id: string | number) => `/users/${id}`,
SET_PASSWORD: "/api/auth/set-password", SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me" ME: "/api/auth/me",
}, },
ROLES: { ROLES: {
@@ -74,9 +74,18 @@ export const URL_CONSTANTS = {
STATS: "/companies/stats", STATS: "/companies/stats",
BY_ID: (id: string | number) => `/companies/${id}`, BY_ID: (id: string | number) => `/companies/${id}`,
DOCUMENTS: (id: string) => `/companies/${id}/documents`, DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`, PROFILE_STATUS: (profileId: string) =>
BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`, `/companies/company-profiles/${profileId}/status`,
PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, 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: { CUSTOMERS_API: {
@@ -124,15 +133,21 @@ export const URL_CONSTANTS = {
CANCEL: (id: string) => `/bookings/${id}/cancel`, CANCEL: (id: string) => `/bookings/${id}/cancel`,
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`, CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
CLEARANCE: (id: string) => `/bookings/${id}/clearance`, 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_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_FINALIZE_PRE: (id: string) => CLEARANCE_FINALIZE_PRE: (id: string) =>
`/bookings/${id}/clearance/finalize-pre-clearance`, `/bookings/${id}/clearance/finalize-pre-clearance`,
CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`, CLEARANCE_TRANSIT_PERMIT: (id: string) =>
CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`, `/bookings/${id}/clearance/transit-permit`,
CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`, CLEARANCE_DELIVERY_ORDER: (id: string) =>
CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`, `/bookings/${id}/clearance/delivery-order`,
CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`, 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_ET_QUEUE: "/bookings/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue", CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
}, },
@@ -157,7 +172,8 @@ export const URL_CONSTANTS = {
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) => CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`, `/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`, 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: (id: string) => `/contracts/${id}/clearance/duty`,
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`, CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
CLEARANCE_FINALIZE_PRE: (id: string) => CLEARANCE_FINALIZE_PRE: (id: string) =>
@@ -247,7 +263,7 @@ export const URL_CONSTANTS = {
}, },
ROUTES: { ROUTES: {
BASE: '/routes', BASE: "/routes",
BY_ID: (id: string) => `/routes/${id}`, BY_ID: (id: string) => `/routes/${id}`,
}, },
@@ -261,12 +277,15 @@ export const URL_CONSTANTS = {
BATCH_BOARD_DETAIL: (scheduleId: string) => BATCH_BOARD_DETAIL: (scheduleId: string) =>
`/train-scheduling/batch-board/${scheduleId}`, `/train-scheduling/batch-board/${scheduleId}`,
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`, 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) => DOC_REVIEW_COMPLETE: (id: string) =>
`/train-scheduling/schedules/${id}/doc-review-complete`, `/train-scheduling/schedules/${id}/doc-review-complete`,
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`, RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
ASSIGN_UNASSIGNED_BOOKING: (id: string) => ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`, `/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) => MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`, `/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) => EXPIRE_BOOKING: (bookingId: string) =>
@@ -275,12 +294,14 @@ export const URL_CONSTANTS = {
`/train-scheduling/bookings/${bookingId}/move-schedule`, `/train-scheduling/bookings/${bookingId}/move-schedule`,
GLOBAL_RULES: "/train-scheduling/global-rules", GLOBAL_RULES: "/train-scheduling/global-rules",
PREVIEW: "/train-scheduling/preview", 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: { CONTAINER: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings", ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview", PREVIEW: "/train-scheduling/container/preview",
SCHEDULES: "/train-scheduling/container/schedules", 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) => ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/container/schedules/${id}/assign-bookings`, `/train-scheduling/container/schedules/${id}/assign-bookings`,
CANCEL_SCHEDULE: (id: string) => CANCEL_SCHEDULE: (id: string) =>
@@ -319,15 +340,18 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`, `/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) => EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`, `/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`, ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) => RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`, `/train-scheduling/schedules/${id}/reschedule/preview`,
RESCHEDULE_EXECUTE: (id: string) => RESCHEDULE_EXECUTE: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/execute`, `/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", 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) => CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`, `/train-scheduling/container/schedules/${id}/cancel`,
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) => REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
@@ -375,175 +399,193 @@ export const URL_CONSTANTS = {
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`, APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain", APPROVAL_RULES_CHAIN: "/approval-rules/chain",
}, },
RATE_MATRIX: { RATE_MATRIX: {
BASE: '/api/rate-matrices', BASE: "/api/rate-matrices",
DRAFT: '/api/rate-matrices/draft', DRAFT: "/api/rate-matrices/draft",
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`, SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`, AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
LIST: '/api/rate-matrices', LIST: "/api/rate-matrices",
DETAIL: (id: string) => `/api/rate-matrices/${id}`, DETAIL: (id: string) => `/api/rate-matrices/${id}`,
}, },
REFERENCE: { REFERENCE: {
PORTS: '/api/reference/ports', PORTS: "/api/reference/ports",
CITIES: '/api/reference/cities', CITIES: "/api/reference/cities",
CONTAINER_TYPES: '/api/reference/container-types', CONTAINER_TYPES: "/api/reference/container-types",
CURRENCIES: '/api/reference/currencies', CURRENCIES: "/api/reference/currencies",
}, },
FACILITIES: { FACILITIES: {
BASE: '/facilities', BASE: "/facilities",
BY_ID: (id: string) => `/facilities/${id}`, BY_ID: (id: string) => `/facilities/${id}`,
}, },
WAREHOUSES: { WAREHOUSES: {
BASE: '/warehouses', BASE: "/warehouses",
DASHBOARD: '/warehouses/dashboard', DASHBOARD: "/warehouses/dashboard",
BY_ID: (id: string) => `/warehouses/${id}`, BY_ID: (id: string) => `/warehouses/${id}`,
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`, YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
}, },
WAREHOUSE_YARDS: { WAREHOUSE_YARDS: {
BASE: '/warehouse-yards', BASE: "/warehouse-yards",
BY_ID: (id: string) => `/warehouse-yards/${id}`, BY_ID: (id: string) => `/warehouse-yards/${id}`,
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`, ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
}, },
WAREHOUSE_ZONES: { WAREHOUSE_ZONES: {
BASE: '/warehouse-zones', BASE: "/warehouse-zones",
BY_ID: (id: string) => `/warehouse-zones/${id}`, BY_ID: (id: string) => `/warehouse-zones/${id}`,
}, },
WAREHOUSE_INVENTORY: { WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory', BASE: "/warehouse-inventory",
RECEIVE: '/warehouse-inventory/receive', RECEIVE: "/warehouse-inventory/receive",
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary', DASHBOARD_SUMMARY: "/warehouse-inventory/dashboard/summary",
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading', READY_FOR_LOADING: "/warehouse-inventory/ready-for-loading",
INQUIRY: '/warehouse-inventory/inquiry', INQUIRY: "/warehouse-inventory/inquiry",
STORE: (id: string) => `/warehouse-inventory/${id}/store`, STORE: (id: string) => `/warehouse-inventory/${id}/store`,
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`, INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`, MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`, LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`, DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`, MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
RESERVE: '/warehouse-inventory/reserve', RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue', ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived', AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready', AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`, UNLOAD_BOOKING: (bookingId: string) =>
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`, `/warehouse-inventory/bookings/${bookingId}/unload`,
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons', INSPECTION_REPORTS: (inventoryId: string) =>
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`, `/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`, MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`, ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`, LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
// Import branch // 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: (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`, 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`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk) // Receive (Import/Export bulk)
ELIGIBLE_BOOKINGS: (direction?: string) => ELIGIBLE_BOOKINGS: (direction?: string) =>
direction direction
? `/warehouse-inventory/eligible-bookings?direction=${direction}` ? `/warehouse-inventory/eligible-bookings?direction=${direction}`
: `/warehouse-inventory/eligible-bookings`, : `/warehouse-inventory/eligible-bookings`,
RECEIVE_BULK: '/warehouse-inventory/receive-bulk', RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export', LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected', BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
RECEIVED_EXPORT: '/warehouse-inventory/received-export', RECEIVED_EXPORT: "/warehouse-inventory/received-export",
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export', READY_TO_LOAD_EXPORT: "/warehouse-inventory/ready-to-load-export",
LOADED_EXPORT: '/warehouse-inventory/loaded-export', LOADED_EXPORT: "/warehouse-inventory/loaded-export",
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export', BULK_DISPATCH_EXPORT: "/warehouse-inventory/bulk-dispatch-export",
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue', IMPORT_ARRIVE_QUEUE: "/warehouse-inventory/import/arrive-queue",
IMPORT_TRAIN_ITEMS: (scheduleId: string) => IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/import/trains/${scheduleId}/items`, `/warehouse-inventory/import/trains/${scheduleId}/items`,
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings', IMPORT_AUTO_UNLOAD_ARRIVED:
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue', "/warehouse-inventory/import/auto-unload-arrived-bookings",
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue', IMPORT_UNLOADED_QUEUE: "/warehouse-inventory/import/unloaded-queue",
EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-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) => EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`, `/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: { WAREHOUSE_LOADINGS: {
BASE: '/warehouse-loadings', BASE: "/warehouse-loadings",
}, },
WAREHOUSE_INSPECTION: { WAREHOUSE_INSPECTION: {
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`, 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: { WAREHOUSE_RULES: {
ALLOCATION: '/warehouse-allocation-rules', ALLOCATION: "/warehouse-allocation-rules",
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`, ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
ALLOCATION_PREVIEW: '/warehouse-allocation/preview', ALLOCATION_PREVIEW: "/warehouse-allocation/preview",
FEES: '/warehouse-fee-rules', FEES: "/warehouse-fee-rules",
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`, 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: { WAREHOUSE_INVOICES: {
BASE: '/warehouse-fee-invoices', BASE: "/warehouse-fee-invoices",
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`, BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`, DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`, RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`, CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`, PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`, PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`, GENERATE: (inventoryId: string) =>
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`, `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`, FOR_INVENTORY: (inventoryId: string) =>
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`, `/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: { INTERCHANGE_DOCUMENTS: {
BASE: '/interchange-documents', BASE: "/interchange-documents",
BY_ID: (id: string) => `/interchange-documents/${id}`, 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`, ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`, DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`, CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
}, },
IMPORT_OPERATIONS: { IMPORT_OPERATIONS: {
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents', DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`, CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`, CUSTOMS_DOCUMENTS: (bookingId: string) =>
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`, `/import-operations/customs/${bookingId}/documents`,
CUSTOMS_DECLARATION: (bookingId: string) =>
`/import-operations/customs/${bookingId}/declaration`,
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) => CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
`/import-operations/customs/${bookingId}/notify-duties-taxes`, `/import-operations/customs/${bookingId}/notify-duties-taxes`,
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) => CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
`/import-operations/customs/${bookingId}/duties-taxes-paid`, `/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) => CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`, `/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) => EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`, `/import-operations/empty-container-returns/${id}/status`,
}, },
VEHICLES: { VEHICLES: {
BASE: '/vehicles', BASE: "/vehicles",
BY_ID: (id: string) => `/vehicles/${id}`, BY_ID: (id: string) => `/vehicles/${id}`,
}, },
FIRST_MILE: { FIRST_MILE: {
BASE: '/first-mile', BASE: "/first-mile",
BY_ID: (id: string) => `/first-mile/${id}`, BY_ID: (id: string) => `/first-mile/${id}`,
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`, ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
}, },
LAST_MILE: { LAST_MILE: {
BASE: '/last-mile', BASE: "/last-mile",
BY_ID: (id: string) => `/last-mile/${id}`, BY_ID: (id: string) => `/last-mile/${id}`,
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`, ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
}, },
DRIVERS: { DRIVERS: {
BASE: '/drivers', BASE: "/drivers",
BY_ID: (id: string) => `/drivers/${id}`, BY_ID: (id: string) => `/drivers/${id}`,
}, },
}; };

View File

@@ -1,24 +1,3 @@
import { useCallback, useState } from "react"; // Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; // `@/hooks/useFileViewer` imports keep working.
export { useFileViewer } 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 };
}

View File

@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
Anchor,
Box, Box,
Button, Button,
Card, Card,
@@ -17,10 +18,13 @@ import {
ArrowRight, ArrowRight,
Banknote, Banknote,
Download, Download,
Eye,
FileText, FileText,
IdCard, IdCard,
LayoutGrid, LayoutGrid,
Package, Package,
Paperclip,
Receipt,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -30,6 +34,7 @@ import {
BookingStatusBadge, BookingStatusBadge,
CompanyStatusBadge, CompanyStatusBadge,
CompanyTypeBadge, CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge, PaymentStatusBadge,
ProfileApprovalActions, ProfileApprovalActions,
ProfileChips, ProfileChips,
@@ -50,7 +55,13 @@ import type {
CustomerDocument, CustomerDocument,
CustomerPayment, CustomerPayment,
} from "@/types/customer"; } 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 }) { function InfoField({ label, value }: { label: string; value?: string | null }) {
return ( return (
@@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
export default function CustomerDetailPage() { export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { view, viewer } = useFileViewer();
const { data: company, isLoading } = useQuery( const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({ 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 bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? []; const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.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( const totalPaid = useMemo(
() => () =>
@@ -285,20 +322,37 @@ export default function CustomerDetailPage() {
header: "", header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" }, meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => ( cell: ({ row }) => (
<ActionIcon <Group gap={4} justify="flex-end" wrap="nowrap">
component="a" <ActionIcon
href={fileViewUrl(row.original.id, true)} variant="subtle"
variant="subtle" color="gray"
color="gray" aria-label="View"
aria-label="Download" data-stop-row-click
data-stop-row-click onClick={() =>
> view({
<Download size={16} /> name: row.original.name,
</ActionIcon> 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( 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) { if (isLoading) {
return ( return (
<Center mih="60vh"> <Center mih="60vh">
@@ -392,8 +499,9 @@ export default function CustomerDetailPage() {
]} ]}
backTo="/dashboard/customers" backTo="/dashboard/customers"
title={company.name} title={company.name}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" subtitle={`TIN ${company.tin}${
}`} company.country ? ` · ${company.country}` : ""
}`}
meta={ meta={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} /> <CompanyTypeBadge type={company.type} />
@@ -416,6 +524,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}> <Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
Payments Payments
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
</Tabs.List> </Tabs.List>
{/* OVERVIEW */} {/* OVERVIEW */}
@@ -528,9 +639,9 @@ export default function CustomerDetailPage() {
error={ error={
bookingsQuery.isError bookingsQuery.isError
? { ? {
message: "Failed to load bookings.", message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(), onRetry: () => void bookingsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -539,23 +650,63 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */} {/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg"> <Tabs.Panel value="documents" pt="lg">
<TableCard minWidth={760}> <Stack gap="lg">
<DataTable <TableCard minWidth={760}>
columns={documentColumns} <DataTable
data={documents} columns={documentColumns}
status={tableStatus(documentsQuery)} data={documents}
emptyMessage="No documents uploaded." status={tableStatus(documentsQuery)}
containerClassName="border-0 shadow-none bg-transparent" emptyMessage="No documents uploaded."
error={ containerClassName="border-0 shadow-none bg-transparent"
documentsQuery.isError error={
? { documentsQuery.isError
message: "Failed to load documents.", ? {
onRetry: () => void documentsQuery.refetch(), message: "Failed to load documents.",
} onRetry: () => void documentsQuery.refetch(),
: undefined }
} : undefined
/> }
</TableCard> />
</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> </Tabs.Panel>
{/* PAYMENTS */} {/* PAYMENTS */}
@@ -570,15 +721,53 @@ export default function CustomerDetailPage() {
error={ error={
paymentsQuery.isError paymentsQuery.isError
? { ? {
message: "Failed to load payments.", message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(), onRetry: () => void paymentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
</TableCard> </TableCard>
</Tabs.Panel> </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> </Tabs>
{viewer}
</PageContainer> </PageContainer>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -28,6 +28,11 @@ import type {
UpdateFileUploadFieldDto, UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto, UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings"; } from "@/types/fileUploadSettings";
import type {
Invoice,
InvoiceListFilter,
PaginatedInvoices,
} from "@/types/invoice";
import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
import { import {
RuleEngineListResult, RuleEngineListResult,
@@ -128,6 +133,7 @@ import {
import { containerTypesService } from "./container-types.service"; import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService"; import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service"; import { customersService } from "./customers.service";
import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { import {
@@ -197,7 +203,10 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
export const api = { export const api = {
trainScheduling: { trainScheduling: {
// ── Queries ──────────────────────────────────────────────────────────── // ── Queries ────────────────────────────────────────────────────────────
scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>( scheduleList: endpoint<
{ freightType?: FreightType },
TrainScheduleListItem[]
>(
"train-scheduling", "train-scheduling",
"schedules", "schedules",
({ freightType }) => trainSchedulingService.listSchedules(freightType), ({ freightType }) => trainSchedulingService.listSchedules(freightType),
@@ -211,11 +220,16 @@ export const api = {
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
), ),
batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>( batchBoardDetail: endpoint<
{ scheduleId: string },
BatchBoardScheduleDetail
>(
"train-scheduling", "train-scheduling",
"batch-board-detail", "batch-board-detail",
({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId), ({ scheduleId }) =>
({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), trainSchedulingService.getBatchBoardDetail(scheduleId),
({ scheduleId }) =>
QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
), ),
scheduleDetail: endpoint< scheduleDetail: endpoint<
@@ -344,7 +358,10 @@ export const api = {
), ),
// ── Mutations ────────────────────────────────────────────────────────── // ── Mutations ──────────────────────────────────────────────────────────
runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>( runAllocation: endpoint<
{ scheduleId: string },
WagonAllocationAttemptResult
>(
"train-scheduling", "train-scheduling",
"run-allocation", "run-allocation",
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId), ({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
@@ -462,7 +479,10 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>( pinWagons: endpoint<
{ id: string; payload: PinWagonsPayload },
TrainScheduleDetail
>(
"train-scheduling", "train-scheduling",
"pin-wagons", "pin-wagons",
({ id, payload }) => trainSchedulingService.pinWagons(id, payload), ({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
@@ -558,8 +578,10 @@ export const api = {
({ id }) => warehouseService.getById(id).then((r) => r.data), ({ id }) => warehouseService.getById(id).then((r) => r.data),
), ),
dashboard: endpoint<void, WarehouseDashboard>("warehouses", "dashboard", () => dashboard: endpoint<void, WarehouseDashboard>(
warehouseService.dashboard().then((r) => r.data), "warehouses",
"dashboard",
() => warehouseService.dashboard().then((r) => r.data),
), ),
create: endpoint<SaveWarehousePayload, Warehouse>( create: endpoint<SaveWarehousePayload, Warehouse>(
@@ -650,10 +672,8 @@ export const api = {
listInventory: endpoint< listInventory: endpoint<
{ filter?: InventoryFilter }, { filter?: InventoryFilter },
WarehouseInventoryItem[] WarehouseInventoryItem[]
>( >("warehouse-inventory", "list", ({ filter }) =>
"warehouse-inventory", warehouseService.listInventory(filter).then((r) => r.data),
"list",
({ filter }) => warehouseService.listInventory(filter).then((r) => r.data),
), ),
inquiry: endpoint< inquiry: endpoint<
@@ -666,11 +686,19 @@ export const api = {
({ filter }) => ["warehouse-inventory", "inquiry", filter], ({ filter }) => ["warehouse-inventory", "inquiry", filter],
), ),
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>( eligibleBookings: endpoint<
{ direction?: "IMPORT" | "EXPORT" } | void,
EligibleBooking[]
>(
"warehouse-inventory", "warehouse-inventory",
"eligible-bookings", "eligible-bookings",
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data), (input) =>
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"], warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
(input) => [
"warehouse-inventory",
"eligible-bookings",
input?.direction ?? "ALL",
],
), ),
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>( readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
@@ -706,7 +734,11 @@ export const api = {
"import-train-items", "import-train-items",
({ scheduleId }) => ({ scheduleId }) =>
warehouseService.importTrainItems(scheduleId).then((r) => r.data), 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[]>( importUnloadedQueue: endpoint<void, ImportUnloadedItem[]>(
@@ -774,8 +806,11 @@ export const api = {
"inspection-reports", "inspection-reports",
({ inventoryId }) => ({ inventoryId }) =>
warehouseService.listInspectionReports(inventoryId).then((r) => r.data), warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
({ inventoryId }) => ({ inventoryId }) => [
["warehouse-inventory", inventoryId, "inspection-reports"], "warehouse-inventory",
inventoryId,
"inspection-reports",
],
), ),
allocationRules: endpoint<void, AllocationRule[]>( allocationRules: endpoint<void, AllocationRule[]>(
@@ -792,15 +827,28 @@ export const api = {
() => ["warehouse-fee-rules"], () => ["warehouse-fee-rules"],
), ),
feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>( feePreview: endpoint<
{ inventoryId: string; billingCurrency?: "ETB" | "USD" },
FeePreview[]
>(
"warehouse-inventory", "warehouse-inventory",
"fee-preview", "fee-preview",
({ inventoryId, billingCurrency }) => ({ inventoryId, billingCurrency }) =>
warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data), warehouseService
({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'], .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", "warehouse-fee-invoices",
"list", "list",
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data), ({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
@@ -829,7 +877,8 @@ export const api = {
receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>( receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>(
"warehouse-inventory", "warehouse-inventory",
"receive", "receive",
(payload) => warehouseService.receiveInventory(payload).then((r) => r.data), (payload) =>
warehouseService.receiveInventory(payload).then((r) => r.data),
undefined, undefined,
() => [["warehouse-inventory"], ["warehouses"]], () => [["warehouse-inventory"], ["warehouses"]],
), ),
@@ -864,7 +913,8 @@ export const api = {
>( >(
"warehouse-inventory", "warehouse-inventory",
"load", "load",
({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data), ({ id, payload }) =>
warehouseService.load(id, payload).then((r) => r.data),
undefined, undefined,
() => INVENTORY_INVALIDATIONS, () => INVENTORY_INVALIDATIONS,
), ),
@@ -883,7 +933,8 @@ export const api = {
>( >(
"warehouse-inventory", "warehouse-inventory",
"move", "move",
({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data), ({ id, payload }) =>
warehouseService.move(id, payload).then((r) => r.data),
undefined, undefined,
() => INVENTORY_INVALIDATIONS, () => INVENTORY_INVALIDATIONS,
), ),
@@ -939,7 +990,8 @@ export const api = {
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>( bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
"warehouse-inventory", "warehouse-inventory",
"bulk-mark-inspected", "bulk-mark-inspected",
(payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data), (payload) =>
warehouseService.bulkMarkInspected(payload).then((r) => r.data),
undefined, undefined,
() => INVENTORY_INVALIDATIONS, () => INVENTORY_INVALIDATIONS,
), ),
@@ -957,7 +1009,12 @@ export const api = {
{ {
scheduleId: string; scheduleId: string;
warehouseId?: string; warehouseId?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[]; assignments?: {
bookingId: string;
warehouseId: string;
yardId: string;
zoneId: string;
}[];
}, },
AutoUnloadArrivedResult AutoUnloadArrivedResult
>( >(
@@ -1029,11 +1086,11 @@ export const api = {
), ),
// ── Allocation + fee rules ───────────────────────────────────────────── // ── Allocation + fee rules ─────────────────────────────────────────────
previewAllocation: endpoint<AllocationCriteria, AllocationPreviewResult | null>( previewAllocation: endpoint<
"warehouse-allocation-rules", AllocationCriteria,
"preview", AllocationPreviewResult | null
(criteria) => >("warehouse-allocation-rules", "preview", (criteria) =>
warehouseService.previewAllocation(criteria).then((r) => r.data), warehouseService.previewAllocation(criteria).then((r) => r.data),
), ),
createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>( createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>(
@@ -1095,7 +1152,11 @@ export const api = {
// ── Invoices ─────────────────────────────────────────────────────────── // ── Invoices ───────────────────────────────────────────────────────────
generateInvoice: endpoint< generateInvoice: endpoint<
{ inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' }, {
inventoryId: string;
confirmZero?: boolean;
billingCurrency?: "ETB" | "USD";
},
WarehouseFeeInvoice WarehouseFeeInvoice
>( >(
"warehouse-fee-invoices", "warehouse-fee-invoices",
@@ -1151,7 +1212,10 @@ export const api = {
}, },
routes: { routes: {
list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>( list: endpoint<
{ status?: import("./routes.service").RouteStatus } | void,
RouteRecord[]
>(
"routes", "routes",
"list", "list",
(input) => (input) =>
@@ -1176,7 +1240,10 @@ export const api = {
() => [["routes"]], () => [["routes"]],
), ),
update: endpoint<{ id: string; data: Partial<SaveRoutePayload> }, RouteRecord>( update: endpoint<
{ id: string; data: Partial<SaveRoutePayload> },
RouteRecord
>(
"routes", "routes",
"update", "update",
({ id, data }) => routesService.update(id, data).then((r) => r.data), ({ id, data }) => routesService.update(id, data).then((r) => r.data),
@@ -1289,10 +1356,8 @@ export const api = {
({ trainId }) => ["wagons", "train", trainId], ({ trainId }) => ["wagons", "train", trainId],
), ),
getById: endpoint<{ id: string }, Wagon>( getById: endpoint<{ id: string }, Wagon>("wagons", "getById", ({ id }) =>
"wagons", wagonService.getById(id).then((r) => r.data),
"getById",
({ id }) => wagonService.getById(id).then((r) => r.data),
), ),
assignToTrain: endpoint< assignToTrain: endpoint<
@@ -1671,7 +1736,8 @@ export const api = {
>( >(
"file-upload-settings", "file-upload-settings",
"addField", "addField",
({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto), ({ settingId, dto }) =>
fileUploadSettingsService.addField(settingId, dto),
undefined, undefined,
() => [["file-upload-settings"]], () => [["file-upload-settings"]],
), ),
@@ -1770,7 +1836,8 @@ export const api = {
>( >(
"dropdown-settings", "dropdown-settings",
"updateOption", "updateOption",
({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto), ({ optionId, dto }) =>
dropdownSettingsService.updateOption(optionId, dto),
undefined, undefined,
() => [["dropdown-settings"]], () => [["dropdown-settings"]],
), ),
@@ -1823,11 +1890,10 @@ export const api = {
ruleEngineService.update(resource, id, payload), ruleEngineService.update(resource, id, payload),
), ),
remove: endpoint< remove: endpoint<{ resource: RuleEngineResourceSlug; id: string }, void>(
{ resource: RuleEngineResourceSlug; id: string }, "rule-engine",
void "remove",
>("rule-engine", "remove", ({ resource, id }) => ({ resource, id }) => ruleEngineService.remove(resource, id),
ruleEngineService.remove(resource, id),
), ),
submitRate: endpoint<{ id: string }, RuleEngineRecord>( submitRate: endpoint<{ id: string }, RuleEngineRecord>(
@@ -1850,14 +1916,21 @@ export const api = {
), ),
reorder: endpoint< reorder: endpoint<
{ resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } }, {
resource: RuleEngineResourceSlug;
payload: { ids: string[]; requiresDirectorApproval?: boolean };
},
void void
>("rule-engine", "reorder", ({ resource, payload }) => >("rule-engine", "reorder", ({ resource, payload }) =>
ruleEngineService.reorder(resource, payload), ruleEngineService.reorder(resource, payload),
), ),
moveOrder: endpoint< moveOrder: endpoint<
{ resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" }, {
resource: RuleEngineResourceSlug;
id: string;
direction: "up" | "down";
},
void void
>("rule-engine", "moveOrder", ({ resource, id, direction }) => >("rule-engine", "moveOrder", ({ resource, id, direction }) =>
ruleEngineService.moveOrder(resource, id, direction), ruleEngineService.moveOrder(resource, id, direction),
@@ -1879,10 +1952,8 @@ export const api = {
({ id }) => QUERY_KEYS.BOOKINGS.byId(id), ({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
), ),
remove: endpoint<{ id: string }, void>( remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
"bookings", bookingsService.remove(id),
"remove",
({ id }) => bookingsService.remove(id),
), ),
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>( staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
@@ -1932,10 +2003,11 @@ export const api = {
({ id }) => bookingsService.generateContract(id), ({ id }) => bookingsService.generateContract(id),
), ),
getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>( getContractView: endpoint<
"bookings", { id: string },
"getContractView", import("./bookings.service").ContractView
({ id }) => bookingsService.getContractView(id), >("bookings", "getContractView", ({ id }) =>
bookingsService.getContractView(id),
), ),
signContract: endpoint< signContract: endpoint<
@@ -2019,7 +2091,8 @@ export const api = {
>( >(
"customers", "customers",
"setProfileStatus", "setProfileStatus",
({ profileId, status }) => customersService.setProfileStatus(profileId, status), ({ profileId, status }) =>
customersService.setProfileStatus(profileId, status),
undefined, undefined,
(_input, data) => [ (_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId), QUERY_KEYS.CUSTOMERS.byId(data.companyId),
@@ -2040,6 +2113,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: { overview: {
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>( get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
"overview", "overview",

View File

@@ -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",
});
},
};

View File

@@ -32,6 +32,14 @@ export type ProfileType =
/** Mirrors backend `ProfileStatus`. */ /** Mirrors backend `ProfileStatus`. */
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted"; 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. */ /** A single role a company is registered for, with its reference code. */
export interface CompanyProfile { export interface CompanyProfile {
id: string; id: string;
@@ -39,7 +47,10 @@ export interface CompanyProfile {
type: ProfileType; type: ProfileType;
reference: string; reference: string;
status: ProfileStatus; status: ProfileStatus;
/** @deprecated Superseded by licenseFiles (file model). */
businessLicense?: string | null; businessLicense?: string | null;
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record<string, unknown> | null; attributes?: Record<string, unknown> | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View 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;
}

View File

@@ -1,24 +1,3 @@
import { useCallback, useState } from "react"; // Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; // `@/hooks/useFileViewer` imports keep working.
export { useFileViewer } 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 };
}

View File

@@ -1,13 +1,16 @@
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service"; import { companiesService } from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings"; import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import { import {
Anchor,
Button, Button,
Card, Card,
Center, Center,
Group, Group,
Stack,
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
@@ -17,10 +20,19 @@ import {
CheckCircle2, CheckCircle2,
FileCheck, FileCheck,
Loader2, Loader2,
Paperclip,
UploadCloud, UploadCloud,
XCircle, XCircle,
} from "lucide-react"; } 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 { interface TabDocumentsProps {
profile: ProfileResponse; profile: ProfileResponse;
@@ -28,21 +40,63 @@ interface TabDocumentsProps {
onContinue?: () => void; 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 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( const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({ 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({ const docUploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) => mutationFn: (files: Record<string, File | File[] | null>) =>
companiesService.uploadDocuments(profile.companyId, files), companiesService.uploadDocuments(profile.companyId, files),
onSuccess: () => { 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 ?? []) { for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field); const min = getMinFiles(field);
if (min <= 0) continue; if (min <= 0) continue;
if (uploadedKeys.includes(field.fileKey)) continue;
const v = documentFiles[field.fileKey]; const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0; const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) { if (count < min) {
@@ -82,94 +137,139 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
return errs; return errs;
}; };
const licenseProfiles = profile.companyProfiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
return ( return (
<Card padding="lg"> <>
<Group gap="sm" mb="xs"> <Card padding="lg">
<FileCheck size={20} /> <Group gap="sm" mb="xs">
<Title order={3}>Documents</Title> <FileCheck size={20} />
</Group> <Title order={3}>Documents</Title>
<Text c="edr-muted" size="sm" mb="lg"> </Group>
Upload and manage required business documents <Text c="edr-muted" size="sm" mb="lg">
</Text> Upload and manage required business documents
{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> </Text>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
/>
)}
{docSettingQuery.data && ( {docSettingQuery.isLoading ? (
<Group <Center py="xl">
justify="space-between" <Loader2 size={24} className="animate-spin" />
mt="lg" </Center>
pt="md" ) : !docSettingQuery.data ? (
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }} <Text c="edr-muted" size="sm" ta="center" py="md">
> No document requirements configured for your account.
<Group gap="xs"> </Text>
{docUploadMutation.isSuccess && ( ) : (
<Group gap={6} c="green"> <SmartFileInput
<CheckCircle2 size={16} /> file={docSettingQuery.data}
<Text size="sm" fw={500}> value={documentFiles}
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"} onChange={handleFilesChange}
</Text> errors={fieldErrors}
</Group> uploadedKeys={uploadedKeys}
)} existingFiles={existingFilesByKey}
{docUploadMutation.isError && ( onViewFile={view}
<Group gap={6} c="red"> />
<XCircle size={16} /> )}
<Text size="sm" fw={500}>Upload failed</Text>
</Group> {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> </Group>
{mode === "onboarding" ? ( )}
<Button </Card>
type="button"
leftSection={<ArrowRight size={16} />} {licenseProfiles.length > 0 && (
loading={docUploadMutation.isPending} <Card padding="lg" mt="lg">
onClick={() => { <Group gap="sm" mb="xs">
const validationErrors = validateRequired(); <Paperclip size={20} />
if (Object.keys(validationErrors).length > 0) { <Title order={3}>Business licenses</Title>
setFieldErrors(validationErrors); </Group>
return; <Text c="edr-muted" size="sm" mb="lg">
} License documents uploaded per operational profile
if (hasFiles) { </Text>
docUploadMutation.mutate(documentFiles, {
onSuccess: () => onContinue?.(), <Stack gap="md">
}); {licenseProfiles.map((p) => (
} else { <Stack key={p.id} gap={4}>
onContinue?.(); <Text size="sm" fw={600} c="edr-text">
} {ROLE_LABELS[p.type] ?? p.type} · {p.reference}
}} </Text>
> {p.licenseFiles.map((f) => (
Continue <Group key={f.url} gap={6} wrap="nowrap">
</Button> <Paperclip size={13} className="text-edr-muted" />
) : ( <Text component="button" type="button" size="xs">
<Button {f.name}
type="button" </Text>
leftSection={<UploadCloud size={16} />} </Group>
loading={docUploadMutation.isPending} ))}
disabled={!hasFiles} </Stack>
onClick={() => { ))}
if (!hasFiles) return; </Stack>
docUploadMutation.mutate(documentFiles); </Card>
}}
>
Upload Documents
</Button>
)}
</Group>
)} )}
</Card>
{viewer}
</>
); );
} }

View File

@@ -53,6 +53,7 @@ import {
UpdateDropdownSettingDto, UpdateDropdownSettingDto,
} from "@/types/dropdownSettings"; } from "@/types/dropdownSettings";
import type { import type {
CompanyDocument,
CompanyInfoResponse, CompanyInfoResponse,
CompanyNationality, CompanyNationality,
CompanyProfileResponse, CompanyProfileResponse,
@@ -158,7 +159,11 @@ export const api = {
createCompanyProfile: endpoint< createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string }, { type: ProfileTypeValue; businessLicense?: string },
CompanyProfileResponse CompanyProfileResponse
>("companies", "createCompanyProfile", companiesService.createCompanyProfile), >(
"companies",
"createCompanyProfile",
companiesService.createCompanyProfile,
),
startOnboarding: endpoint< startOnboarding: endpoint<
{ {
@@ -192,6 +197,12 @@ export const api = {
"onboardingRequirements", "onboardingRequirements",
companiesService.getOnboardingRequirements, companiesService.getOnboardingRequirements,
), ),
documents: endpoint<{ companyId: string }, CompanyDocument[]>(
"companies",
"documents",
({ companyId }) => companiesService.getDocuments(companyId),
),
}, },
bookings: { bookings: {
@@ -228,7 +239,8 @@ export const api = {
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>( downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
"bookings", "bookings",
"downloadHandoverDocument", "downloadHandoverDocument",
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId), ({ inventoryId }) =>
bookingsService.downloadHandoverDocument(inventoryId),
), ),
create: endpoint< create: endpoint<
@@ -312,11 +324,8 @@ export const api = {
proceedToOperation: endpoint< proceedToOperation: endpoint<
{ id: string; scheduledDate: string }, { id: string; scheduledDate: string },
Freight.IBooking Freight.IBooking
>( >("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
"bookings", bookingsService.proceedToOperation(id, scheduledDate),
"proceedToOperation",
({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
), ),
checkPayment: endpoint<{ orderId: string }, { status: string }>( checkPayment: endpoint<{ orderId: string }, { status: string }>(
@@ -354,10 +363,11 @@ export const api = {
bookingsService.getAvailableDays({ originYardId, destinationYardId }), bookingsService.getAvailableDays({ originYardId, destinationYardId }),
), ),
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>( getAvailableDaysForCargo: endpoint<
"train-scheduling", Freight.AvailableDaysForCargoQuery,
"availableDaysForCargo", string[]
(input) => bookingsService.getAvailableDaysForCargo(input), >("train-scheduling", "availableDaysForCargo", (input) =>
bookingsService.getAvailableDaysForCargo(input),
), ),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>( getMyBookingWindows: endpoint<void, MyBookingWindow[]>(

View File

@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
company: CompanyResponse; 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. */ /** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField { export interface OnboardingDocumentField {
fileKey: string; fileKey: string;
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
} }
export interface CompanyProfileInput { export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; type:
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
businessLicense?: string; 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>>( const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.CREATE, URL_CONSTANTS.COMPANIES_API.CREATE,
payload, payload,
@@ -195,7 +214,9 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => { updateProfile: async (
payload: UpdateProfilePayload,
): Promise<ProfileResponse> => {
const response = await client.patch<ApiResponse<ProfileResponse>>( const response = await client.patch<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE, URL_CONSTANTS.COMPANIES_API.PROFILE,
payload, payload,
@@ -293,7 +314,18 @@ export const companiesService = {
formData.append(fieldName, fileOrFiles); 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). */ /** Upload business-license document(s) for a company profile (multi-file). */

View File

@@ -11,6 +11,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { Button } from "../button"; import { Button } from "../button";
import type { ViewableFile } from "../FileViewer";
export interface SmartFileInputProps { export interface SmartFileInputProps {
/** The settings object containing features and their upload fields config. */ /** The settings object containing features and their upload fields config. */
@@ -27,6 +28,26 @@ export interface SmartFileInputProps {
* no in-memory File is currently selected for them. * no in-memory File is currently selected for them.
*/ */
uploadedKeys?: string[]; uploadedKeys?: string[];
/**
* Metadata for already-uploaded files, keyed by fileKey. When a field has
* entries here, they're listed with view/download links (instead of the
* generic placeholder text) and the field counts as uploaded even if it's
* not also listed in `uploadedKeys`.
*/
existingFiles?: Record<
string,
{ name: string; url: string; size?: number; mimeType?: string | null }[]
>;
/**
* When provided, already-uploaded files render as buttons that call this with
* the file instead of opening a new browser tab. Wire it to `useFileViewer`'s
* `view` to preview documents inline:
*
* const { view, viewer } = useFileViewer();
* <SmartFileInput ... onViewFile={view} />
* {viewer}
*/
onViewFile?: (file: ViewableFile) => void;
/** Disabled state for the entire file input group. */ /** Disabled state for the entire file input group. */
disabled?: boolean; disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
@@ -70,12 +91,76 @@ function FileIcon({ name, className }: { name: string; className?: string }) {
return <File className={cn("text-slate-400", className)} />; return <File className={cn("text-slate-400", className)} />;
} }
type ExistingFile = {
name: string;
url: string;
size?: number;
mimeType?: string | null;
};
/**
* A single already-uploaded file. Renders a click-to-view button when
* `onViewFile` is set (inline preview via the FileViewer), otherwise a plain
* new-tab anchor.
*/
function ExistingFileLink({
file: f,
onViewFile,
className,
showSize = false,
}: {
file: ExistingFile;
onViewFile?: (file: ViewableFile) => void;
className?: string;
showSize?: boolean;
}) {
const label =
showSize && typeof f.size === "number"
? `${f.name} (${formatBytes(f.size)})`
: f.name;
if (onViewFile) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewFile({ name: f.name, url: f.url, mimeType: f.mimeType });
}}
className={cn(
"relative z-10 text-left text-xs text-primary hover:underline truncate max-w-xs",
className,
)}
>
{label}
</button>
);
}
return (
<a
href={f.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={cn(
"relative z-10 text-xs text-primary hover:underline truncate max-w-xs",
className,
)}
>
{label}
</a>
);
}
export function SmartFileInput({ export function SmartFileInput({
file, file,
value, value,
onChange, onChange,
errors, errors,
uploadedKeys, uploadedKeys,
existingFiles,
onViewFile,
disabled = false, disabled = false,
variant = "default", variant = "default",
className, className,
@@ -270,9 +355,11 @@ export function SmartFileInput({
const fieldError = const fieldError =
errors?.[field.fileKey] || localErrors[field.fileKey]; errors?.[field.fileKey] || localErrors[field.fileKey];
const isDragOver = dragActive[field.fileKey]; const isDragOver = dragActive[field.fileKey];
const existingForField = existingFiles?.[field.fileKey] ?? [];
// Already uploaded server-side and nothing newly picked to replace it. // Already uploaded server-side and nothing newly picked to replace it.
const isUploaded = const isUploaded =
(uploadedKeys?.includes(field.fileKey) ?? false) && ((uploadedKeys?.includes(field.fileKey) ?? false) ||
existingForField.length > 0) &&
currentFiles.length === 0; currentFiles.length === 0;
// Format accepted files for the HTML input element // Format accepted files for the HTML input element
@@ -417,6 +504,17 @@ export function SmartFileInput({
{field.allowedExtensions.join(", ").toUpperCase() || {field.allowedExtensions.join(", ").toUpperCase() ||
"All"} "All"}
</span> </span>
{existingForField.length > 0 && (
<div className="flex flex-col gap-1 basis-full">
{existingForField.map((f, idx) => (
<ExistingFileLink
key={`${f.url}-${idx}`}
file={f}
onViewFile={onViewFile}
/>
))}
</div>
)}
</div> </div>
) : isUploaded ? ( ) : isUploaded ? (
// Uploaded state: a solid success panel that still doubles as a // Uploaded state: a solid success panel that still doubles as a
@@ -431,7 +529,7 @@ export function SmartFileInput({
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
disabled && disabled &&
"opacity-50 pointer-events-none cursor-not-allowed", "opacity-50 pointer-events-none cursor-not-allowed",
)} )}
> >
<input <input
@@ -457,11 +555,24 @@ export function SmartFileInput({
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{isDragOver ? "Drop to replace" : "Document uploaded"} {isDragOver ? "Drop to replace" : "Document uploaded"}
</p> </p>
<p className="mt-0.5 text-xs text-muted-foreground"> {existingForField.length > 0 ? (
{isDragOver <div className="mt-0.5 flex flex-col gap-0.5">
? "Release to replace the document on file." {existingForField.map((f, idx) => (
: "Saved to your application. Drag a new file here or click to replace it."} <ExistingFileLink
</p> key={`${f.url}-${idx}`}
file={f}
onViewFile={onViewFile}
showSize
/>
))}
</div>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{isDragOver
? "Release to replace the document on file."
: "Saved to your application. Drag a new file here or click to replace it."}
</p>
)}
</div> </div>
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex"> <span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
@@ -480,9 +591,9 @@ export function SmartFileInput({
? "border-primary bg-primary/5 dark:bg-primary/10" ? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10", : "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError && fieldError &&
"border-destructive hover:border-destructive/80", "border-destructive hover:border-destructive/80",
disabled && disabled &&
"opacity-50 pointer-events-none cursor-not-allowed", "opacity-50 pointer-events-none cursor-not-allowed",
)} )}
> >
<input <input

View File

@@ -0,0 +1,27 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "../components/FileViewer";
/**
* 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}
*
* Pass `view` straight into `SmartFileInput`'s `onViewFile` prop to make its
* already-uploaded files open in the viewer instead of a new tab.
*/
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 };
}

View File

@@ -20,6 +20,8 @@ export type {
ViewableFile, ViewableFile,
} from "./components/FileViewer"; } from "./components/FileViewer";
export { useFileViewer } from "./hooks/useFileViewer";
export { OperationDatePicker } from "./components/OperationDatePicker"; export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker"; export type { OperationDatePickerProps } from "./components/OperationDatePicker";