diff --git a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts index 854604c45..51e99614a 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts @@ -109,6 +109,15 @@ export const contractsDataset: ExportDataset = { { key: 'contractKind', label: 'Kind', type: 'text' }, { key: 'tradeDirection', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) }, { key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) }, + { key: 'paymentCurrency', label: 'Currency', type: 'select', options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ] }, + { key: 'serviceTypeId', label: 'Service type', type: 'text' }, + // Routes are one-to-many on contract_routes, so these filter via EXISTS + // rather than a column comparison. + { key: 'originYardId', label: 'Origin', type: 'text' }, + { key: 'destinationYardId', label: 'Destination', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search reference or customer', type: 'text' }, ], @@ -125,6 +134,24 @@ export const contractsDataset: ExportDataset = { if (params.contractKind) qb.andWhere('ct.contract_kind = :contractKind', { contractKind: params.contractKind }); if (params.tradeDirection) qb.andWhere('ct.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); if (params.freightType) qb.andWhere('ct.freight_type = :freightType', { freightType: params.freightType }); + if (params.paymentCurrency) qb.andWhere('ct.payment_currency = :paymentCurrency', { paymentCurrency: params.paymentCurrency }); + if (params.serviceTypeId) qb.andWhere('ct.service_type_id = :serviceTypeId', { serviceTypeId: params.serviceTypeId }); + if (params.originYardId) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.contract_routes cr + WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL + AND cr.origin_yard_id = :originYardId)`, + { originYardId: params.originYardId }, + ); + } + if (params.destinationYardId) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.contract_routes cr2 + WHERE cr2.contract_id = ct.id AND cr2.deleted_at IS NULL + AND cr2.destination_yard_id = :destinationYardId)`, + { destinationYardId: params.destinationYardId }, + ); + } if (params.companyId) qb.andWhere('ct.company_id = :companyId', { companyId: params.companyId }); if (params.search) { qb.andWhere('(ct.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index c98ca684f..5eb0986b5 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -98,6 +98,9 @@ export const invoicesDataset: ExportDataset = { filters: [ { key: 'issued', label: 'Issued', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect' }, + // The invoices list page sends a single `status`; accept both so its + // on-screen filter actually carries into the export. + { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, @@ -115,6 +118,7 @@ export const invoicesDataset: ExportDataset = { if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo }); const statuses = params.statuses as string[] | null; if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); + if (params.status) qb.andWhere('i.status = :status', { status: params.status }); if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency }); if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId }); if (params.search) { diff --git a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts index 5402e7917..bb31591da 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts @@ -101,6 +101,9 @@ export const trainSchedulesDataset: ExportDataset = { { key: 'departure', label: 'Departure', type: 'daterange' }, { key: 'status', label: 'Status', type: 'text' }, { key: 'direction', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) }, + // freightType is derived from the bookings aboard, so it filters via + // EXISTS — the same shape the list service's scheduleFreightTypeFilter uses. + { key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) }, { key: 'originStationId', label: 'Origin', type: 'text' }, { key: 'destinationStationId', label: 'Destination', type: 'text' }, { key: 'search', label: 'Search reference or train number', type: 'text' }, @@ -115,6 +118,14 @@ export const trainSchedulesDataset: ExportDataset = { if (params.departureTo) qb.andWhere('sch.scheduled_departure_date < :departureTo', { departureTo: params.departureTo }); if (params.status) qb.andWhere('sch.status = :status', { status: params.status }); if (params.direction) qb.andWhere('sch.direction = :direction', { direction: params.direction }); + if (params.freightType) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.bookings fb + WHERE fb.train_schedule_id = sch.id AND fb.deleted_at IS NULL + AND fb.freight_type = :freightType)`, + { freightType: params.freightType }, + ); + } if (params.originStationId) qb.andWhere('sch.origin_station_id = :originStationId', { originStationId: params.originStationId }); if (params.destinationStationId) qb.andWhere('sch.destination_station_id = :destinationStationId', { destinationStationId: params.destinationStationId }); if (params.search) { diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index aa8b5ed8f..4f761c6f0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -53,6 +53,7 @@ import { type ColumnDef, } from "@edr/ui-common"; import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters"; +import { ExportButton } from "@/components/export/ExportButton"; /** Every filterable status — the pill tabs are gone, so the select carries them all. */ const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map( @@ -468,7 +469,9 @@ export default function ContractRequestsPage() { searchPlaceholder="Search reference or customer…" sortOptions={SORT_OPTIONS} viewId="contract-requests" - /> + > + + {showEmpty ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 91bb72fdc..552d4c61b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -37,6 +37,7 @@ import type { Company, CompanyStatus } from "@/types/customer"; import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; import { FilterBar, useFilters, type FilterDef } from "@/components/filters"; +import { ExportButton } from "@/components/export/ExportButton"; /** * The list's segmented views. "Pending approval" means submitted-and-awaiting- @@ -313,6 +314,7 @@ export default function CustomersPage() { { label: "Active", value: "active" }, ]} /> + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 23dc6b76b..50f3fe26c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -42,6 +42,7 @@ import { } from "@/services/fleet/fleet.service"; import { DataTable, DataTableFooter } from "@edr/ui-common"; import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters"; +import { ExportButton } from "@/components/export/ExportButton"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; @@ -621,6 +622,9 @@ const FleetResourcePage = () => { ]} styles={{ root: { background: "var(--mantine-color-gray-1)" } }} /> + {config.exportKey ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 526181d44..e73ebe8f1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -119,6 +119,11 @@ export interface FleetResourceConfig { supportsSearch: boolean; /** Server-side list filters (e.g. wagon status / readiness). */ listFilters?: FleetListFilterDef[]; + /** + * Export dataset key for this resource. Omitted where no dataset exists yet, + * in which case the page renders no export button. + */ + exportKey?: string; columns: FleetResourceColumn[]; formFields: FleetFormFieldDef[]; emptyValues: Record; @@ -182,6 +187,7 @@ const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter( export const FLEET_RESOURCES: FleetResourceConfig[] = [ { slug: "locomotives", + exportKey: "locomotives", label: "Locomotives", subtitle: "Manage locomotive master data used by train scheduling and fleet operations", basePath: "/dashboard/locomotives", @@ -249,6 +255,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ }, { slug: "trains", + exportKey: "trains", label: "Trains", subtitle: "Manage train master data independently from train scheduling", basePath: "/dashboard/trains", @@ -292,6 +299,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ }, { slug: "wagons", + exportKey: "wagons", label: "Wagons", subtitle: "Manage wagon master data. Operational scheduling uses train schedules separately", basePath: "/dashboard/wagons", diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 159157e2f..a8211f73f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -30,6 +30,7 @@ import { } from "@/components/customers"; import { KpiStrip } from "@/components/page"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; +import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; import type { Invoice } from "@/types/invoice"; @@ -247,6 +248,7 @@ export default function InvoicesPanel() { style={{ flex: 1, minWidth: "240px" }} radius="lg" /> + +