From 53fed882be6e833003e48f84e93c2b4764e122c8 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 06:20:05 +0000
Subject: [PATCH 1/8] feat: setup pagination to find all invoice api
---
.../src/modules/billing/billing.controller.ts | 24 ++++++---
.../src/modules/billing/billing.service.ts | 53 +++++++++++++++++--
.../modules/billing/dto/filter-invoice.dto.ts | 42 +++++++++++++++
3 files changed, 109 insertions(+), 10 deletions(-)
create mode 100644 apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts
diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts
index 7da5e2d66..b9f0a74c0 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts
@@ -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 type { Response } from "express";
-import { FreightAdmin } from "../../common/booking-guards";
+import { BookingView } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
+import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
-@FreightAdmin()
+@BookingView()
@ApiBearerAuth()
export class BillingController {
- constructor(private readonly billingService: BillingService) { }
+ constructor(private readonly billingService: BillingService) {}
@Get("invoices")
- @ApiOperation({ summary: "List all invoices" })
- findAll() {
- return this.billingService.findAll();
+ @ApiOperation({
+ summary: "List invoices (paginated, filterable by company/status/search)",
+ })
+ findAll(@Query() query: FilterInvoiceDto) {
+ return this.billingService.findAllPaginated(query);
}
@Get("invoices/:id")
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 82a47fa29..536129122 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -125,7 +125,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
- ) { }
+ ) {}
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -134,9 +134,56 @@ export class BillingService {
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. */
async findById(id: string): Promise {
- 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`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
@@ -375,7 +422,7 @@ export class BillingService {
input.dueAt ??
new Date(
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);
diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts
new file mode 100644
index 000000000..e8942d586
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts
@@ -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;
+}
From 1e408536aafa8b186a593ed3aaa14326dc3faba2 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 06:21:25 +0000
Subject: [PATCH 2/8] feat: add invoice pages and api to the backoffice
---
apps/edr-freight-web/backoffice/src/App.tsx | 416 +++++++++++-------
.../backoffice/src/constants/QUERY_KEYS.ts | 75 +++-
.../backoffice/src/constants/URLS.ts | 207 +++++----
.../src/pages/invoices/InvoiceDetailPage.tsx | 254 +++++++++++
.../src/pages/invoices/InvoicesPage.tsx | 237 ++++++++++
.../backoffice/src/services/api.ts | 203 ++++++---
.../src/services/invoices.service.ts | 36 ++
.../backoffice/src/types/invoice.ts | 22 +
8 files changed, 1128 insertions(+), 322 deletions(-)
create mode 100644 apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/services/invoices.service.ts
create mode 100644 apps/edr-freight-web/backoffice/src/types/invoice.ts
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 567ecf074..e0c47414d 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -12,6 +12,7 @@ import {
PackageCheck,
PackageOpen,
Paperclip,
+ Receipt,
Send,
Settings,
ShieldCheck,
@@ -32,7 +33,11 @@ import {
useParams,
} from "react-router-dom";
-import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
+import {
+ FreightDashboardLayout,
+ type SidebarItem,
+ type SidebarSection,
+} from "@/components/layout";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
@@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
+import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
+import InvoicesPage from "./pages/invoices/InvoicesPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
@@ -61,7 +68,10 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
-import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
+import {
+ FREIGHT_PERMS,
+ hasPermission as hasFreightPermission,
+} from "./lib/permissions";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -144,6 +154,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: ,
permission: FREIGHT_PERMS.bookings.view,
},
+ {
+ label: "Invoices",
+ href: "/dashboard/invoices",
+ icon: ,
+ permission: FREIGHT_PERMS.bookings.view,
+ },
...demoItems,
],
},
@@ -506,7 +522,10 @@ const App = () => {
} />
} />
} />
- } />
+ }
+ />
}>
} />
} />
@@ -522,8 +541,27 @@ const App = () => {
/>
} />
} />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
} />
- } />
+ }
+ />
}
@@ -536,7 +574,9 @@ const App = () => {
+
}
@@ -570,7 +610,9 @@ const App = () => {
+
}
@@ -578,7 +620,9 @@ const App = () => {
+
}
@@ -586,7 +630,9 @@ const App = () => {
+
}
@@ -618,12 +664,20 @@ const App = () => {
}
/>
- } />
- } />
+ }
+ />
+ }
+ />
+
}
@@ -631,7 +685,9 @@ const App = () => {
+
}
@@ -644,7 +700,9 @@ const App = () => {
+
}
@@ -655,155 +713,174 @@ const App = () => {
/>
} />
} />
- } />
+ }
+ />
} />
} />
} />
} />
} />
} />
- } />
- } />
+ }
+ />
+ }
+ />
} />
} />
- } />
- } />
+ }
+ />
+ }
+ />
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
}
+ element={
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+ }
/>
{
{/* Legacy embedded user management routes */}
} />
} />
- } />
+ }
+ />
{/* } /> */}
- } />
+ }
+ />
} />
{
}
+ element={
+
+ }
/>
{
}
/>
} />
- } />
- } />
+ }
+ />
+ }
+ />
{
}
+ element={
+
+ }
+ />
+ }
/>
- } />
} />
} />
@@ -1026,9 +1122,7 @@ const App = () => {
/** Redirect removed milestones page to document clearance. */
function BookingMilestonesRedirect() {
const { id } = useParams();
- return (
-
- );
+ return ;
}
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
index 2221fe898..cb30ad30b 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -3,6 +3,7 @@ import type { BookingListFilter } from "@/services/bookings.service";
import type { ContractListFilter } from "@/services/contracts.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { CompanyListFilter } from "@/types/customer";
+import type { InvoiceListFilter } from "@/types/invoice";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
import type { TrainScheduleFilters } from "@/types/trainScheduling";
@@ -16,7 +17,8 @@ export const QUERY_KEYS = {
ROOT: ["file-upload-settings"] as const,
list: () => ["file-upload-settings", "list"] as const,
byId: (id: string) => ["file-upload-settings", "detail", id] as const,
- byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
+ byCode: (code: string) =>
+ ["file-upload-settings", "by-code", code] as const,
},
DROPDOWN_SETTINGS: {
@@ -33,10 +35,18 @@ export const QUERY_KEYS = {
["customers", "list", filter ?? {}] as const,
byId: (id: string) => ["customers", "detail", id] as const,
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
- documents: (id: string) => ["customers", "detail", id, "documents"] as const,
+ documents: (id: string) =>
+ ["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
},
+ INVOICES: {
+ ROOT: ["invoices"] as const,
+ list: (filter?: InvoiceListFilter) =>
+ ["invoices", "list", filter ?? {}] as const,
+ byId: (id: string) => ["invoices", "detail", id] as const,
+ },
+
BOOKINGS: {
ROOT: ["bookings"] as const,
list: (filter?: BookingListFilter) =>
@@ -79,7 +89,12 @@ export const QUERY_KEYS = {
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
- ["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
+ [
+ "train-scheduling",
+ "eligible-bookings",
+ freightType ?? "CONTAINER",
+ filters ?? {},
+ ] as const,
locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const,
@@ -97,31 +112,37 @@ export const QUERY_KEYS = {
FLEET: {
ROOT: ["fleet"] as const,
- list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
+ list: (resource: FleetResourceSlug | string) =>
+ ["fleet", "list", resource] as const,
},
VEHICLES: {
ROOT: ["vehicles"] as const,
- list: (filter?: Record) => ["vehicles", "list", filter ?? {}] as const,
+ list: (filter?: Record) =>
+ ["vehicles", "list", filter ?? {}] as const,
byId: (id: string) => ["vehicles", "detail", id] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
- list: (filter?: Record) => ["first-mile", "list", filter ?? {}] as const,
+ list: (filter?: Record) =>
+ ["first-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["first-mile", "detail", id] as const,
},
LAST_MILE: {
ROOT: ["last-mile"] as const,
- list: (filter?: Record) => ["last-mile", "list", filter ?? {}] as const,
+ list: (filter?: Record) =>
+ ["last-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["last-mile", "detail", id] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
- list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
- ["rule-engine", "list", resource, params ?? {}] as const,
+ list: (
+ resource: RuleEngineResourceSlug | string,
+ params?: RuleEngineListParams,
+ ) => ["rule-engine", "list", resource, params ?? {}] as const,
detail: (resource: RuleEngineResourceSlug | string, id: string) =>
["rule-engine", "detail", resource, id] as const,
chain: ["rule-engine", "approval-rules", "chain"] as const,
@@ -135,27 +156,39 @@ export const QUERY_KEYS = {
OVERVIEW: {
ROOT: ["overview"] as const,
- dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
- bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
- contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const,
- billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
+ dashboard: (range?: string) =>
+ ["overview", "dashboard", range ?? "30d"] as const,
+ bookingsTab: (range?: string) =>
+ ["overview", "bookings", range ?? "30d"] as const,
+ contractsTab: (range?: string) =>
+ ["overview", "contracts", range ?? "30d"] as const,
+ billingTab: (range?: string) =>
+ ["overview", "billing", range ?? "30d"] as const,
operationsTab: () => ["overview", "operations"] as const,
- customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
- staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
+ customersTab: (range?: string) =>
+ ["overview", "customers", range ?? "30d"] as const,
+ staffTab: (range?: string) =>
+ ["overview", "staff", range ?? "30d"] as const,
},
FUEL: {
ROOT: ["fuel"] as const,
- purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
- stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
+ purchases: (vehicleId?: string) =>
+ ["fuel", "purchases", vehicleId ?? "all"] as const,
+ stats: (vehicleId?: string) =>
+ ["fuel", "stats", vehicleId ?? "all"] as const,
},
MAINTENANCE: {
ROOT: ["maintenance"] as const,
- schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
- upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
- history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
- stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
+ schedules: (vehicleId?: string) =>
+ ["maintenance", "schedules", vehicleId ?? "all"] as const,
+ upcoming: (vehicleId?: string) =>
+ ["maintenance", "upcoming", vehicleId ?? "all"] as const,
+ history: (vehicleId?: string) =>
+ ["maintenance", "history", vehicleId ?? "all"] as const,
+ stats: (vehicleId?: string) =>
+ ["maintenance", "stats", vehicleId ?? "all"] as const,
},
FINANCIAL_REPORTS: {
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index dff9167f5..abac14b04 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -13,7 +13,7 @@ export const URL_CONSTANTS = {
BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`,
SET_PASSWORD: "/api/auth/set-password",
- ME: "/api/auth/me"
+ ME: "/api/auth/me",
},
ROLES: {
@@ -74,9 +74,18 @@ export const URL_CONSTANTS = {
STATS: "/companies/stats",
BY_ID: (id: string | number) => `/companies/${id}`,
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
- PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`,
- BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`,
- PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`,
+ PROFILE_STATUS: (profileId: string) =>
+ `/companies/company-profiles/${profileId}/status`,
+ BOOKINGS_CUSTOMER_VIEW: (id: string) =>
+ `/bookings/by-company/${id}/customer-view`,
+ PAYMENTS_CUSTOMER_VIEW: (id: string) =>
+ `/payments/by-company/${id}/customer-view`,
+ },
+
+ BILLING: {
+ INVOICES: "/billing/invoices",
+ INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
+ INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
},
CUSTOMERS_API: {
@@ -124,15 +133,21 @@ export const URL_CONSTANTS = {
CANCEL: (id: string) => `/bookings/${id}/cancel`,
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
- CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`,
+ CLEARANCE_DECLARATION: (id: string) =>
+ `/bookings/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_FINALIZE_PRE: (id: string) =>
`/bookings/${id}/clearance/finalize-pre-clearance`,
- CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`,
- CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`,
- CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`,
- CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`,
- CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`,
+ CLEARANCE_TRANSIT_PERMIT: (id: string) =>
+ `/bookings/${id}/clearance/transit-permit`,
+ CLEARANCE_DELIVERY_ORDER: (id: string) =>
+ `/bookings/${id}/clearance/delivery-order`,
+ CLEARANCE_RELEASE_ORDER: (id: string) =>
+ `/bookings/${id}/clearance/release-order`,
+ CLEARANCE_RO_AMENDMENT: (id: string) =>
+ `/bookings/${id}/clearance/ro-amendment`,
+ CLEARANCE_EXPORT_RELEASE: (id: string) =>
+ `/bookings/${id}/clearance/export-release`,
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
@@ -157,7 +172,8 @@ export const URL_CONSTANTS = {
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
- CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
+ CLEARANCE_DECLARATION: (id: string) =>
+ `/contracts/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
CLEARANCE_FINALIZE_PRE: (id: string) =>
@@ -232,7 +248,7 @@ export const URL_CONSTANTS = {
},
ROUTES: {
- BASE: '/routes',
+ BASE: "/routes",
BY_ID: (id: string) => `/routes/${id}`,
},
@@ -246,10 +262,12 @@ export const URL_CONSTANTS = {
BATCH_BOARD_DETAIL: (scheduleId: string) =>
`/train-scheduling/batch-board/${scheduleId}`,
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
- RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
+ RUN_ALLOCATION: (id: string) =>
+ `/train-scheduling/schedules/${id}/run-allocation`,
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
- BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
+ BOOKING_WINDOW: (id: string) =>
+ `/train-scheduling/schedules/${id}/booking-window`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) =>
@@ -258,12 +276,14 @@ export const URL_CONSTANTS = {
`/train-scheduling/bookings/${bookingId}/move-schedule`,
GLOBAL_RULES: "/train-scheduling/global-rules",
PREVIEW: "/train-scheduling/preview",
- ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
+ ASSIGN_BOOKINGS: (id: string) =>
+ `/train-scheduling/schedules/${id}/assign-bookings`,
CONTAINER: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview",
SCHEDULES: "/train-scheduling/container/schedules",
- SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
+ SCHEDULE_BY_ID: (id: string) =>
+ `/train-scheduling/container/schedules/${id}`,
ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/container/schedules/${id}/assign-bookings`,
CANCEL_SCHEDULE: (id: string) =>
@@ -302,15 +322,18 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`,
- CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
+ CHECKPOINTS: (id: string) =>
+ `/train-scheduling/schedules/${id}/checkpoints`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`,
RESCHEDULE_EXECUTE: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/execute`,
- MAINTENANCE: (id: string) => `/train-scheduling/schedules/${id}/maintenance`,
+ MAINTENANCE: (id: string) =>
+ `/train-scheduling/schedules/${id}/maintenance`,
SCHEDULES: "/train-scheduling/container/schedules",
- SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
+ SCHEDULE_BY_ID: (id: string) =>
+ `/train-scheduling/container/schedules/${id}`,
CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`,
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
@@ -358,175 +381,193 @@ export const URL_CONSTANTS = {
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
},
- RATE_MATRIX: {
- BASE: '/api/rate-matrices',
- DRAFT: '/api/rate-matrices/draft',
+ RATE_MATRIX: {
+ BASE: "/api/rate-matrices",
+ DRAFT: "/api/rate-matrices/draft",
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
- LIST: '/api/rate-matrices',
+ LIST: "/api/rate-matrices",
DETAIL: (id: string) => `/api/rate-matrices/${id}`,
},
REFERENCE: {
- PORTS: '/api/reference/ports',
- CITIES: '/api/reference/cities',
- CONTAINER_TYPES: '/api/reference/container-types',
- CURRENCIES: '/api/reference/currencies',
+ PORTS: "/api/reference/ports",
+ CITIES: "/api/reference/cities",
+ CONTAINER_TYPES: "/api/reference/container-types",
+ CURRENCIES: "/api/reference/currencies",
},
FACILITIES: {
- BASE: '/facilities',
+ BASE: "/facilities",
BY_ID: (id: string) => `/facilities/${id}`,
},
WAREHOUSES: {
- BASE: '/warehouses',
- DASHBOARD: '/warehouses/dashboard',
+ BASE: "/warehouses",
+ DASHBOARD: "/warehouses/dashboard",
BY_ID: (id: string) => `/warehouses/${id}`,
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
},
WAREHOUSE_YARDS: {
- BASE: '/warehouse-yards',
+ BASE: "/warehouse-yards",
BY_ID: (id: string) => `/warehouse-yards/${id}`,
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
},
WAREHOUSE_ZONES: {
- BASE: '/warehouse-zones',
+ BASE: "/warehouse-zones",
BY_ID: (id: string) => `/warehouse-zones/${id}`,
},
WAREHOUSE_INVENTORY: {
- BASE: '/warehouse-inventory',
- RECEIVE: '/warehouse-inventory/receive',
- DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
- READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
- INQUIRY: '/warehouse-inventory/inquiry',
+ BASE: "/warehouse-inventory",
+ RECEIVE: "/warehouse-inventory/receive",
+ DASHBOARD_SUMMARY: "/warehouse-inventory/dashboard/summary",
+ READY_FOR_LOADING: "/warehouse-inventory/ready-for-loading",
+ INQUIRY: "/warehouse-inventory/inquiry",
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
- RESERVE: '/warehouse-inventory/reserve',
- ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
- AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
- AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
- UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
- INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
- LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
- BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
+ RESERVE: "/warehouse-inventory/reserve",
+ ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
+ AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
+ AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
+ UNLOAD_BOOKING: (bookingId: string) =>
+ `/warehouse-inventory/bookings/${bookingId}/unload`,
+ INSPECTION_REPORTS: (inventoryId: string) =>
+ `/warehouse-inventory/${inventoryId}/inspection-reports`,
+ LOADABLE_WAGONS: "/warehouse-inventory/loadable-wagons",
+ BOOKING_SCHEDULE: (bookingId: string) =>
+ `/warehouse-inventory/booking/${bookingId}/schedule`,
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
// Import branch
- MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
+ MARK_READY_PICKUP: (id: string) =>
+ `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
- RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
+ RELEASE_DOCUMENT: (id: string) =>
+ `/warehouse-inventory/${id}/release-document`,
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
- HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
+ HANDOVER_DOCUMENT: (id: string) =>
+ `/warehouse-inventory/${id}/handover-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk)
ELIGIBLE_BOOKINGS: (direction?: string) =>
direction
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
: `/warehouse-inventory/eligible-bookings`,
- RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
- LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
- BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
- RECEIVED_EXPORT: '/warehouse-inventory/received-export',
- READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
- LOADED_EXPORT: '/warehouse-inventory/loaded-export',
- BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
- IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
+ RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
+ LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
+ BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
+ RECEIVED_EXPORT: "/warehouse-inventory/received-export",
+ READY_TO_LOAD_EXPORT: "/warehouse-inventory/ready-to-load-export",
+ LOADED_EXPORT: "/warehouse-inventory/loaded-export",
+ BULK_DISPATCH_EXPORT: "/warehouse-inventory/bulk-dispatch-export",
+ IMPORT_ARRIVE_QUEUE: "/warehouse-inventory/import/arrive-queue",
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/import/trains/${scheduleId}/items`,
- IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
- IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
- IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
- EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue',
+ IMPORT_AUTO_UNLOAD_ARRIVED:
+ "/warehouse-inventory/import/auto-unload-arrived-bookings",
+ IMPORT_UNLOADED_QUEUE: "/warehouse-inventory/import/unloaded-queue",
+ IMPORT_PICKUP_READY_QUEUE: "/warehouse-inventory/import/pickup-ready-queue",
+ EXPORT_DJIBOUTI_ARRIVAL_QUEUE:
+ "/warehouse-inventory/export/djibouti-arrival-queue",
EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`,
- EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti',
+ EXPORT_AUTO_UNLOAD_AT_DJIBOUTI:
+ "/warehouse-inventory/export/auto-unload-at-djibouti",
},
WAREHOUSE_LOADINGS: {
- BASE: '/warehouse-loadings',
+ BASE: "/warehouse-loadings",
},
WAREHOUSE_INSPECTION: {
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
- ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
+ ATTACHMENTS: (id: string) =>
+ `/warehouse-inspection-reports/${id}/attachments`,
},
WAREHOUSE_RULES: {
- ALLOCATION: '/warehouse-allocation-rules',
+ ALLOCATION: "/warehouse-allocation-rules",
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
- ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
- FEES: '/warehouse-fee-rules',
+ ALLOCATION_PREVIEW: "/warehouse-allocation/preview",
+ FEES: "/warehouse-fee-rules",
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
- FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
+ FEE_PREVIEW: (inventoryId: string) =>
+ `/warehouse-inventory/${inventoryId}/fee-preview`,
},
WAREHOUSE_INVOICES: {
- BASE: '/warehouse-fee-invoices',
+ BASE: "/warehouse-fee-invoices",
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
- GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
- FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
- FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
- GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
+ GENERATE: (inventoryId: string) =>
+ `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
+ FOR_INVENTORY: (inventoryId: string) =>
+ `/warehouse-inventory/${inventoryId}/fee-invoices`,
+ FOR_BOOKING: (bookingId: string) =>
+ `/bookings/${bookingId}/warehouse-fee-invoices`,
+ GATE_CLEARANCE: (inventoryId: string) =>
+ `/warehouse-inventory/${inventoryId}/gate-clearance`,
},
INTERCHANGE_DOCUMENTS: {
- BASE: '/interchange-documents',
+ BASE: "/interchange-documents",
BY_ID: (id: string) => `/interchange-documents/${id}`,
- GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule',
+ GENERATE_FROM_SCHEDULE: "/interchange-documents/generate-from-schedule",
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
},
IMPORT_OPERATIONS: {
- DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
+ DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
- CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
- CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
+ CUSTOMS_DOCUMENTS: (bookingId: string) =>
+ `/import-operations/customs/${bookingId}/documents`,
+ CUSTOMS_DECLARATION: (bookingId: string) =>
+ `/import-operations/customs/${bookingId}/declaration`,
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
- CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
+ CUSTOMS_RISK: (bookingId: string) =>
+ `/import-operations/customs/${bookingId}/risk`,
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`,
- EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
+ EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,
},
VEHICLES: {
- BASE: '/vehicles',
+ BASE: "/vehicles",
BY_ID: (id: string) => `/vehicles/${id}`,
},
FIRST_MILE: {
- BASE: '/first-mile',
+ BASE: "/first-mile",
BY_ID: (id: string) => `/first-mile/${id}`,
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
},
LAST_MILE: {
- BASE: '/last-mile',
+ BASE: "/last-mile",
BY_ID: (id: string) => `/last-mile/${id}`,
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
},
DRIVERS: {
- BASE: '/drivers',
+ BASE: "/drivers",
BY_ID: (id: string) => `/drivers/${id}`,
},
};
diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx
new file mode 100644
index 000000000..7bafc093e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx
@@ -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 (
+
+
+ {label}
+
+
+ {value && value.trim() ? value : "—"}
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+ }
+
+ if (!invoice) {
+ return (
+
+
+ Invoice not found
+ }
+ onClick={() => navigate("/dashboard/invoices")}
+ >
+ Back to invoices
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ action={
+ void downloadDocument()}
+ >
+
+
+ }
+ />
+
+
+
+
+
+ Summary
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Line items
+
+
+
+
+ Description
+ Charge type
+ Quantity
+ Unit rate
+ Amount
+
+
+
+ {(invoice.lines ?? []).map((line) => (
+
+ {line.description ?? line.chargeType}
+
+
+ {humanize(line.chargeType)}
+
+
+ {line.quantity}
+
+ {formatMoney(line.unitRate, line.currency)}
+
+
+ {formatMoney(line.amount, line.currency)}
+
+
+ ))}
+ {(invoice.lines ?? []).length === 0 && (
+
+
+
+ No line items.
+
+
+
+ )}
+
+
+
+
+
+
+ Subtotal
+
+
+ {formatMoney(invoice.subtotalAmount, invoice.currency)}
+
+
+
+
+ Tax
+
+
+ {formatMoney(invoice.taxAmount, invoice.currency)}
+
+
+
+
+ Paid
+
+
+ {formatMoney(invoice.paidAmount, invoice.currency)}
+
+
+
+
+ Total
+
+
+ {formatMoney(invoice.totalAmount, invoice.currency)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx
new file mode 100644
index 000000000..7eca77ddf
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx
@@ -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[] = useMemo(
+ () => [
+ {
+ id: "invoiceNumber",
+ header: "Invoice",
+ cell: ({ row }) => (
+
+ {row.original.invoiceNumber}
+
+ ),
+ },
+ {
+ id: "billedTo",
+ header: "Billed to",
+ cell: ({ row }) => (
+
+ {row.original.company?.name ?? "—"}
+
+ ),
+ },
+ {
+ id: "source",
+ header: "Source",
+ cell: ({ row }) => (
+
+ {humanize(row.original.source)}
+
+ ),
+ },
+ {
+ id: "status",
+ header: "Status",
+ cell: ({ row }) => ,
+ },
+ {
+ id: "amount",
+ header: "Amount",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {formatMoney(row.original.totalAmount, row.original.currency)}
+
+ ),
+ },
+ {
+ id: "balance",
+ header: "Balance",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {formatMoney(row.original.balanceAmount, row.original.currency)}
+
+ ),
+ },
+ {
+ id: "dueAt",
+ header: "Due",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {formatDate(row.original.dueAt)}
+
+ ),
+ },
+ ],
+ [],
+ );
+
+ return (
+
+ void refetch()}
+ >
+
+
+ }
+ />
+
+
+
+
+
+ }
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ rightSection={
+ query ? (
+ setQuery("")}
+ >
+
+
+ ) : null
+ }
+ style={{ flex: 1, minWidth: "240px" }}
+ radius="lg"
+ />
+ {
+ 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" },
+ ]}
+ />
+
+ {total} record{total !== 1 ? "s" : ""}
+
+
+
+
+
+
+ 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}
+ />
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index d7a60179c..fc8eb162c 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -28,6 +28,11 @@ import type {
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
+import type {
+ Invoice,
+ InvoiceListFilter,
+ PaginatedInvoices,
+} from "@/types/invoice";
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
import {
RuleEngineListResult,
@@ -128,6 +133,7 @@ import {
import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
+import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import {
@@ -197,7 +203,10 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray = [
export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
- scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>(
+ scheduleList: endpoint<
+ { freightType?: FreightType },
+ TrainScheduleListItem[]
+ >(
"train-scheduling",
"schedules",
({ freightType }) => trainSchedulingService.listSchedules(freightType),
@@ -211,11 +220,16 @@ export const api = {
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
),
- batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>(
+ batchBoardDetail: endpoint<
+ { scheduleId: string },
+ BatchBoardScheduleDetail
+ >(
"train-scheduling",
"batch-board-detail",
- ({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId),
- ({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
+ ({ scheduleId }) =>
+ trainSchedulingService.getBatchBoardDetail(scheduleId),
+ ({ scheduleId }) =>
+ QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
),
scheduleDetail: endpoint<
@@ -344,7 +358,10 @@ export const api = {
),
// ── Mutations ──────────────────────────────────────────────────────────
- runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>(
+ runAllocation: endpoint<
+ { scheduleId: string },
+ WagonAllocationAttemptResult
+ >(
"train-scheduling",
"run-allocation",
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
@@ -454,7 +471,10 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
- pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>(
+ pinWagons: endpoint<
+ { id: string; payload: PinWagonsPayload },
+ TrainScheduleDetail
+ >(
"train-scheduling",
"pin-wagons",
({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
@@ -550,8 +570,10 @@ export const api = {
({ id }) => warehouseService.getById(id).then((r) => r.data),
),
- dashboard: endpoint("warehouses", "dashboard", () =>
- warehouseService.dashboard().then((r) => r.data),
+ dashboard: endpoint(
+ "warehouses",
+ "dashboard",
+ () => warehouseService.dashboard().then((r) => r.data),
),
create: endpoint(
@@ -642,10 +664,8 @@ export const api = {
listInventory: endpoint<
{ filter?: InventoryFilter },
WarehouseInventoryItem[]
- >(
- "warehouse-inventory",
- "list",
- ({ filter }) => warehouseService.listInventory(filter).then((r) => r.data),
+ >("warehouse-inventory", "list", ({ filter }) =>
+ warehouseService.listInventory(filter).then((r) => r.data),
),
inquiry: endpoint<
@@ -658,11 +678,19 @@ export const api = {
({ filter }) => ["warehouse-inventory", "inquiry", filter],
),
- eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
+ eligibleBookings: endpoint<
+ { direction?: "IMPORT" | "EXPORT" } | void,
+ EligibleBooking[]
+ >(
"warehouse-inventory",
"eligible-bookings",
- (input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
- (input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
+ (input) =>
+ warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
+ (input) => [
+ "warehouse-inventory",
+ "eligible-bookings",
+ input?.direction ?? "ALL",
+ ],
),
readyToLoadExport: endpoint(
@@ -698,7 +726,11 @@ export const api = {
"import-train-items",
({ scheduleId }) =>
warehouseService.importTrainItems(scheduleId).then((r) => r.data),
- ({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId],
+ ({ scheduleId }) => [
+ "warehouse-inventory",
+ "import-train-items",
+ scheduleId,
+ ],
),
importUnloadedQueue: endpoint(
@@ -766,8 +798,11 @@ export const api = {
"inspection-reports",
({ inventoryId }) =>
warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
- ({ inventoryId }) =>
- ["warehouse-inventory", inventoryId, "inspection-reports"],
+ ({ inventoryId }) => [
+ "warehouse-inventory",
+ inventoryId,
+ "inspection-reports",
+ ],
),
allocationRules: endpoint(
@@ -784,15 +819,28 @@ export const api = {
() => ["warehouse-fee-rules"],
),
- feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>(
+ feePreview: endpoint<
+ { inventoryId: string; billingCurrency?: "ETB" | "USD" },
+ FeePreview[]
+ >(
"warehouse-inventory",
"fee-preview",
({ inventoryId, billingCurrency }) =>
- warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data),
- ({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'],
+ warehouseService
+ .feePreview(inventoryId, billingCurrency)
+ .then((r) => r.data),
+ ({ inventoryId, billingCurrency }) => [
+ "warehouse-inventory",
+ inventoryId,
+ "fee-preview",
+ billingCurrency ?? "USD",
+ ],
),
- invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>(
+ invoices: endpoint<
+ { filter?: WarehouseInvoiceFilter },
+ WarehouseFeeInvoice[]
+ >(
"warehouse-fee-invoices",
"list",
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
@@ -821,7 +869,8 @@ export const api = {
receiveInventory: endpoint(
"warehouse-inventory",
"receive",
- (payload) => warehouseService.receiveInventory(payload).then((r) => r.data),
+ (payload) =>
+ warehouseService.receiveInventory(payload).then((r) => r.data),
undefined,
() => [["warehouse-inventory"], ["warehouses"]],
),
@@ -856,7 +905,8 @@ export const api = {
>(
"warehouse-inventory",
"load",
- ({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data),
+ ({ id, payload }) =>
+ warehouseService.load(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -875,7 +925,8 @@ export const api = {
>(
"warehouse-inventory",
"move",
- ({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data),
+ ({ id, payload }) =>
+ warehouseService.move(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -931,7 +982,8 @@ export const api = {
bulkMarkInspected: endpoint(
"warehouse-inventory",
"bulk-mark-inspected",
- (payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data),
+ (payload) =>
+ warehouseService.bulkMarkInspected(payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -949,7 +1001,12 @@ export const api = {
{
scheduleId: string;
warehouseId?: string;
- assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
+ assignments?: {
+ bookingId: string;
+ warehouseId: string;
+ yardId: string;
+ zoneId: string;
+ }[];
},
AutoUnloadArrivedResult
>(
@@ -1021,11 +1078,11 @@ export const api = {
),
// ── Allocation + fee rules ─────────────────────────────────────────────
- previewAllocation: endpoint(
- "warehouse-allocation-rules",
- "preview",
- (criteria) =>
- warehouseService.previewAllocation(criteria).then((r) => r.data),
+ previewAllocation: endpoint<
+ AllocationCriteria,
+ AllocationPreviewResult | null
+ >("warehouse-allocation-rules", "preview", (criteria) =>
+ warehouseService.previewAllocation(criteria).then((r) => r.data),
),
createAllocationRule: endpoint(
@@ -1087,7 +1144,11 @@ export const api = {
// ── Invoices ───────────────────────────────────────────────────────────
generateInvoice: endpoint<
- { inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' },
+ {
+ inventoryId: string;
+ confirmZero?: boolean;
+ billingCurrency?: "ETB" | "USD";
+ },
WarehouseFeeInvoice
>(
"warehouse-fee-invoices",
@@ -1143,7 +1204,10 @@ export const api = {
},
routes: {
- list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>(
+ list: endpoint<
+ { status?: import("./routes.service").RouteStatus } | void,
+ RouteRecord[]
+ >(
"routes",
"list",
(input) =>
@@ -1168,7 +1232,10 @@ export const api = {
() => [["routes"]],
),
- update: endpoint<{ id: string; data: Partial }, RouteRecord>(
+ update: endpoint<
+ { id: string; data: Partial },
+ RouteRecord
+ >(
"routes",
"update",
({ id, data }) => routesService.update(id, data).then((r) => r.data),
@@ -1281,10 +1348,8 @@ export const api = {
({ trainId }) => ["wagons", "train", trainId],
),
- getById: endpoint<{ id: string }, Wagon>(
- "wagons",
- "getById",
- ({ id }) => wagonService.getById(id).then((r) => r.data),
+ getById: endpoint<{ id: string }, Wagon>("wagons", "getById", ({ id }) =>
+ wagonService.getById(id).then((r) => r.data),
),
assignToTrain: endpoint<
@@ -1663,7 +1728,8 @@ export const api = {
>(
"file-upload-settings",
"addField",
- ({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto),
+ ({ settingId, dto }) =>
+ fileUploadSettingsService.addField(settingId, dto),
undefined,
() => [["file-upload-settings"]],
),
@@ -1762,7 +1828,8 @@ export const api = {
>(
"dropdown-settings",
"updateOption",
- ({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto),
+ ({ optionId, dto }) =>
+ dropdownSettingsService.updateOption(optionId, dto),
undefined,
() => [["dropdown-settings"]],
),
@@ -1815,11 +1882,10 @@ export const api = {
ruleEngineService.update(resource, id, payload),
),
- remove: endpoint<
- { resource: RuleEngineResourceSlug; id: string },
- void
- >("rule-engine", "remove", ({ resource, id }) =>
- ruleEngineService.remove(resource, id),
+ remove: endpoint<{ resource: RuleEngineResourceSlug; id: string }, void>(
+ "rule-engine",
+ "remove",
+ ({ resource, id }) => ruleEngineService.remove(resource, id),
),
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
@@ -1842,14 +1908,21 @@ export const api = {
),
reorder: endpoint<
- { resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } },
+ {
+ resource: RuleEngineResourceSlug;
+ payload: { ids: string[]; requiresDirectorApproval?: boolean };
+ },
void
>("rule-engine", "reorder", ({ resource, payload }) =>
ruleEngineService.reorder(resource, payload),
),
moveOrder: endpoint<
- { resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" },
+ {
+ resource: RuleEngineResourceSlug;
+ id: string;
+ direction: "up" | "down";
+ },
void
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
ruleEngineService.moveOrder(resource, id, direction),
@@ -1871,10 +1944,8 @@ export const api = {
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
),
- remove: endpoint<{ id: string }, void>(
- "bookings",
- "remove",
- ({ id }) => bookingsService.remove(id),
+ remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
+ bookingsService.remove(id),
),
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
@@ -1924,10 +1995,11 @@ export const api = {
({ id }) => bookingsService.generateContract(id),
),
- getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
- "bookings",
- "getContractView",
- ({ id }) => bookingsService.getContractView(id),
+ getContractView: endpoint<
+ { id: string },
+ import("./bookings.service").ContractView
+ >("bookings", "getContractView", ({ id }) =>
+ bookingsService.getContractView(id),
),
signContract: endpoint<
@@ -2011,7 +2083,8 @@ export const api = {
>(
"customers",
"setProfileStatus",
- ({ profileId, status }) => customersService.setProfileStatus(profileId, status),
+ ({ profileId, status }) =>
+ customersService.setProfileStatus(profileId, status),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
@@ -2032,6 +2105,22 @@ export const api = {
),
},
+ invoices: {
+ list: endpoint<{ filter: InvoiceListFilter }, PaginatedInvoices>(
+ "invoices",
+ "list",
+ ({ filter }) => invoicesService.list(filter),
+ ({ filter }) => QUERY_KEYS.INVOICES.list(filter),
+ ),
+
+ getById: endpoint<{ id: string }, Invoice>(
+ "invoices",
+ "getById",
+ ({ id }) => invoicesService.getById(id),
+ ({ id }) => QUERY_KEYS.INVOICES.byId(id),
+ ),
+ },
+
overview: {
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
"overview",
diff --git a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts
new file mode 100644
index 000000000..a4abc6ea2
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts
@@ -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 {
+ return apiClient
+ .get(URL_CONSTANTS.BILLING.INVOICES, {
+ params: cleanParams(filter),
+ })
+ .then((r) => r.data);
+ },
+
+ getById(id: string): Promise {
+ return apiClient
+ .get(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
+ .then((r) => r.data);
+ },
+
+ downloadDocument(id: string) {
+ return apiClient.get(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
+ responseType: "blob",
+ });
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts
new file mode 100644
index 000000000..26ecfcafa
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts
@@ -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;
+}
From 633c7c52c58e685bed08f4539b0a3b0ae2827c12 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 06:22:32 +0000
Subject: [PATCH 3/8] feat: add viewing existingFiles to smartfileinput
---
.../src/components/SmartFileInput/index.tsx | 68 ++++++++++++++++---
1 file changed, 58 insertions(+), 10 deletions(-)
diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx
index 978fb5498..d39e431c7 100644
--- a/packages/ui-common/src/components/SmartFileInput/index.tsx
+++ b/packages/ui-common/src/components/SmartFileInput/index.tsx
@@ -27,6 +27,16 @@ export interface SmartFileInputProps {
* no in-memory File is currently selected for them.
*/
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 }[]
+ >;
/** Disabled state for the entire file input group. */
disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
@@ -76,6 +86,7 @@ export function SmartFileInput({
onChange,
errors,
uploadedKeys,
+ existingFiles,
disabled = false,
variant = "default",
className,
@@ -270,9 +281,11 @@ export function SmartFileInput({
const fieldError =
errors?.[field.fileKey] || localErrors[field.fileKey];
const isDragOver = dragActive[field.fileKey];
+ const existingForField = existingFiles?.[field.fileKey] ?? [];
// Already uploaded server-side and nothing newly picked to replace it.
const isUploaded =
- (uploadedKeys?.includes(field.fileKey) ?? false) &&
+ ((uploadedKeys?.includes(field.fileKey) ?? false) ||
+ existingForField.length > 0) &&
currentFiles.length === 0;
// Format accepted files for the HTML input element
@@ -417,6 +430,21 @@ export function SmartFileInput({
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
+ {existingForField.length > 0 && (
+
+ {existingForField.map((f, idx) => (
+
+ {f.name}
+
+ ))}
+
+ )}
) : isUploaded ? (
// Uploaded state: a solid success panel that still doubles as a
@@ -431,7 +459,7 @@ export function SmartFileInput({
? "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",
disabled &&
- "opacity-50 pointer-events-none cursor-not-allowed",
+ "opacity-50 pointer-events-none cursor-not-allowed",
)}
>
{isDragOver ? "Drop to replace" : "Document uploaded"}
-
- {isDragOver
- ? "Release to replace the document on file."
- : "Saved to your application. Drag a new file here or click to replace it."}
-
+ {existingForField.length > 0 ? (
+
+ ) : (
+
+ {isDragOver
+ ? "Release to replace the document on file."
+ : "Saved to your application. Drag a new file here or click to replace it."}
+
+ )}
@@ -480,9 +528,9 @@ export function SmartFileInput({
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError &&
- "border-destructive hover:border-destructive/80",
+ "border-destructive hover:border-destructive/80",
disabled &&
- "opacity-50 pointer-events-none cursor-not-allowed",
+ "opacity-50 pointer-events-none cursor-not-allowed",
)}
>
Date: Fri, 3 Jul 2026 06:57:22 +0000
Subject: [PATCH 4/8] feat: extended the smartfileinput and add a global
useFileViewer
---
.../src/components/SmartFileInput/index.tsx | 105 ++++++++++++++----
.../ui-common/src/hooks/useFileViewer.tsx | 27 +++++
packages/ui-common/src/index.ts | 2 +
3 files changed, 113 insertions(+), 21 deletions(-)
create mode 100644 packages/ui-common/src/hooks/useFileViewer.tsx
diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx
index d39e431c7..0da3e2f37 100644
--- a/packages/ui-common/src/components/SmartFileInput/index.tsx
+++ b/packages/ui-common/src/components/SmartFileInput/index.tsx
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../button";
+import type { ViewableFile } from "../FileViewer";
export interface SmartFileInputProps {
/** The settings object containing features and their upload fields config. */
@@ -35,8 +36,18 @@ export interface SmartFileInputProps {
*/
existingFiles?: Record<
string,
- { name: string; url: string; size?: number }[]
+ { 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();
+ *
+ * {viewer}
+ */
+ onViewFile?: (file: ViewableFile) => void;
/** Disabled state for the entire file input group. */
disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
@@ -80,6 +91,68 @@ function FileIcon({ name, className }: { name: string; className?: string }) {
return ;
}
+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 (
+
+ );
+ }
+
+ return (
+ e.stopPropagation()}
+ className={cn(
+ "relative z-10 text-xs text-primary hover:underline truncate max-w-xs",
+ className,
+ )}
+ >
+ {label}
+
+ );
+}
+
export function SmartFileInput({
file,
value,
@@ -87,6 +160,7 @@ export function SmartFileInput({
errors,
uploadedKeys,
existingFiles,
+ onViewFile,
disabled = false,
variant = "default",
className,
@@ -433,15 +507,11 @@ export function SmartFileInput({
{existingForField.length > 0 && (
{existingForField.map((f, idx) => (
-
- {f.name}
-
+ file={f}
+ onViewFile={onViewFile}
+ />
))}
)}
@@ -488,19 +558,12 @@ export function SmartFileInput({
{existingForField.length > 0 ? (
) : (
diff --git a/packages/ui-common/src/hooks/useFileViewer.tsx b/packages/ui-common/src/hooks/useFileViewer.tsx
new file mode 100644
index 000000000..68269f7cf
--- /dev/null
+++ b/packages/ui-common/src/hooks/useFileViewer.tsx
@@ -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();
+ *
+ * {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(null);
+
+ const view = useCallback((f: ViewableFile) => setFile(f), []);
+ const close = useCallback(() => setFile(null), []);
+
+ const viewer = (
+
+ );
+
+ return { view, close, viewer };
+}
diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts
index 2e8779076..43c39d962 100644
--- a/packages/ui-common/src/index.ts
+++ b/packages/ui-common/src/index.ts
@@ -20,6 +20,8 @@ export type {
ViewableFile,
} from "./components/FileViewer";
+export { useFileViewer } from "./hooks/useFileViewer";
+
export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
From 300afb4c777fb0e43498e58e43ce2ccd2c57cb3f Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 06:57:56 +0000
Subject: [PATCH 5/8] feat: add existing files to customer documet tab on
portal
---
.../portal/src/hooks/useFileViewer.tsx | 27 +-
.../src/pages/settings/TabDocuments.tsx | 273 ++++++++++++------
.../portal/src/services/api.ts | 32 +-
.../portal/src/services/companies.service.ts | 40 ++-
4 files changed, 245 insertions(+), 127 deletions(-)
diff --git a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
index 4ca24476c..c00777ef7 100644
--- a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
+++ b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
@@ -1,24 +1,3 @@
-import { useCallback, useState } from "react";
-import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
-
-/**
- * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
- * from any file row to open the document inline (pdf / image / video / office /
- * text); render `viewer` once near the page root.
- *
- * const { view, viewer } = useFileViewer();
- *
- * {viewer}
- */
-export function useFileViewer() {
- const [file, setFile] = useState(null);
-
- const view = useCallback((f: ViewableFile) => setFile(f), []);
- const close = useCallback(() => setFile(null), []);
-
- const viewer = (
-
- );
-
- return { view, close, viewer };
-}
+// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
+// `@/hooks/useFileViewer` imports keep working.
+export { useFileViewer } from "@edr/ui-common";
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
index 4d5c4b759..6fbcbe911 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
@@ -4,10 +4,12 @@ import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common";
import {
+ Anchor,
Button,
Card,
Center,
Group,
+ Stack,
Text,
Title,
} from "@mantine/core";
@@ -17,10 +19,19 @@ import {
CheckCircle2,
FileCheck,
Loader2,
+ Paperclip,
UploadCloud,
XCircle,
} from "lucide-react";
-import { useState } from "react";
+import { useMemo, useState } from "react";
+
+const ROLE_LABELS: Record = {
+ importer: "Importer",
+ exporter: "Exporter",
+ freight_forwarder: "Freight Forwarder",
+ dj_freight_forwarder: "DJ Freight Forwarder",
+ transporter: "Transporter",
+};
interface TabDocumentsProps {
profile: ProfileResponse;
@@ -28,21 +39,59 @@ interface TabDocumentsProps {
onContinue?: () => void;
}
-export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
+function documentSettingCode(nationality: string | null | undefined): string {
+ return nationality === "foreign"
+ ? "company_onboarding_documents_foreign"
+ : "company_onboarding_documents_ethiopian";
+}
+
+export default function TabDocuments({
+ profile,
+ mode = "edit",
+ onContinue,
+}: TabDocumentsProps) {
const queryClient = useQueryClient();
- const [documentFiles, setDocumentFiles] = useState>({});
+ const [documentFiles, setDocumentFiles] = useState<
+ Record
+ >({});
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
- input: { code: "customer_file_documents" },
+ input: { code: documentSettingCode(profile.nationality) },
}),
);
+ const docsQuery = useQuery(
+ api.companies.documents.queryOptions({
+ input: { companyId: profile.companyId },
+ }),
+ );
+
+ const uploadedKeys = useMemo(
+ () => (docsQuery.data ?? []).map((d) => d.code),
+ [docsQuery.data],
+ );
+
+ const existingFilesByKey = useMemo(() => {
+ const map: Record =
+ {};
+ for (const doc of docsQuery.data ?? []) {
+ (map[doc.code] ??= []).push({
+ name: doc.name,
+ url: doc.url,
+ size: doc.size,
+ });
+ }
+ return map;
+ }, [docsQuery.data]);
+
const docUploadMutation = useMutation({
mutationFn: (files: Record) =>
companiesService.uploadDocuments(profile.companyId, files),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
},
});
@@ -73,6 +122,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
+ if (uploadedKeys.includes(field.fileKey)) continue;
const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) {
@@ -82,94 +132,141 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
return errs;
};
+ const licenseProfiles = profile.companyProfiles.filter(
+ (p) => p.licenseFiles && p.licenseFiles.length > 0,
+ );
+
return (
-
-
-
- Documents
-
-
- Upload and manage required business documents
-
-
- {docSettingQuery.isLoading ? (
-
-
-
- ) : !docSettingQuery.data ? (
-
- No document requirements configured for your account.
+ <>
+
+
+
+ Documents
+
+
+ Upload and manage required business documents
- ) : (
-
- )}
- {docSettingQuery.data && (
-
-
- {docUploadMutation.isSuccess && (
-
-
-
- {mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
-
-
- )}
- {docUploadMutation.isError && (
-
-
- Upload failed
-
+ {docSettingQuery.isLoading ? (
+
+
+
+ ) : !docSettingQuery.data ? (
+
+ No document requirements configured for your account.
+
+ ) : (
+
+ )}
+
+ {docSettingQuery.data && (
+
+
+ {docUploadMutation.isSuccess && (
+
+
+
+ {mode === "onboarding"
+ ? "Saved successfully"
+ : "Documents uploaded successfully"}
+
+
+ )}
+ {docUploadMutation.isError && (
+
+
+
+ Upload failed
+
+
+ )}
+
+ {mode === "onboarding" ? (
+ }
+ 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
+
+ ) : (
+ }
+ loading={docUploadMutation.isPending}
+ disabled={!hasFiles}
+ onClick={() => {
+ if (!hasFiles) return;
+ docUploadMutation.mutate(documentFiles);
+ }}
+ >
+ Upload Documents
+
)}
- {mode === "onboarding" ? (
- }
- 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
-
- ) : (
- }
- loading={docUploadMutation.isPending}
- disabled={!hasFiles}
- onClick={() => {
- if (!hasFiles) return;
- docUploadMutation.mutate(documentFiles);
- }}
- >
- Upload Documents
-
- )}
-
+ )}
+
+
+ {licenseProfiles.length > 0 && (
+
+
+
+ Business licenses
+
+
+ License documents uploaded per operational profile
+
+
+
+ {licenseProfiles.map((p) => (
+
+
+ {ROLE_LABELS[p.type] ?? p.type} · {p.reference}
+
+ {p.licenseFiles.map((f) => (
+
+
+
+ {f.name}
+
+
+ ))}
+
+ ))}
+
+
)}
-
+ >
);
}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index a09bd4b4e..1ba83ec30 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -53,6 +53,7 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import type {
+ CompanyDocument,
CompanyInfoResponse,
CompanyNationality,
CompanyProfileResponse,
@@ -158,7 +159,11 @@ export const api = {
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string },
CompanyProfileResponse
- >("companies", "createCompanyProfile", companiesService.createCompanyProfile),
+ >(
+ "companies",
+ "createCompanyProfile",
+ companiesService.createCompanyProfile,
+ ),
startOnboarding: endpoint<
{
@@ -192,6 +197,12 @@ export const api = {
"onboardingRequirements",
companiesService.getOnboardingRequirements,
),
+
+ documents: endpoint<{ companyId: string }, CompanyDocument[]>(
+ "companies",
+ "documents",
+ ({ companyId }) => companiesService.getDocuments(companyId),
+ ),
},
bookings: {
@@ -228,7 +239,8 @@ export const api = {
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
"bookings",
"downloadHandoverDocument",
- ({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
+ ({ inventoryId }) =>
+ bookingsService.downloadHandoverDocument(inventoryId),
),
create: endpoint<
@@ -312,11 +324,8 @@ export const api = {
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
Freight.IBooking
- >(
- "bookings",
- "proceedToOperation",
- ({ id, scheduledDate }) =>
- bookingsService.proceedToOperation(id, scheduledDate),
+ >("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
+ bookingsService.proceedToOperation(id, scheduledDate),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(
@@ -354,10 +363,11 @@ export const api = {
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
- getAvailableDaysForCargo: endpoint(
- "train-scheduling",
- "availableDaysForCargo",
- (input) => bookingsService.getAvailableDaysForCargo(input),
+ getAvailableDaysForCargo: endpoint<
+ Freight.AvailableDaysForCargoQuery,
+ string[]
+ >("train-scheduling", "availableDaysForCargo", (input) =>
+ bookingsService.getAvailableDaysForCargo(input),
),
getMyBookingWindows: endpoint(
diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts
index d326811b8..d3f584170 100644
--- a/apps/edr-freight-web/portal/src/services/companies.service.ts
+++ b/apps/edr-freight-web/portal/src/services/companies.service.ts
@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
+/** A single company-level document uploaded against a `file_upload_settings` field. */
+export interface CompanyDocument {
+ id: string;
+ name: string;
+ /** The `fileKey` of the setting field it was uploaded against. */
+ code: string;
+ mimeType: string;
+ size: number;
+ uploadedAt: string;
+ url: string;
+}
+
/** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField {
fileKey: string;
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
}
export interface CompanyProfileInput {
- type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
+ type:
+ | "importer"
+ | "exporter"
+ | "freight_forwarder"
+ | "dj_freight_forwarder"
+ | "transporter";
businessLicense?: string;
}
@@ -180,7 +197,9 @@ export const companiesService = {
}
},
- create: async (payload: CreateCompanyPayload): Promise => {
+ create: async (
+ payload: CreateCompanyPayload,
+ ): Promise => {
const response = await client.post>(
URL_CONSTANTS.COMPANIES_API.CREATE,
payload,
@@ -195,7 +214,9 @@ export const companiesService = {
return unwrap(response.data);
},
- updateProfile: async (payload: UpdateProfilePayload): Promise => {
+ updateProfile: async (
+ payload: UpdateProfilePayload,
+ ): Promise => {
const response = await client.patch>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
payload,
@@ -293,7 +314,18 @@ export const companiesService = {
formData.append(fieldName, fileOrFiles);
}
}
- await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
+ await client.post(
+ URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
+ formData,
+ );
+ },
+
+ /** List documents already uploaded for a company (settings-driven, by fileKey). */
+ getDocuments: async (companyId: string): Promise => {
+ const response = await client.get>(
+ URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
+ );
+ return unwrap(response.data);
},
/** Upload business-license document(s) for a company profile (multi-file). */
From 43f7a039ad9e204dc9e7081c66b8e07c5d4e4b22 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 07:20:31 +0000
Subject: [PATCH 6/8] fix: payment api crash
---
apps/edr-freight-api/src/modules/payment/payment.repository.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts
index 25c3bdd6b..96a4994ea 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts
@@ -93,9 +93,8 @@ export class PaymentRepository {
p.paid_at,
p.created_at
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
- AND p.deleted_at IS NULL
AND b.deleted_at IS NULL
ORDER BY p.created_at DESC`,
[companyId],
From 3925a11dd4ccdd1548cac480136d91721fd7441c Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 07:21:19 +0000
Subject: [PATCH 7/8] feat: add invoice to customer detail on backoffice
---
.../src/components/customers/badges.tsx | 64 +++++-
.../src/components/customers/index.ts | 1 +
.../backoffice/src/hooks/useFileViewer.tsx | 27 +--
.../pages/customers/CustomerDetailPage.tsx | 209 +++++++++++++++---
.../backoffice/src/types/customer.ts | 11 +
5 files changed, 256 insertions(+), 56 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
index e108d1b1f..31c7efc52 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
@@ -1,3 +1,4 @@
+import type { Freight } from "@edr/types";
import { Badge, Button, Group, Tooltip } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -88,7 +89,13 @@ export function ProfileChips({
}) {
if (!profiles.length) {
return (
-
+
No profiles
);
@@ -118,7 +125,13 @@ export function ProfileChips({
))}
{extra > 0 ? (
-
+
+{extra}
) : null}
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record = {
CANCELLED: "red",
};
-export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
+export function BookingStatusBadge({
+ status,
+}: {
+ status: CustomerBookingStatus;
+}) {
return (
= {
refunded: "grape",
};
-export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
+export function PaymentStatusBadge({
+ status,
+}: {
+ status: CustomerPaymentStatus;
+}) {
return (
= {
+ 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 (
+
+ {humanize(status)}
+
+ );
+}
+
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
api.customers.setProfileStatus.mutationOptions(),
);
- const act = (next: ProfileStatus) =>
- mutate({ profileId, status: next });
+ const act = (next: ProfileStatus) => mutate({ profileId, status: next });
if (status === "pending") {
return (
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
index 61b75767a..620b5ec35 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -2,6 +2,7 @@ export {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
+ InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
index 4ca24476c..c00777ef7 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
+++ b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
@@ -1,24 +1,3 @@
-import { useCallback, useState } from "react";
-import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
-
-/**
- * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
- * from any file row to open the document inline (pdf / image / video / office /
- * text); render `viewer` once near the page root.
- *
- * const { view, viewer } = useFileViewer();
- *
- * {viewer}
- */
-export function useFileViewer() {
- const [file, setFile] = useState(null);
-
- const view = useCallback((f: ViewableFile) => setFile(f), []);
- const close = useCallback(() => setFile(null), []);
-
- const viewer = (
-
- );
-
- return { view, close, viewer };
-}
+// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
+// `@/hooks/useFileViewer` imports keep working.
+export { useFileViewer } from "@edr/ui-common";
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index 5689186cd..f94f97c71 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -1,5 +1,6 @@
import {
ActionIcon,
+ Anchor,
Box,
Button,
Card,
@@ -21,6 +22,8 @@ import {
IdCard,
LayoutGrid,
Package,
+ Paperclip,
+ Receipt,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
@@ -30,6 +33,7 @@ import {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
+ InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
@@ -50,7 +54,8 @@ import type {
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
-import { DataTable, type ColumnDef } from "@edr/ui-common";
+import type { Invoice } from "@/types/invoice";
+import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
@@ -104,9 +109,34 @@ export default function CustomerDetailPage() {
}),
);
+ const { pagination: invoicePagination, setPagination: setInvoicePagination } =
+ usePagination({
+ pageSize: 10,
+ });
+ const invoiceFilter = useMemo(
+ () => ({
+ companyId: id ?? "",
+ page: invoicePagination.pageIndex + 1,
+ pageSize: invoicePagination.pageSize,
+ }),
+ [id, invoicePagination.pageIndex, invoicePagination.pageSize],
+ );
+ const invoicesQuery = useQuery(
+ api.invoices.list.queryOptions({
+ input: { filter: invoiceFilter },
+ enabled: Boolean(id),
+ }),
+ );
+
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
+ const invoices = invoicesQuery.data?.items ?? [];
+ const invoiceTotal = invoicesQuery.data?.total ?? 0;
+ const invoicePageCount = Math.max(
+ 1,
+ Math.ceil(invoiceTotal / invoicePagination.pageSize),
+ );
const totalPaid = useMemo(
() =>
@@ -358,6 +388,59 @@ export default function CustomerDetailPage() {
[],
);
+ const invoiceColumns: ColumnDef[] = useMemo(
+ () => [
+ {
+ id: "invoiceNumber",
+ header: "Invoice",
+ cell: ({ row }) => (
+
+ {row.original.invoiceNumber}
+
+ ),
+ },
+ {
+ id: "source",
+ header: "Source",
+ cell: ({ row }) => (
+
+ {humanize(row.original.source)}
+
+ ),
+ },
+ {
+ id: "status",
+ header: "Status",
+ cell: ({ row }) => ,
+ },
+ {
+ id: "amount",
+ header: "Amount",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {formatMoney(row.original.totalAmount, row.original.currency)}
+
+ ),
+ },
+ {
+ id: "dueAt",
+ header: "Due",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {formatDate(row.original.dueAt)}
+
+ ),
+ },
+ ],
+ [],
+ );
+
+ const licenseProfiles = (company?.companyProfiles ?? []).filter(
+ (p) => p.licenseFiles && p.licenseFiles.length > 0,
+ );
+
if (isLoading) {
return (
@@ -392,8 +475,9 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
- subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
- }`}
+ subtitle={`TIN ${company.tin}${
+ company.country ? ` · ${company.country}` : ""
+ }`}
meta={
@@ -416,6 +500,9 @@ export default function CustomerDetailPage() {
}>
Payments
+ }>
+ Invoices
+
{/* OVERVIEW */}
@@ -528,9 +615,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
- message: "Failed to load bookings.",
- onRetry: () => void bookingsQuery.refetch(),
- }
+ message: "Failed to load bookings.",
+ onRetry: () => void bookingsQuery.refetch(),
+ }
: undefined
}
/>
@@ -539,23 +626,57 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
-
- void documentsQuery.refetch(),
- }
- : undefined
- }
- />
-
+
+
+ void documentsQuery.refetch(),
+ }
+ : undefined
+ }
+ />
+
+
+ {licenseProfiles.length > 0 && (
+
+
+
+ Business licenses
+
+
+ {licenseProfiles.map((p) => (
+
+
+ {humanize(p.type)} · {p.reference}
+
+ {(p.licenseFiles ?? []).map((f) => (
+
+
+
+ {f.name}
+
+
+ ))}
+
+ ))}
+
+
+
+ )}
+
{/* PAYMENTS */}
@@ -570,14 +691,50 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
- message: "Failed to load payments.",
- onRetry: () => void paymentsQuery.refetch(),
- }
+ message: "Failed to load payments.",
+ onRetry: () => void paymentsQuery.refetch(),
+ }
: undefined
}
/>
+
+ {/* INVOICES */}
+
+
+
+ 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,
+ }}
+ />
+
+
+
);
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index 638ab98cd..a67f1732f 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -32,6 +32,14 @@ export type ProfileType =
/** Mirrors backend `ProfileStatus`. */
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
+/** A business-license document uploaded for a company profile. */
+export interface LicenseFile {
+ name: string;
+ url: string;
+ size: number;
+ mimeType?: string;
+}
+
/** A single role a company is registered for, with its reference code. */
export interface CompanyProfile {
id: string;
@@ -39,7 +47,10 @@ export interface CompanyProfile {
type: ProfileType;
reference: string;
status: ProfileStatus;
+ /** @deprecated Superseded by licenseFiles (file model). */
businessLicense?: string | null;
+ /** Business-license documents uploaded for this profile. */
+ licenseFiles?: LicenseFile[];
attributes?: Record | null;
createdAt: string;
updatedAt: string;
From 515d59fb532abc7df52a1b471659f1f749e8dde0 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 08:35:57 +0000
Subject: [PATCH 8/8] fix: file preview
---
.../pages/customers/CustomerDetailPage.tsx | 62 ++++++++++++++-----
.../src/pages/settings/TabDocuments.tsx | 25 ++++----
2 files changed, 61 insertions(+), 26 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index f94f97c71..7f62ea3f5 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -18,6 +18,7 @@ import {
ArrowRight,
Banknote,
Download,
+ Eye,
FileText,
IdCard,
LayoutGrid,
@@ -55,7 +56,12 @@ import type {
CustomerPayment,
} from "@/types/customer";
import type { Invoice } from "@/types/invoice";
-import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
+import {
+ DataTable,
+ useFileViewer,
+ usePagination,
+ type ColumnDef,
+} from "@edr/ui-common";
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
@@ -83,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const { view, viewer } = useFileViewer();
const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({
@@ -315,20 +322,37 @@ export default function CustomerDetailPage() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
-
-
-
+
+
+ view({
+ name: row.original.name,
+ url: fileViewUrl(row.original.id),
+ mimeType: row.original.mimeType,
+ })
+ }
+ >
+
+
+
+
+
+
),
},
],
- [],
+ [view],
);
const paymentColumns: ColumnDef[] = useMemo(
@@ -661,9 +685,15 @@ export default function CustomerDetailPage() {
+ view({
+ name: f.name,
+ url: f.url,
+ mimeType: f.mimeType,
+ })
+ }
size="xs"
>
{f.name}
@@ -736,6 +766,8 @@ export default function CustomerDetailPage() {
+
+ {viewer}
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
index 6fbcbe911..b904d7eba 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
@@ -1,8 +1,9 @@
+import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
-import { SmartFileInput } from "@edr/ui-common";
+import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import {
Anchor,
Button,
@@ -51,6 +52,7 @@ export default function TabDocuments({
onContinue,
}: TabDocumentsProps) {
const queryClient = useQueryClient();
+ const { view, viewer } = useFileViewer();
const [documentFiles, setDocumentFiles] = useState<
Record
>({});
@@ -73,13 +75,16 @@ export default function TabDocuments({
);
const existingFilesByKey = useMemo(() => {
- const map: Record =
- {};
+ 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: doc.url,
+ url: fileViewUrl(doc.id),
size: doc.size,
+ mimeType: doc.mimeType,
});
}
return map;
@@ -163,6 +168,7 @@ export default function TabDocuments({
errors={fieldErrors}
uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey}
+ onViewFile={view}
/>
)}
@@ -252,14 +258,9 @@ export default function TabDocuments({
{p.licenseFiles.map((f) => (
-
+
{f.name}
-
+
))}
@@ -267,6 +268,8 @@ export default function TabDocuments({
)}
+
+ {viewer}
>
);
}