Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-13 14:14:31 +00:00
219 changed files with 13585 additions and 5559 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Navigate,
Outlet,
@@ -10,6 +11,7 @@ import {
} from "react-router-dom";
import { FreightDashboardLayout, type SidebarItem } from "@/components/layout";
import { api } from "@/services/api";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
@@ -36,15 +38,13 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import InvoicesPage from "./pages/invoices/InvoicesPage";
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS } from "./lib/permissions";
@@ -53,6 +53,7 @@ import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
import LogoSettingsPage from "./pages/settings/LogoSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -82,7 +83,6 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
@@ -140,8 +140,18 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = [];
const { data: reportCatalog } = useQuery(api.reports.catalog.queryOptions());
const reportItems: SidebarItem[] = useMemo(
() =>
(reportCatalog ?? []).map((report) => ({
label: report.title,
href: `/dashboard/reports/${report.key}`,
})),
[reportCatalog],
);
const sidebarSections = filterSidebarByPermission(
buildSidebarSections(demoItems),
buildSidebarSections(demoItems, reportItems),
user,
);
const displayName = user?.name?.en || user?.username || user?.email || "User";
@@ -200,12 +210,43 @@ const App = () => {
{/* Landing is per-user: /dashboard/overview is gated on overview:view, so
a fixed target strands anyone without that key on a blank page. */}
<Route path="/" element={<Navigate to={landingPath} replace />} />
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
<Route
path="/dashboard"
element={<Navigate to={landingPath} replace />}
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
<Route path="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
<Route
path="overview"
element={
<RequirePermission permission={FREIGHT_PERMS.overview.view}>
<OverviewPage />
</RequirePermission>
}
/>
<Route
path="reports"
element={
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
<ReportsIndexRedirect />
</RequirePermission>
}
/>
<Route
path="reports/:reportKey"
element={
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
<ReportPage />
</RequirePermission>
}
/>
<Route
path="audit-logs"
element={
<RequirePermission permission={FREIGHT_PERMS.auditLog.view}>
<AuditLogsPage />
</RequirePermission>
}
/>
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"
@@ -217,16 +258,28 @@ const App = () => {
/>
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><BookingRequestsPage /></RequirePermission>} />
<Route
path="payments"
path="booking-requests"
element={
<RequirePermission permission={FREIGHT_PERMS.payments.view}>
<PaymentsPage />
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingRequestsPage />
</RequirePermission>
}
/>
{/* Payments used to be its own page; it's now the "payments" tab on
the merged Invoices hub. Old bookmarks/links still land there. */}
<Route
path="payments"
element={<Navigate to="/dashboard/invoices?tab=payments" replace />}
/>
<Route
path="support"
element={
<RequirePermission permission={FREIGHT_PERMS.support.agentView}>
<SupportInboxPage />
</RequirePermission>
}
/>
<Route path="support" element={<RequirePermission permission={FREIGHT_PERMS.support.agentView}><SupportInboxPage /></RequirePermission>} />
<Route
path="customers"
element={
@@ -243,6 +296,11 @@ const App = () => {
</RequirePermission>
}
/>
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
<Route
path="shipping-lines"
element={
@@ -254,18 +312,19 @@ const App = () => {
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoicesPage />
<RequirePermission
permission={[
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.payments.view,
]}
>
<FinanceHubPage />
</RequirePermission>
}
/>
<Route
path="usd-payments"
element={
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<UsdPaymentsPage />
</RequirePermission>
}
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
/>
<Route
path="invoices/:id"
@@ -275,7 +334,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
<Route
path="booking-requests/new"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<NewBookingPage />
</RequirePermission>
}
/>
<Route
path="wagon-cancellations"
element={
@@ -484,25 +550,195 @@ const App = () => {
path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />}
/>
<Route path="warehouses" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseListPage /></RequirePermission>} />
<Route path="warehouses/:id" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseDetailPage /></RequirePermission>} />
<Route path="warehouse-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><WarehouseInventoryPage /></RequirePermission>} />
<Route path="import-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportWarehouseFlowPage /></RequirePermission>} />
<Route path="export-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportWarehouseFlowPage /></RequirePermission>} />
<Route path="arrival-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ArrivalQueuePage /></RequirePermission>} />
<Route path="loading-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadingQueuePage /></RequirePermission>} />
<Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
<Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
<Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
<Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
<Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
<Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />
<Route path="export-djibouti-unloading" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportDjiboutiUnloadingQueuePage /></RequirePermission>} />
<Route path="interchange-documents" element={<RequirePermission permission={FREIGHT_PERMS.interchangeDocuments.view}><InterchangeDocumentsPage /></RequirePermission>} />
<Route path="inventory-inquiry" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><InventoryInquiryPage /></RequirePermission>} />
<Route path="warehouse-rules" element={<RequirePermission permission={[FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view]}><WarehouseRulesPage /></RequirePermission>} />
<Route path="warehouse-fee-invoices" element={<RequirePermission permission={FREIGHT_PERMS.warehouseFeeInvoices.view}><WarehouseInvoicesPage /></RequirePermission>} />
<Route path="warehouse-dashboard" element={<RequirePermission permission={FREIGHT_PERMS.warehouseDashboard.view}><WarehouseDashboardPage /></RequirePermission>} />
<Route
path="warehouses"
element={
<RequirePermission permission={FREIGHT_PERMS.warehouses.view}>
<WarehouseListPage />
</RequirePermission>
}
/>
<Route
path="warehouses/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.warehouses.view}>
<WarehouseDetailPage />
</RequirePermission>
}
/>
<Route
path="warehouse-inventory"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<WarehouseInventoryPage />
</RequirePermission>
}
/>
<Route
path="import-warehouse"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ImportWarehouseFlowPage />
</RequirePermission>
}
/>
<Route
path="export-warehouse"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ExportWarehouseFlowPage />
</RequirePermission>
}
/>
<Route
path="arrival-queue"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ArrivalQueuePage />
</RequirePermission>
}
/>
<Route
path="loading-queue"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<LoadingQueuePage />
</RequirePermission>
}
/>
<Route
path="intercity"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<IntercityPage />
</RequirePermission>
}
/>
<Route
path="trucks-on-site"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<TrucksOnSitePage />
</RequirePermission>
}
/>
<Route
path="import-trucks"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ImportTrucksPage />
</RequirePermission>
}
/>
<Route
path="container-returns"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ContainerReturnsPage />
</RequirePermission>
}
/>
<Route
path="loaded-inventory"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<LoadedInventoryPage />
</RequirePermission>
}
/>
<Route
path="dispatch-queue"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<DispatchQueuePage />
</RequirePermission>
}
/>
<Route
path="export-djibouti-unloading"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ExportDjiboutiUnloadingQueuePage />
</RequirePermission>
}
/>
<Route
path="interchange-documents"
element={
<RequirePermission
permission={FREIGHT_PERMS.interchangeDocuments.view}
>
<InterchangeDocumentsPage />
</RequirePermission>
}
/>
<Route
path="inventory-inquiry"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<InventoryInquiryPage />
</RequirePermission>
}
/>
<Route
path="warehouse-rules"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
]}
>
<WarehouseRulesPage />
</RequirePermission>
}
/>
<Route
path="warehouse-fee-invoices"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseFeeInvoices.view}
>
<WarehouseInvoicesPage />
</RequirePermission>
}
/>
<Route
path="warehouse-dashboard"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseDashboard.view}
>
<WarehouseDashboardPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
@@ -781,7 +1017,9 @@ const App = () => {
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.fileUpload.view}>
<RequirePermission
permission={FREIGHT_PERMS.settings.fileUpload.view}
>
<FileUploadSettingsPage />
</RequirePermission>
}
@@ -789,7 +1027,9 @@ const App = () => {
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.dropdown.view}>
<RequirePermission
permission={FREIGHT_PERMS.settings.dropdown.view}
>
<DropdownSettingsPage />
</RequirePermission>
}
@@ -804,9 +1044,7 @@ const App = () => {
<Route
path="stamp-settings"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.stamp.view}
>
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
<CompanyStampSettingsPage />
</RequirePermission>
}
@@ -816,6 +1054,15 @@ const App = () => {
path="invoice-stamp-settings"
element={<Navigate to="/dashboard/stamp-settings" replace />}
/>
{/* The ONE company logo, shown in the header of every generated document. */}
<Route
path="logo-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.logo.view}>
<LogoSettingsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={
@@ -875,7 +1122,9 @@ const App = () => {
<Route
path="configuration/exchange-rate"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.exchangeRate.view}>
<RequirePermission
permission={FREIGHT_PERMS.settings.exchangeRate.view}
>
<div className="p-4">
<ExchangeRateSettingsCard />
</div>
@@ -943,4 +1192,3 @@ function LegacyGlEthiopiaClearanceRedirect() {
}
export default App;

View File

@@ -1,44 +1,16 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { Building2, FileCheck, Mail, MapPin, Phone, User } from "lucide-react";
import { Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { LinkedEntityCard } from "@/components/detail";
import type { FieldRowProps } from "@/components/detail";
import { SectionCard } from "./SectionCard";
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
export interface BookingCompanyCardProps {
booking: BookingDetail;
}
/** Customer (company) information for the booking. */
/** Customer (company) quick info for the booking, linking to its detail page. */
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const company = booking.company;
@@ -46,11 +18,9 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
if (!company && booking.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRow
icon={Building2}
label="Government"
value={booking.governmentInstitution}
/>
<Text size="sm" fw={600}>
{booking.governmentInstitution ?? "Government"}
</Text>
</SectionCard>
);
}
@@ -67,36 +37,24 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const companyName = company.companyName ?? company.name ?? company.label;
const rows: InfoRowProps[] = [
const rows: FieldRowProps[] = [
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact person", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
].filter((r) => r.value);
];
return (
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={companyName}
name={companyName ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue"
>
<Stack gap={0}>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">
No additional company details available.
</Text>
) : (
rows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))
)}
</Stack>
</SectionCard>
rows={rows}
emptyMessage="No additional company details available."
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Anchor as AnchorIcon } from "lucide-react";
import { Code } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { LinkedEntityCard } from "@/components/detail";
import type { FieldRowProps } from "@/components/detail";
export interface BookingContractCardProps {
booking: BookingDetail;
}
/** Parent contract quick info for the booking, linking to its detail page. */
export function BookingContractCard({ booking }: BookingContractCardProps) {
if (!booking.contractId || !booking.contractReference) return null;
const rows: FieldRowProps[] = [
{
label: "Kind",
value: booking.contractKind === "GENERAL" ? "General" : "One-time",
},
];
return (
<LinkedEntityCard
icon={AnchorIcon}
title="Contract"
name={booking.contractReference}
to={`/dashboard/contract-requests/${booking.contractId}`}
accent="teal"
rows={rows}
footer={
booking.contractSummary ? (
<Code
block
mt={4}
style={{
maxHeight: 220,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{booking.contractSummary}
</Code>
) : undefined
}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { Anchor } from "lucide-react";
import { Code } from "@mantine/core";
import { SectionCard } from "./SectionCard";
export interface BookingContractSummaryCardProps {
summary: string;
}
/** Generated contract terms, shown verbatim. */
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
return (
<SectionCard icon={Anchor} title="Contract summary" accent="teal">
<Code
block
style={{
maxHeight: 256,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{summary}
</Code>
</SectionCard>
);
}

View File

@@ -1,6 +1,10 @@
import { Truck } from "lucide-react";
import { SimpleGrid } from "@mantine/core";
import type { ReactNode } from "react";
import { Download, Truck } from "lucide-react";
import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
@@ -8,24 +12,92 @@ import { MetricTile } from "./MetricTile";
export interface BookingMileServicesCardProps {
booking: BookingDetail;
/** Export handover-mode control — how the cargo reaches the train. Lives
* here because it's the other "how does the cargo physically travel" fact;
* shown even when no mile address is set, since EXPORT bookings still need
* the choice made. */
handoverSection?: ReactNode;
}
/** First / last mile addresses. Renders nothing when neither is present. */
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) {
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
/**
* First / last mile addresses, plus the export handover control and the
* stored last-mile contract reference (signed status + PDF download) for
* Truck & Machinery once a request on this booking is approved. Renders
* nothing when none of the three are present.
*/
export function BookingMileServicesCard({
booking,
handoverSection,
}: BookingMileServicesCardProps) {
const hasAddresses =
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
const { data: requestsResponse } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }),
queryFn: async () =>
(await lastMileRequestsService.list({ bookingId: booking.id })).data,
enabled: Boolean(booking.lastMileDeliveryAddress),
});
const approvedRequest = (requestsResponse?.data ?? []).find(
(r) => r.status === "APPROVED",
);
if (!hasAddresses && !handoverSection) {
return null;
}
const downloadContract = async () => {
if (!approvedRequest) return;
const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
};
return (
<SectionCard icon={Truck} title="Mile services" accent="grape">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
<Stack gap="md">
{hasAddresses && (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)}
</SimpleGrid>
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
{approvedRequest && (
<Group justify="space-between" align="center" wrap="wrap">
<Stack gap={0}>
<Text size="sm" fw={600}>
Last-mile contract
</Text>
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
{approvedRequest.customerSignedAt
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
approvedRequest.signerDisplayName
? ` by ${approvedRequest.signerDisplayName}`
: ""
}`
: "Awaiting customer signature"}
</Text>
</Stack>
<Button
size="xs"
variant="light"
leftSection={<Download size={14} />}
onClick={() => void downloadContract()}
>
Download PDF
</Button>
</Group>
)}
</SimpleGrid>
{handoverSection}
</Stack>
</SectionCard>
);
}

View File

@@ -1,258 +0,0 @@
import type { ReactNode } from "react";
import {
ArrowLeft,
Building2,
Calendar,
Clock,
Container as ContainerIcon,
Flame,
RefreshCw,
Wallet,
Weight,
} from "lucide-react";
import {
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { formatDate } from "./booking-detail.styles";
export interface BookingRequestHeroProps {
booking: BookingDetail;
customerLabel: string;
onBack: () => void;
onRefresh: () => void;
isFetching?: boolean;
}
/** Top hero for the request detail page: identity, status, next step, key figures. */
export function BookingRequestHero({
booking,
customerLabel,
onBack,
onRefresh,
isFetching,
}: BookingRequestHeroProps) {
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
return (
<Paper
radius="xl"
p="xl"
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="default"
size="compact-sm"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={onBack}
>
Back to list
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Group>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "#B26C09" }}
>
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Stack gap={2} miw={0}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<ContractReferenceLink
contractId={booking.contractId}
contractReference={booking.contractReference}
/>
</Stack>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
<Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} strong />
<MetaItem
icon={Calendar}
text={`Scheduled ${booking.scheduledDate}`}
/>
<MetaItem
icon={Clock}
text={`Created ${formatDate(booking.createdAt)}`}
/>
</Group>
</Stack>
</Group>
{booking.nextStep ? (
<Paper
radius="lg"
p={4}
maw={640}
style={{
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
<Group grow gap="md" align="stretch" wrap="wrap">
<HeroTile
icon={Wallet}
label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(
undefined,
{
minimumFractionDigits: 2,
},
)}`}
hint={booking.paymentStatus}
accent="edr-green"
/>
<HeroTile
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
accent="blue"
/>
<HeroTile
icon={ContainerIcon}
label="Containers"
value={containerCount || "—"}
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
accent="teal"
/>
<HeroTile
icon={Flame}
label="Priority score"
value={booking.priorityScore ?? 0}
hint={booking.tradeDirection}
accent="orange"
/>
</Group>
</Stack>
</Paper>
);
}
function MetaItem({
icon: Icon,
text,
strong,
}: {
icon: LucideIcon;
text: ReactNode;
strong?: boolean;
}) {
return (
<Group gap={6} wrap="nowrap">
<Icon size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
{text}
</Text>
</Group>
);
}
function HeroTile({
icon: Icon,
label,
value,
hint,
accent = "edr-green",
}: {
icon: LucideIcon;
label: string;
value: ReactNode;
hint?: ReactNode;
accent?: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 160px",
minWidth: 150,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
<Icon size={18} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.4 }}
>
{label}
</Text>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{hint ? (
<Text size="xs" c="dimmed" truncate>
{hint}
</Text>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -16,10 +16,9 @@ export * from "./BookingPaymentCard";
export * from "./BookingPaymentCountdownCard";
export * from "./BookingFactsCard";
export * from "./BookingDocumentsCard";
export * from "./BookingRequestHero";
export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";
export * from "./BookingContractCard";
export * from "./BookingCompanyCard";
export * from "./BookingSchedulingWindowCard";

View File

@@ -2,6 +2,7 @@ import { Button, Group, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { Search, X } from "lucide-react";
import type { ReactNode } from "react";
import { getDateRangePresets } from "./dateRangePresets";
export interface ListControlsProps {
search: string;
@@ -54,28 +55,19 @@ const ListControls = ({
)}
{showDateRange && (
<>
<DatePickerInput
label={dateLabel ? `${dateLabel} from` : "From"}
placeholder="Any"
value={dateFrom}
onChange={onDateFromChange}
// Cannot start after it ends — the picker refuses the invalid range
// instead of silently returning nothing.
maxDate={dateTo ?? undefined}
clearable
w={150}
/>
<DatePickerInput
label={dateLabel ? `${dateLabel} to` : "To"}
placeholder="Any"
value={dateTo}
onChange={onDateToChange}
minDate={dateFrom ?? undefined}
clearable
w={150}
/>
</>
<DatePickerInput
type="range"
label={dateLabel ?? "Date range"}
placeholder="Any"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
onDateFromChange(from);
onDateToChange(to);
}}
presets={getDateRangePresets()}
clearable
w={230}
/>
)}
{children}

View File

@@ -0,0 +1,41 @@
import {
format,
startOfDay,
endOfDay,
startOfMonth,
endOfMonth,
startOfYear,
subDays,
subMonths,
} from "date-fns";
import type { DatePickerPreset } from "@mantine/dates";
const iso = (date: Date) => format(date, "yyyy-MM-dd");
/**
* Shared "Today / Last 7 days / …" presets for every Mantine
* `<DatePickerInput type="range" presets={getDateRangePresets()} />` in the app,
* so every from/to filter offers the same shortcuts. Computed fresh per call
* (not a module-level constant) so "Today" stays today.
*/
export function getDateRangePresets(): DatePickerPreset<"range">[] {
const today = new Date();
return [
{ label: "Today", value: [iso(startOfDay(today)), iso(endOfDay(today))] },
{
label: "Yesterday",
value: [iso(startOfDay(subDays(today, 1))), iso(endOfDay(subDays(today, 1)))],
},
{ label: "Last 7 days", value: [iso(startOfDay(subDays(today, 6))), iso(endOfDay(today))] },
{ label: "Last 30 days", value: [iso(startOfDay(subDays(today, 29))), iso(endOfDay(today))] },
{ label: "This month", value: [iso(startOfMonth(today)), iso(endOfDay(today))] },
{
label: "Last month",
value: [
iso(startOfMonth(subMonths(today, 1))),
iso(endOfMonth(subMonths(today, 1))),
],
},
{ label: "Year to date", value: [iso(startOfYear(today)), iso(endOfDay(today))] },
];
}

View File

@@ -0,0 +1,16 @@
import { Badge } from "@mantine/core";
const STATUS_COLOR: Record<string, string> = {
PENDING: "edr-green",
ACCEPTED: "blue",
REJECTED: "red",
};
/** Status of a customer-submitted shipment (booking) request against a contract. */
export function BookingRequestStatusBadge({ status }: { status: string }) {
return (
<Badge variant="light" radius="sm" color={STATUS_COLOR[status] ?? "gray"}>
{status}
</Badge>
);
}

View File

@@ -0,0 +1,172 @@
import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { ImageIcon, RefreshCw, X } from "lucide-react";
const MAX_LOGO_MB = 10;
export interface LogoUploadProps {
/** Logo image as a data URL, or null when none is attached yet. */
value: string | null;
onChange: (dataUrl: string | null) => void;
label?: string;
description?: string;
}
/**
* Company logo picker — reads the picked image straight into a data URL,
* same transport as {@link StampUpload}. Kept as its own component (not a
* generalized image-upload) matching how stamp/teeter are already separate
* files here despite the near-identical shape.
*/
export function LogoUpload({
value,
onChange,
label = "Company logo",
description = "Attach the official company logo.",
}: LogoUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const readFile = (file: File | undefined | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("The logo must be an image file (PNG or JPG).");
return;
}
if (file.size > MAX_LOGO_MB * 1024 * 1024) {
setError(`The logo image must be under ${MAX_LOGO_MB} MB.`);
return;
}
const reader = new FileReader();
reader.onload = () => {
setError(null);
setFileName(file.name);
onChange(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => setError("Could not read that file. Try another.");
reader.readAsDataURL(file);
};
const openPicker = () => inputRef.current?.click();
const clear = () => {
setFileName(null);
setError(null);
onChange(null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(e) => readFile(e.currentTarget.files?.[0])}
/>
{value ? (
<Paper withBorder radius="md" p="sm">
<Group gap="md" wrap="nowrap" align="center">
<Box
style={{
background:
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
borderRadius: 8,
flexShrink: 0,
padding: 6,
}}
>
<Image
src={value}
alt="Company logo"
fit="contain"
h={92}
w={92}
/>
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{fileName ?? "Logo attached"}
</Text>
<Text size="xs" c="dimmed">
Shown in the header of every generated document.
</Text>
<Group gap="xs" mt={2}>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<RefreshCw size={13} />}
onClick={openPicker}
>
Replace
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<X size={13} />}
onClick={clear}
>
Remove
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<Paper
withBorder
radius="md"
p="lg"
onClick={openPicker}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
readFile(e.dataTransfer.files?.[0]);
}}
style={{
borderColor: dragging
? "var(--mantine-color-edr-green-6)"
: undefined,
borderStyle: "dashed",
backgroundColor: dragging
? "var(--mantine-color-edr-green-0)"
: undefined,
cursor: "pointer",
}}
>
<Stack gap={6} align="center">
<ImageIcon size={26} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
Upload company logo
</Text>
<Text size="xs" c="dimmed" ta="center">
{description} Drop an image here or click to browse PNG or JPG,
up to {MAX_LOGO_MB} MB.
</Text>
</Stack>
</Paper>
)}
{error && (
<Text size="xs" c="red.7">
{error}
</Text>
)}
</Stack>
);
}

View File

@@ -30,6 +30,7 @@ import { clearanceWorkflowFileLabel } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { LinkedEntityCard } from "@/components/detail";
import { customersService } from "@/services/customers.service";
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
@@ -141,25 +142,23 @@ export function ContractCustomerCard({
return (
<Stack gap="lg">
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={company.name}
name={company.name ?? "Unnamed company"}
to={`/dashboard/customers/${company.id}`}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber },
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
{ icon: Globe, label: "Country", value: company.country },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: Globe, label: "Website", value: company.website },
]}
/>
</SectionCard>
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber },
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
{ icon: Globe, label: "Country", value: company.country },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: Globe, label: "Website", value: company.website },
]}
/>
<SectionCard icon={User} title="Contact person" accent="teal">
<InfoRows

View File

@@ -12,56 +12,14 @@ import {
User,
Warehouse,
} from "lucide-react";
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { LinkedEntityCard } from "@/components/detail";
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
/** Customer (company) on the request's contract. */
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
const company = contract?.company;
@@ -75,23 +33,21 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
);
}
return (
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={company.name ?? undefined}
name={company.name ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
);
}
@@ -119,43 +75,41 @@ export function RequestContractSummaryCard({
}) {
if (!contract) return null;
return (
<SectionCard
<LinkedEntityCard
icon={FileText}
title="Contract"
subtitle={contract.reference}
name={contract.reference}
to={`/dashboard/contract-requests/${contract.id}`}
accent="grape"
>
<InfoRows
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
</SectionCard>
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
);
}

View File

@@ -1,151 +0,0 @@
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id">;
}
/**
* Staff-triggered password reset. Sends a single-use link to the customer's
* primary contact; the customer opens it and picks their own new password. No
* credential is ever shown to or handled by staff.
*/
export default function ResetPasswordAction({
company,
}: ResetPasswordActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("phone");
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
// The destination is the primary contact's IAM account, not the company
// record — those are different fields and routinely hold different values, so
// showing `company.phone` here would tell staff the wrong number. Only fetched
// once the modal is open.
const targetQuery = useQuery(
api.customers.resetTarget.queryOptions({
input: { companyId: company.id },
enabled: allowed && opened,
}),
);
const target = targetQuery.data;
const { mutate, isPending } = useMutation(
api.customers.resetPassword.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Reset link sent",
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
});
},
onError: (error) => {
toast({
title: "Could not send reset link",
description: error.message,
variant: "destructive",
});
},
}),
);
if (!allowed) return null;
// SMS is domestic-only: a foreign number counts as unavailable, same as a
// missing one, so staff can't send a link that will never arrive.
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
const channelMissing =
!!target && (channel === "email" ? !target.email : !phoneUsable);
return (
<>
<Button
variant="default"
leftSection={<KeyRound size={16} />}
onClick={() => setOpened(true)}
>
Reset password
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset link"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a single-use link to this customer&apos;s primary
contact. They choose their own new password you will not see it.
The link expires in 24 hours.
</Text>
{targetQuery.isLoading ? (
<Stack align="center" py="md">
<Loader size="sm" />
</Stack>
) : targetQuery.isError ? (
<Alert color="red" variant="light">
{targetQuery.error.message}
</Alert>
) : target ? (
<>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label={`Send the link to ${target.name || "the primary contact"} via`}
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
disabled={!phoneUsable}
description={
!target.phone
? "No phone number on this account"
: target.phoneIsDomestic === false
? `${target.phone} — foreign number, SMS unavailable; use email`
: target.phone
}
/>
<Radio
value="email"
label="Email"
disabled={!target.email}
description={
target.email ?? "No email address on this account"
}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
These are the primary contact&apos;s own login details, which may
differ from the company contact details on the profile.
</Text>
<Button
color="edr-green"
loading={isPending}
disabled={channelMissing}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset link
</Button>
</>
) : null}
</Stack>
</Modal>
</>
);
}

View File

@@ -19,10 +19,6 @@ export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,
} from "./RequestDocumentChangeModal";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,
} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export {
PersonCard,

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
import { Anchor, Group, Text } from "@mantine/core";
import { Link } from "react-router-dom";
export interface EntityLinkProps {
/** Route to the related record's detail page. Renders nothing if falsy — a
* link with no id would be a dead one (e.g. a government booking with no
* company). */
to?: string | null;
label: ReactNode;
icon?: LucideIcon;
/** Monospace label — for references/codes (e.g. "CT-2024-0117"). */
mono?: boolean;
size?: "xs" | "sm" | "md";
fw?: number;
className?: string;
}
/**
* Inline link to another record's detail page, with a small "go to" glyph so
* it reads as navigation rather than plain emphasis. `stopPropagation` matters
* wherever this sits inside a clickable table row (booking/invoice rows
* navigate on click) — without it a nested link races the row handler.
*/
export function EntityLink({
to,
label,
icon: Icon,
mono,
size = "sm",
fw = 600,
className,
}: EntityLinkProps) {
if (!to) {
return (
<Text size={size} fw={fw} c="dimmed" ff={mono ? "monospace" : undefined}>
{label}
</Text>
);
}
return (
<Anchor
component={Link}
to={to}
onClick={(e) => e.stopPropagation()}
underline="hover"
c="edr-green"
fw={fw}
fz={size}
ff={mono ? "monospace" : undefined}
className={className}
>
<Group gap={4} wrap="nowrap" component="span" style={{ display: "inline-flex" }}>
{Icon ? <Icon size={14} /> : null}
<span>{label}</span>
<ArrowUpRight size={13} style={{ flexShrink: 0 }} />
</Group>
</Anchor>
);
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Group, Stack, Text } from "@mantine/core";
export interface FieldProps {
label: string;
value?: ReactNode;
}
/**
* Stacked label-over-value pair — uppercase dimmed label, value below. Used in
* grids of facts (e.g. an invoice summary, a contract's key figures).
*/
export function Field({ label, value }: FieldProps) {
const isEmpty = value === undefined || value === null || value === "";
return (
<Stack gap={2}>
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
{label}
</Text>
<Text size="sm" c="edr-text">
{isEmpty ? "—" : value}
</Text>
</Stack>
);
}
export interface FieldRowProps {
icon?: LucideIcon;
label: string;
value?: ReactNode;
}
/**
* Left icon+label / right bold value row, divider-separated when stacked in a
* list. Used inside quick-info cards (see `LinkedEntityCard`).
*/
export function FieldRow({ icon: Icon, label, value }: FieldRowProps) {
const isEmpty = value === undefined || value === null || value === "";
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
{Icon ? <Icon size={15} color="var(--mantine-color-gray-5)" /> : null}
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{isEmpty ? "—" : value}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Divider, Stack, Text } from "@mantine/core";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { FieldRow, type FieldRowProps } from "./Field";
import { EntityLink } from "./EntityLink";
export interface LinkedEntityCardProps {
icon: LucideIcon;
/** Card title, e.g. "Customer" or "Contract". */
title: string;
/** The entity's own name/reference, rendered as the linked subtitle. */
name: ReactNode;
/** Route to the entity's detail page. Omit when there's nothing to link to
* (e.g. a government booking with no company) — the name renders as plain
* dimmed text instead of a dead link. */
to?: string | null;
accent?: string;
/** Quick-info rows shown below the linked name — empty ones are dropped. */
rows?: FieldRowProps[];
/** Extra content under the rows (e.g. a summary paragraph, an action). */
footer?: ReactNode;
/** Shown instead of rows/footer when there's nothing to display at all. */
emptyMessage?: string;
}
/**
* "Customer at a glance" / "Contract at a glance" card for a detail page's
* sticky rail: a linked title plus a handful of quick-info rows, so the
* related record's essentials are visible without navigating away.
*/
export function LinkedEntityCard({
icon,
title,
name,
to,
accent = "blue",
rows = [],
footer,
emptyMessage,
}: LinkedEntityCardProps) {
const visibleRows = rows.filter((r) => r.value !== undefined && r.value !== null && r.value !== "");
return (
<SectionCard icon={icon} title={title} accent={accent}>
<Stack gap={4}>
<EntityLink to={to} label={name} size="sm" fw={700} />
{visibleRows.length > 0 ? (
<Stack gap={0} mt={4}>
{visibleRows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<FieldRow {...row} />
</div>
))}
</Stack>
) : emptyMessage ? (
<Text size="sm" c="dimmed" mt={4}>
{emptyMessage}
</Text>
) : null}
{footer}
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,14 @@
export { Field, FieldRow } from "./Field";
export type { FieldProps, FieldRowProps } from "./Field";
export { EntityLink } from "./EntityLink";
export type { EntityLinkProps } from "./EntityLink";
export { LinkedEntityCard } from "./LinkedEntityCard";
export type { LinkedEntityCardProps } from "./LinkedEntityCard";
// Re-exported so pages under this restructure have one import path for both
// the new quick-info primitives and the existing section-card shell. Imported
// from the file directly (not the bookings/detail barrel) — that barrel also
// re-exports cards that import from this module, and going through it would
// create a circular import.
export { SectionCard } from "@/components/bookings/detail/SectionCard";
export type { SectionCardProps } from "@/components/bookings/detail/SectionCard";

View File

@@ -44,10 +44,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
},
},
{
prefix: "/dashboard/payments",
// Invoices, Payments, and USD Payments are tabs on one page now
// (FinanceHubPage); the header title itself is set per-tab there.
prefix: "/dashboard/invoices",
meta: {
title: "Payments",
subtitle: "View booking payment transactions",
title: "Invoices",
subtitle: "Invoices, payments, and USD bank transfers",
},
},
{

View File

@@ -7,7 +7,7 @@ import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs";
export interface PageHeaderProps {
title: string;
subtitle?: string;
subtitle?: ReactNode;
/** Breadcrumb trail — pass only on nested pages (details, sub-resources). */
breadcrumbs?: BreadcrumbItem[];
/** Route to return to; renders a back arrow before the title. */

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import { Mail, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in backoffice user change their own email. Goes through
* /me/contact/otp + /me/contact rather than the generic (unverified)
* /auth/update-profile route, so the new address is proven before it's
* written — see account.controller.ts on the API side.
*/
export function ChangeEmailCard() {
const { user } = useAuth();
const sendOtpMutation = useMutation(
api.account.sendContactOtp.mutationOptions(),
);
const updateContactMutation = useMutation(
api.account.updateContact.mutationOptions(),
);
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail");
const [newEmail, setNewEmail] = useState("");
const [otp, setOtp] = useState("");
const [formError, setFormError] = useState("");
const closeDialog = () => {
setOpen(false);
setStep("enterEmail");
setNewEmail("");
setOtp("");
setFormError("");
};
const sendOtp = () => {
setFormError("");
if (!newEmail.trim()) {
setFormError("Enter the new email address.");
return;
}
sendOtpMutation.mutate(
{ channel: "email", value: newEmail.trim() },
{
onSuccess: (result) => {
toast.success(`Verification code sent to ${result.sentTo}`);
setStep("enterOtp");
},
},
);
};
const confirmOtp = () => {
setFormError("");
if (!otp.trim()) {
setFormError("Enter the verification code.");
return;
}
updateContactMutation.mutate(
{ channel: "email", value: newEmail.trim(), otp: otp.trim() },
{
onSuccess: () => {
toast.success("Email updated.");
closeDialog();
// Refetches the session so the new email shows everywhere — simplest
// way to refresh the cached user without a dedicated context method.
window.location.reload();
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="size-4" />
Email
</CardTitle>
<CardDescription>
{user?.email ? `Current email: ${user.email}` : "Change your account email."}
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Change email
</Button>
</CardContent>
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change email</DialogTitle>
<DialogDescription>
{step === "enterEmail"
? "We'll send a verification code to the new address."
: `Enter the code sent to ${newEmail}.`}
</DialogDescription>
</DialogHeader>
{step === "enterEmail" ? (
<div className="space-y-2">
<Label htmlFor="newEmail">New email</Label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="otp">Verification code</Label>
<Input
id="otp"
value={otp}
onChange={(e) => setOtp(e.target.value)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
{step === "enterEmail" ? (
<Button disabled={sendOtpMutation.isPending} onClick={sendOtp}>
{sendOtpMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Send code"
)}
</Button>
) : (
<Button disabled={updateContactMutation.isPending} onClick={confirmOtp}>
{updateContactMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Confirm"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import { KeyRound, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in backoffice user change their own password. The account
* is logged out on success — the old token was issued under the old
* password, and this forces a clean re-login rather than trusting the
* server to keep the existing session valid.
*/
export function ChangePasswordCard() {
const { logout } = useAuth();
const changePasswordMutation = useMutation(
api.account.changePassword.mutationOptions(),
);
const [open, setOpen] = useState(false);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [formError, setFormError] = useState("");
const closeDialog = () => {
setOpen(false);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
setFormError("");
};
const submit = () => {
setFormError("");
if (!oldPassword || !newPassword || !confirmPassword) {
setFormError("All fields are required.");
return;
}
if (newPassword.length < 8) {
setFormError("New password must be at least 8 characters.");
return;
}
if (newPassword === oldPassword) {
setFormError("New password must be different from the current one.");
return;
}
if (newPassword !== confirmPassword) {
setFormError("New password and confirmation do not match.");
return;
}
changePasswordMutation.mutate(
{ oldPassword, newPassword, confirmPassword },
{
onSuccess: () => {
toast.success("Password changed. Please sign in again.");
closeDialog();
setTimeout(logout, 1200);
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4" />
Password
</CardTitle>
<CardDescription>Change the password for your account.</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Change password
</Button>
</CardContent>
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
You'll be signed out and asked to log in again once it's changed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="oldPassword">Current password</Label>
<Input
id="oldPassword"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New password</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm new password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button disabled={changePasswordMutation.isPending} onClick={submit}>
{changePasswordMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Change password"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,83 @@
import { Box, Text } from "@mantine/core";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { overviewChartColors } from "@/components/overview/overview.styles";
import type { ReportChartDef, ReportColumn } from "@/types/reports";
import { formatReportCell } from "./report-format";
interface ReportChartProps {
chart: ReportChartDef;
items: Record<string, unknown>[];
columns: ReportColumn[];
/** Filtered row count on the server. Chart is capped at 100 rows (the API's
* page-size ceiling) — surface it plainly rather than silently truncate. */
total?: number;
}
const COLORS = overviewChartColors.pipeline;
/** Plots the same rows the table gets — chart.x/chart.y are just column keys. */
export function ReportChart({ chart, items, columns, total }: ReportChartProps) {
const columnByKey = new Map(columns.map((c) => [c.key, c]));
const yLabel = (key: string) => columnByKey.get(key)?.label ?? key;
const yType = (key: string) => columnByKey.get(key)?.type ?? "number";
if (!items.length) {
return (
<Text size="sm" c="dimmed" ta="center" py="xl">
No data for the selected filters.
</Text>
);
}
const Chart = chart.type === "line" ? LineChart : BarChart;
const truncated = typeof total === "number" && total > items.length;
return (
<Box px="md" pb="md">
{truncated ? (
<Text size="xs" c="dimmed" mb="xs">
Showing first {items.length} of {total} rows. Narrow the filters to see the rest charted.
</Text>
) : null}
<ResponsiveContainer width="100%" height={320}>
<Chart data={items} margin={{ top: 8, right: 16, left: 0, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey={chart.x}
tick={{ fontSize: 11 }}
angle={-20}
textAnchor="end"
height={50}
stroke="#94a3b8"
/>
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip formatter={(value, name) => [formatReportCell(value, yType(String(name))), yLabel(String(name))]} />
{chart.y.length > 1 ? <Legend formatter={(name) => yLabel(String(name))} /> : null}
{chart.y.map((key, i) =>
chart.type === "line" ? (
<Line key={key} type="monotone" dataKey={key} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={false} />
) : (
<Bar key={key} dataKey={key} fill={COLORS[i % COLORS.length]} radius={[4, 4, 0, 0]} />
),
)}
</Chart>
</ResponsiveContainer>
</Box>
);
}
export default ReportChart;

View File

@@ -0,0 +1,158 @@
import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { Download, FileSpreadsheet, FileText } from "lucide-react";
import { useState } from "react";
import { reportsService } from "@/services/reports.service";
import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports";
interface ReportExportButtonProps {
def: ReportCatalogEntry;
/** Filters + sort currently applied on screen — no key/page/pageSize. */
params: Omit<ReportRunParams, "key" | "page" | "pageSize">;
}
const RECORD_OPTIONS = [
{ value: "all", label: "All (up to format limit)" },
{ value: "100", label: "First 100" },
{ value: "500", label: "First 500" },
{ value: "1000", label: "First 1,000" },
];
/** Triggers a browser save for a blob without leaving the SPA. */
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/** One export button: format, which fields, how many records — applies the
* filters/sort already on screen. Record count defaults to all (capped
* server-side per format). */
export function ReportExportButton({ def, params }: ReportExportButtonProps) {
const [opened, setOpened] = useState(false);
const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx");
const [fields, setFields] = useState<string[]>(def.columns.map((c) => c.key));
const [records, setRecords] = useState("all");
const [exporting, setExporting] = useState(false);
const allSelected = fields.length === def.columns.length;
const toggleField = (key: string) =>
setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key));
const handleDownload = async () => {
setExporting(true);
try {
const blob = await reportsService.download(def.key, format, {
...params,
fields: allSelected ? undefined : fields.join(","),
limit: records === "all" ? undefined : records,
});
saveBlob(blob, `${def.key}.${format}`);
setOpened(false);
} finally {
setExporting(false);
}
};
return (
<>
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Download size={16} />}
onClick={() => setOpened(true)}
>
Export
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title="Export report" radius="md" size="md">
<Stack gap="lg">
<div>
<Text size="sm" fw={600} mb="xs">
Format
</Text>
<Radio.Group value={format} onChange={(v) => setFormat(v as "xlsx" | "pdf")}>
<SimpleGrid cols={2}>
<Radio.Card value="xlsx" radius="md" p="md">
<Group wrap="nowrap" gap="sm">
<Radio.Indicator />
<FileSpreadsheet size={22} />
<Text size="sm" fw={500}>
Excel (.xlsx)
</Text>
</Group>
</Radio.Card>
<Radio.Card value="pdf" radius="md" p="md">
<Group wrap="nowrap" gap="sm">
<Radio.Indicator />
<FileText size={22} />
<Text size="sm" fw={500}>
PDF
</Text>
</Group>
</Radio.Card>
</SimpleGrid>
</Radio.Group>
</div>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={600}>
Fields
</Text>
<Button variant="subtle" size="compact-sm" onClick={toggleAll}>
{allSelected ? "Clear all" : "Select all"}
</Button>
</Group>
<SimpleGrid cols={2} spacing="xs">
{def.columns.map((col) => (
<Checkbox
key={col.key}
label={col.label}
checked={fields.includes(col.key)}
onChange={() => toggleField(col.key)}
/>
))}
</SimpleGrid>
</div>
<Select
label="Records"
value={records}
onChange={(v) => setRecords(v ?? "all")}
data={RECORD_OPTIONS}
allowDeselect={false}
radius="md"
size="sm"
/>
<Text size="xs" c="dimmed">
Uses the filters and sorting currently applied to the report.
</Text>
<Group justify="flex-end">
<Button variant="default" radius="md" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
radius="md"
loading={exporting}
disabled={!fields.length}
leftSection={<Download size={16} />}
onClick={() => void handleDownload()}
>
Download
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
export default ReportExportButton;

View File

@@ -0,0 +1,110 @@
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
import { DateInput, DatePickerInput } from "@mantine/dates";
import { Search } from "lucide-react";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import type { ReportFilterDef } from "@/types/reports";
export interface ReportFilterValues {
[param: string]: string | undefined;
}
interface ReportFiltersProps {
filters: ReportFilterDef[];
values: ReportFilterValues;
onChange: (values: ReportFilterValues) => void;
}
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
const fromDate = (value: string | null): string | undefined => value ?? undefined;
/** Renders one widget per report-declared filter and reports raw param values back up. */
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
if (!filters.length) return null;
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
return (
<Group gap="sm" wrap="wrap">
{filters.map((filter) => {
switch (filter.type) {
case "daterange":
return (
<DatePickerInput
key={filter.key}
type="range"
placeholder={filter.label}
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
onChange={([from, to]) =>
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
}
presets={getDateRangePresets()}
radius="md"
size="sm"
clearable
w={230}
/>
);
case "date":
return (
<DateInput
key={filter.key}
placeholder={filter.label}
value={toDate(values[filter.key])}
onChange={(d) => set({ [filter.key]: fromDate(d) })}
radius="md"
size="sm"
clearable
w={150}
/>
);
case "select":
return (
<Select
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key] ?? null}
onChange={(v) => set({ [filter.key]: v ?? undefined })}
radius="md"
size="sm"
clearable
w={170}
/>
);
case "multiselect":
return (
<MultiSelect
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
radius="md"
size="sm"
clearable
w={200}
/>
);
case "text":
return (
<TextInput
key={filter.key}
placeholder={filter.label}
leftSection={<Search size={16} />}
value={values[filter.key] ?? ""}
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
radius="md"
size="sm"
w={220}
/>
);
default:
return null;
}
})}
</Group>
);
}
export default ReportFilters;

View File

@@ -0,0 +1,39 @@
import { Stack, Text, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { ReportView } from "./ReportView";
interface ReportSectionProps {
reportKey: string;
/** Scopes the report to one entity, e.g. the contract this page is showing. */
idKeyValue?: string;
}
/**
* Drops a report inline on any page — a contract detail page embedding
* `contract-utilization`, for instance. Renders nothing while the catalog is
* loading or if the caller lacks the report's permission, so pages can embed
* it unconditionally without their own permission check.
*/
export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
if (!def) return null;
return (
<Stack gap="xs">
<div>
<Title order={4}>{def.title}</Title>
<Text size="sm" c="dimmed">
{def.description}
</Text>
</div>
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
</Stack>
);
}
export default ReportSection;

View File

@@ -0,0 +1,226 @@
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import type { Column, SortingState } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { PageHeader } from "@/components/page";
import { KpiStrip } from "@/components/page/KpiStrip";
import { api } from "@/services/api";
import type { ReportRunParams } from "@/types/reports";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import { ReportChart } from "./ReportChart";
import { ReportExportButton } from "./ReportExportButton";
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
import { formatKpiValue, formatReportCell } from "./report-format";
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
const sorted = column.getIsSorted();
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
return (
<UnstyledButton
onClick={column.getToggleSortingHandler()}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text size="sm" fw={600} c="edr-text">
{label}
</Text>
<Icon size={13} opacity={sorted ? 1 : 0.4} />
</UnstyledButton>
);
}
interface ReportViewProps {
reportKey: string;
/** Scopes the report to one entity when embedded (e.g. a contract detail page). */
idKeyValue?: string;
/** Full-page usage: renders the title/description as a PageHeader (no back
* arrow) with export/refresh as its actions, instead of inline above the
* table. Off by default for embedded sections. */
pageHeader?: boolean;
}
/**
* The report engine: one component renders any report the catalog describes —
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
* Adding a report never touches this file.
*/
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const [sorting, setSorting] = useState<SortingState>([]);
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
const [view, setView] = useState<"table" | "chart">("table");
// Filters + sort as the user currently has them — independent of the view
// toggle's paging, so export always matches what's on screen either way.
const appliedParams = useMemo(() => {
const sort = sorting[0];
return {
sortBy: sort?.id,
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
...debouncedFilters,
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
};
}, [def, sorting, debouncedFilters, idKeyValue]);
const runParams: ReportRunParams | undefined = useMemo(() => {
if (!def) return undefined;
return {
key: def.key,
// Chart view isn't paginated on screen — pull the server's max page (100)
// in one shot instead of just whatever page the table happens to be on,
// so the chart doesn't silently plot a fraction of the filtered rows.
page: view === "chart" ? 1 : pagination.pageIndex + 1,
pageSize: view === "chart" ? 100 : pagination.pageSize,
...appliedParams,
};
}, [def, view, pagination, appliedParams]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
enabled: Boolean(runParams),
});
const total = data?.meta.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Record<string, unknown>>[] = useMemo(
() =>
(def?.columns ?? []).map((col) => ({
id: col.key,
accessorKey: col.key,
header: col.sortable
? ({ column }) => <SortableHeader label={col.label} column={column} />
: col.label,
enableSorting: col.sortable,
cell: ({ row }) => (
<Text size="sm" c="edr-text">
{formatReportCell(row.original[col.key], col.type)}
</Text>
),
})),
[def?.columns],
);
if (!def) {
return catalog ? (
<Alert color="red">You don't have access to this report.</Alert>
) : null;
}
const chartToggle = def.chart ? (
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as "table" | "chart")}
data={[
{ label: <LayoutGrid size={14} />, value: "table" },
{ label: <LineChart size={14} />, value: "chart" },
]}
/>
) : null;
const refreshButton = (
<Tooltip label="Refresh">
<ActionIcon
variant="default"
radius="md"
loading={isFetching}
onClick={() => void refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Tooltip>
);
const exportButton = <ReportExportButton def={def} params={appliedParams} />;
return (
<Stack gap="md">
{pageHeader ? (
<PageHeader
title={def.title}
subtitle={def.description}
action={
<Group gap="xs">
{exportButton}
{refreshButton}
</Group>
}
/>
) : null}
{data?.kpis.length ? (
<KpiStrip
loading={isLoading}
items={data.kpis.map((k) => ({ label: k.label, value: formatKpiValue(k.value, k.unit) }))}
/>
) : null}
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<ReportFilters
filters={def.filters}
values={filterValues}
onChange={(v) => {
setFilterValues(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
/>
<Group gap="xs">
{chartToggle}
{pageHeader ? null : (
<>
{exportButton}
{refreshButton}
</>
)}
</Group>
</Group>
</Box>
{view === "chart" && def.chart ? (
<ReportChart chart={def.chart} items={data?.items ?? []} columns={def.columns} total={total} />
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={data?.items ?? []}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No data for the selected filters."
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { sorting },
onSortingChange: setSorting,
onPaginationChange: setPagination,
manualPagination: true,
manualSorting: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
);
}
export default ReportView;

View File

@@ -0,0 +1,42 @@
import type { ReportColumnType } from "@/types/reports";
/** Cell formatting shared by the on-screen table and (indirectly) exports. */
export function formatReportCell(value: unknown, type: ReportColumnType): string {
if (value === null || value === undefined || value === "") return "—";
switch (type) {
case "money":
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 2,
}).format(Number(value));
case "tons":
return `${Number(value).toLocaleString()} t`;
case "percent":
return `${value}%`;
case "number":
return Number(value).toLocaleString();
case "date": {
const d = new Date(String(value));
return Number.isNaN(d.getTime())
? String(value)
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
default:
return String(value);
}
}
export function formatKpiValue(value: number, unit?: string): string {
const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 1 });
if (unit === "ETB") {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
}).format(value);
}
if (unit === "%") return `${formatted}%`;
if (unit === "t") return `${formatted} t`;
return formatted;
}

View File

@@ -14,6 +14,7 @@ import {
} from "@mantine/core";
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
import { EntityLink } from "@/components/detail";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
/**
@@ -254,15 +255,19 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
{e.bookings.map((b) => (
<Table.Tr key={b.bookingId}>
<Table.Td w="50%">
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{b.reference}
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
<EntityLink
to={`/dashboard/booking-requests/${b.bookingId}`}
label={b.reference}
size="sm"
fw={500}
/>
{b.route ? (
<Text span size="xs" c="dimmed">
{" "}
({b.route})
</Text>
) : null}
</Text>
</Group>
</Table.Td>
<Table.Td w="25%">
<Group gap={4} wrap="nowrap">

View File

@@ -29,6 +29,7 @@ import {
// Repeat, // used by the hidden Move (reassign) button
Train,
TrainFront,
Truck,
Weight,
X,
} from "lucide-react";
@@ -36,7 +37,9 @@ import {
import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { EntityLink } from "@/components/detail";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { useToast } from "@/hooks/use-toast";
import type {
EligibleContainerBooking,
@@ -411,6 +414,31 @@ export function ScheduleWorkspacePanel({
);
};
// Export cargo that skipped the warehouse (customer truck straight onto the
// wagon) has no GRN and never will — loadBooking's GRN gate would keep
// rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage
// acceptance sheet is the handover document instead, then loads in one click.
const [truckToTrainPending, setTruckToTrainPending] = useState<string | null>(null);
const doTruckToTrain = (bookingId: string, ref: string) => {
setTruckToTrainPending(bookingId);
bookingsService
.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN")
.then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId }))
.then(() => {
toast({ title: `${ref} loaded — direct truck-to-train handover` });
onChanged();
void yardWorkQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not load as direct truck-to-train",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
)
.finally(() => setTruckToTrainPending(null));
};
const doUnload = (bookingId: string, ref: string) => {
unloadJourney
.mutateAsync({ scheduleId: schedule.id, bookingId })
@@ -601,6 +629,7 @@ export function ScheduleWorkspacePanel({
{pool.map((b) => (
<BookingCard
key={b.id}
bookingId={b.id}
reference={b.reference}
customer={b.customer}
weightTons={b.weightTons}
@@ -708,9 +737,16 @@ export function ScheduleWorkspacePanel({
const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId;
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
const showUnload = canWork && riding && (journey?.canUnload ?? false);
const showTruckToTrain =
canWork &&
!riding &&
!done &&
boardHere &&
b.tradeDirection === "EXPORT";
return (
<BookingCard
key={b.id}
bookingId={b.id}
reference={ref}
customer={b.customer}
weightTons={b.weightTons}
@@ -764,6 +800,24 @@ export function ScheduleWorkspacePanel({
</Button>
</Tooltip>
) : null}
{showTruckToTrain ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
withArrow
>
<Button
size="compact-sm"
variant="light"
color="blue"
radius="md"
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() => doTruckToTrain(b.id, ref)}
>
Truck to Train
</Button>
</Tooltip>
) : null}
{showUnload ? (
<Tooltip
label={
@@ -985,6 +1039,7 @@ function PanelColumn({
}
function BookingCard({
bookingId,
reference,
customer,
weightTons,
@@ -996,6 +1051,8 @@ function BookingCard({
leg,
right,
}: {
/** When set, the reference links to the booking's detail page. */
bookingId?: string;
reference: string;
customer?: string | null;
weightTons?: number | null;
@@ -1030,9 +1087,18 @@ function BookingCard({
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<Text size="sm" fw={700} truncate>
{reference}
</Text>
{bookingId ? (
<EntityLink
to={`/dashboard/booking-requests/${bookingId}`}
label={reference}
size="sm"
fw={700}
/>
) : (
<Text size="sm" fw={700} truncate>
{reference}
</Text>
)}
{status ? <BookingStatusBadge status={status} /> : null}
{intercity ? (
<Tooltip

View File

@@ -1,5 +1,6 @@
import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { Box, Package } from "lucide-react";
import { EntityLink } from "@/components/detail";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = (WagonPlanRow & {
@@ -159,9 +160,12 @@ export function WagonPlanGrid({
<Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0">
<Stack gap={2}>
<Group justify="space-between" gap="xs">
<Text size="xs" fw={500} lineClamp={1}>
{alloc.bookingReference ?? alloc.bookingId}
</Text>
<EntityLink
to={`/dashboard/booking-requests/${alloc.bookingId}`}
label={alloc.bookingReference ?? alloc.bookingId}
size="xs"
fw={500}
/>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T cargo

View File

@@ -89,6 +89,8 @@ export const QUERY_KEYS = {
["contracts", "clearance-history", region ?? "ET"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingRequests: (id: string) =>
["contracts", "booking-requests", id] as const,
bookingMilestones: (bookingId: string) =>
["contracts", "booking-milestones", bookingId] as const,
bookingIncidents: (bookingId: string) =>

View File

@@ -139,7 +139,9 @@ export const URL_CONSTANTS = {
},
REPORTS: {
CATALOG: "/reports",
RUN: (key: string) => `/reports/${key}`,
EXPORT: (key: string) => `/reports/${key}/export`,
},
OVERVIEW: {

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { logoSettingsService } from "@/services/logoSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["logoSettings"];
export const useLogoSettingsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => logoSettingsService.get(),
});
export const useSetLogo = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (logoImageBase64: string) =>
logoSettingsService.set(logoImageBase64),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("logoSettings.updated", "Company logo updated"));
},
onError: handleError,
});
};
export const useClearLogo = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: () => logoSettingsService.clear(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("logoSettings.cleared", "Company logo removed"));
},
onError: handleError,
});
};

View File

@@ -319,7 +319,7 @@ export const TopBar = () => {
{t("header.viewProfile")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
<Key className="w-4 h-4 mr-3" />
{t("header.changePassword")}

View File

@@ -340,6 +340,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// The ONE company logo, applied to every generated document (invoices,
// receipts, contracts, warehouse papers, train-scheduling manifests).
logo: {
view: "edr_freight_app:settings:logo:view",
manage: "edr_freight_app:settings:logo:manage",
},
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
// gate the company stamp before the two were untangled.

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectTrigger,
@@ -134,21 +135,12 @@ const buildQuery = (): CollectionQueryDTO => {
className="w-64"
/>
<div className="flex gap-2 items-center">
<Input
type="date"
onChange={(e) =>
setDateRange({ ...dateRange, start: e.target.value })
}
/>
<span className="text-muted-foreground text-sm">to</span>
<Input
type="date"
onChange={(e) =>
setDateRange({ ...dateRange, end: e.target.value })
}
/>
</div>
<DateRangePicker
value={{ from: parseDay(dateRange.start), to: parseDay(dateRange.end) }}
onChange={(range) =>
setDateRange({ start: formatDay(range.from), end: formatDay(range.to) })
}
/>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-[180px]">

View File

@@ -502,7 +502,7 @@ const Header = () => {
<DropdownMenuItem
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
>
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
<span className="font-medium">

View File

@@ -2,43 +2,56 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import {
ArrowLeft,
Container as ContainerIcon,
FileSignature,
FileText,
Flame,
FolderOpen,
Layers,
LayoutGrid,
Milestone,
MoreHorizontal,
Package,
RefreshCw,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
Container,
Stack,
Grid,
ActionIcon,
Box,
Button,
Center,
Container,
Grid,
Group,
Loader,
Menu,
Paper,
SegmentedControl,
Stack,
Tabs,
Text,
Paper,
Button,
Box,
SegmentedControl,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import {
detailStyles,
BookingRequestHero,
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingContractCard,
BookingContainerUnitsCard,
BookingSchedulingWindowCard,
BookingDocumentsPanel,
@@ -48,6 +61,7 @@ import {
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking";
import {
useBookingDetail,
@@ -133,7 +147,6 @@ export default function BookingRequestDetailPage() {
);
}
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
// Clearance review + finalize now lives solely on the Operations "Clearance
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
@@ -159,23 +172,176 @@ export default function BookingRequestDetailPage() {
setSearchParams(next, { replace: true });
};
const company = booking.company;
const customerName = toBookingListRow(booking).customerLabel;
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
const kpis: KpiItem[] = [
{
label: "Total value",
value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`,
hint: booking.paymentStatus,
icon: Wallet,
color: "edr-green",
},
{
label: "Cargo weight",
value: `${weight} T`,
hint: itemCount != null ? `${itemCount} items` : "VGM total",
icon: Weight,
color: "blue",
},
{
label: "Containers",
value: containerCount || "—",
hint: `${containers.length} line${containers.length === 1 ? "" : "s"}`,
icon: ContainerIcon,
color: "teal",
},
{
label: "Priority score",
value: booking.priorityScore ?? 0,
hint: booking.tradeDirection,
icon: Flame,
color: "orange",
},
];
const hasSignableContract = booking.isGovernment && booking.contractSummary;
return (
<PageContainer>
<Breadcrumbs
items={[
<PageHeader
breadcrumbs={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
backTo="/dashboard/booking-requests"
title={booking.reference}
meta={
<Group gap={6} wrap="wrap">
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
}
subtitle={
<Group gap={6} wrap="wrap">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
<Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate}
</Text>
</Group>
}
action={
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
loading={isFetching}
aria-label="Refresh"
onClick={() => refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
<Menu position="bottom-end" width={260} withinPortal>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="More actions"
>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{hasSignableContract && (
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
}
>
View / sign contract
</Menu.Item>
)}
<Menu.Item
leftSection={<FileText size={15} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Menu.Item>
{booking.customsClearingEnabled && (
<Menu.Item
leftSection={<Milestone size={15} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
}
/>
<Stack gap="lg">
<BookingRequestHero
booking={booking}
customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()}
isFetching={isFetching}
/>
<KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
{booking.nextStep ? (
<Paper
radius="lg"
p={4}
style={{
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
<BookingWorkflowStepper
status={booking.status}
@@ -223,7 +389,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel booking={booking} row={row} />
<OverviewPanel booking={booking} onRefetch={refetch} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingContractCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
@@ -265,97 +432,6 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
{booking.tradeDirection === "EXPORT" && (
<Paper withBorder radius="md" p="sm">
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
try {
await bookingsService.setExportHandoverMode(
booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
);
await refetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Could not change the handover mode",
);
}
}}
/>
<Text size="xs" c="dimmed">
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
: "Cargo is received at the warehouse and issued a GRN before loading."}
</Text>
</Stack>
</Paper>
)}
{booking.isGovernment && booking.contractSummary && (
<Button
fullWidth
variant="default"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View / sign contract
</Button>
)}
<Button
fullWidth
variant="default"
leftSection={<FileText size={16} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Button>
{booking.customsClearingEnabled && (
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Button>
)}
</Stack>
</Box>
</Grid.Col>
@@ -368,11 +444,13 @@ export default function BookingRequestDetailPage() {
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onRefetch,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onRefetch: () => void;
}) {
const row = toBookingListRow(booking);
return (
<Stack gap="lg">
<BookingRouteServiceCard
@@ -380,12 +458,49 @@ function OverviewPanel({
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingMileServicesCard
booking={booking}
handoverSection={
booking.tradeDirection === "EXPORT" ? (
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
try {
await bookingsService.setExportHandoverMode(
booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
);
onRefetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Could not change the handover mode",
);
}
}}
/>
<Text size="xs" c="dimmed">
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
: "Cargo is received at the warehouse and issued a GRN before loading."}
</Text>
</Stack>
) : null
}
/>
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
</Stack>
);
}

View File

@@ -13,7 +13,8 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -764,53 +765,33 @@ export default function BookingRequestsPage() {
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
style={{ minWidth: 220 }}
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Scheduled date range"
value={[scheduledFrom, scheduledTo]}
onChange={([from, to]) => {
setScheduledFrom(from ? new Date(from) : null);
setScheduledTo(to ? new Date(to) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Scheduled from"
value={scheduledFrom}
onChange={(v) => {
setScheduledFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={scheduledTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<DateInput
placeholder="Scheduled to"
value={scheduledTo}
onChange={(v) => {
setScheduledTo(v ? new Date(v) : null);
resetPage();
}}
minDate={scheduledFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
style={{ minWidth: 230 }}
/>
{activeFilterCount > 0 ? (
<Button

View File

@@ -10,11 +10,9 @@ import {
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
@@ -29,9 +27,13 @@ import {
import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import {
SectionCard,
BookingCompanyCard,
BookingContractCard,
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -182,6 +184,18 @@ export default function DocumentClearanceDetailPage() {
);
}
const direction = booking?.tradeDirection ?? "—";
const origin = booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? "Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
@@ -193,25 +207,46 @@ export default function DocumentClearanceDetailPage() {
{ label: reference },
]}
meta={
clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{direction}
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
{clearance.includesCustoms ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
subtitle={
<Group gap={8} wrap="nowrap">
<Text size="sm" c="dimmed" fw={600}>
{origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed" fw={600}>
{destination}
</Text>
</Group>
}
action={
canCompleteBooking ? (
@@ -244,12 +279,16 @@ export default function DocumentClearanceDetailPage() {
}
/>
<ClearanceHero
booking={booking}
clearance={clearance}
stats={stats}
requestedLines={requestedLines}
/>
<KpiStrip items={kpis} />
{requestedLines ? (
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
) : null}
{isPhasedGeneral ? (
<Paper withBorder radius="md" p="lg">
@@ -284,67 +323,54 @@ export default function DocumentClearanceDetailPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
)}
</Stack>
</SectionCard>
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
}
@@ -358,125 +384,3 @@ export default function DocumentClearanceDetailPage() {
</PageContainer>
);
}
function ClearanceHero({
booking,
clearance,
stats,
requestedLines,
}: {
booking: ReturnType<typeof useBookingDetail>["data"];
clearance: Freight.ClearanceView;
stats: { pct: number; approved: number; total: number };
requestedLines?: Freight.RequestedShipmentLines | null;
}) {
const direction = booking?.tradeDirection ?? "—";
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ??
booking?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{booking?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : null}
</Group>
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
{requestedLines ? (
<>
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
</>
) : null}
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -11,7 +11,8 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Search, XCircle } from "lucide-react";
@@ -304,29 +305,19 @@ export default function WagonCancellationsPage() {
w={190}
radius="md"
/>
<DateInput
placeholder="From"
value={from}
onChange={(v) => {
setFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Date range"
value={[from, to]}
onChange={([newFrom, newTo]) => {
setFrom(newFrom ? new Date(newFrom) : null);
setTo(newTo ? new Date(newTo) : null);
resetPage();
}}
maxDate={to ?? undefined}
presets={getDateRangePresets()}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="To"
value={to}
onChange={(v) => {
setTo(v ? new Date(v) : null);
resetPage();
}}
minDate={from ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
style={{ minWidth: 220 }}
/>
<Button
variant="subtle"

View File

@@ -10,7 +10,8 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
@@ -348,31 +349,20 @@ export default function ClearanceDocumentsPage() {
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
style={{ minWidth: 220 }}
aria-label="Created date range"
/>
</Group>
</Box>

View File

@@ -10,12 +10,9 @@ import {
Grid,
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
@@ -37,9 +34,11 @@ import {
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -196,6 +195,29 @@ export default function ContractClearanceDetailPage() {
const workflowFiles = clearance.workflowFiles ?? [];
const direction = contract?.tradeDirection ?? "—";
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const lastRoute = routes[routes.length - 1] ?? routes[0];
const destination =
lastRoute?.destinationYard?.label ??
lastRoute?.destinationYard?.code ??
"Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
@@ -206,57 +228,81 @@ export default function ContractClearanceDetailPage() {
{ label: hubLabel, href: hubHref },
{ label: reference },
]}
subtitle={
<Group gap={8} wrap="nowrap">
{id ? (
<EntityLink to={`/dashboard/contract-requests/${id}`} label="Contract details" />
) : null}
<Text size="sm" c="dimmed">
· {origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
{destination}
</Text>
</Group>
}
meta={
bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{directionLabel(direction)}
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Booking created
</Badge>
) : ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready create booking
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
{customs ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Booking created
</Badge>
) : ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready create booking
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
/>
<ClearanceHero contract={contract} stats={stats} />
<KpiStrip items={kpis} />
{/* Windows on this contract's routes/direction only — tells GL ET when
it can actually create the booking without checking the schedule board. */}
@@ -383,6 +429,7 @@ export default function ContractClearanceDetailPage() {
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md">
<RequestCustomerCard contract={contract} />
{phasedCustoms ? (
<PhasedClearanceActionPanel
contractId={id!}
@@ -425,23 +472,6 @@ export default function ContractClearanceDetailPage() {
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
@@ -457,126 +487,3 @@ export default function ContractClearanceDetailPage() {
);
}
function ClearanceHero({
contract,
stats,
}: {
contract: ReturnType<typeof useContractDetail>["data"];
stats: { pct: number; approved: number; total: number };
}) {
const direction = contract?.tradeDirection ?? "—";
const serviceName = contract?.serviceType?.serviceName ?? null;
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const last = routes[routes.length - 1] ?? routes[0];
const destination =
last?.destinationYard?.label ??
last?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{contract?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{directionLabel(direction)}
</Badge>
{customs ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : (
<Badge size="sm" variant="light" color="gray" radius="sm">
No customs
</Badge>
)}
</Group>
{serviceName && (
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
{serviceName}
</Text>
)}
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -13,7 +13,8 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -613,31 +614,20 @@ export default function ContractRequestsPage() {
style={{ minWidth: 140 }}
aria-label="Filter by payment currency"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
style={{ minWidth: 220 }}
aria-label="Created date range"
/>
{activeFilterCount > 0 ? (
<Button

View File

@@ -29,9 +29,10 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { BookingDetail } from "@/types/booking";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { PageContainer, PageHeader } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
@@ -42,6 +43,7 @@ import {
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -56,6 +58,7 @@ type GlClearanceDetail =
reference: string;
tradeDirection: string;
clearance: Freight.ContractClearanceView;
contract: Freight.IContract;
}
| {
kind: "booking";
@@ -79,6 +82,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
reference: contract.reference,
tradeDirection: contract.tradeDirection,
clearance,
contract,
};
} catch {
const [clearance, booking] = await Promise.all([
@@ -181,6 +185,16 @@ export default function GlClearanceDetailPage() {
{ label: "GL Djibouti Clearance", href: backTo },
{ label: data.reference },
]}
subtitle={
<EntityLink
to={
data.kind === "contract"
? `/dashboard/contract-requests/${id}`
: `/dashboard/booking-requests/${id}`
}
label={data.kind === "contract" ? "Contract details" : "Booking details"}
/>
}
meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{directionLabel(data.tradeDirection)}
@@ -278,37 +292,44 @@ export default function GlClearanceDetailPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
<Stack gap="md">
{data.kind === "contract" ? (
<RequestCustomerCard contract={data.contract} />
) : (
<BookingCompanyCard booking={data.booking} />
)}
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
</Stack>
</Grid.Col>
</Grid>
</Tabs.Panel>

View File

@@ -25,6 +25,7 @@ import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { EntityLink } from "@/components/detail";
import {
RequestCustomerCard,
RequestContractSummaryCard,
@@ -113,7 +114,17 @@ export default function ShipmentRequestDetailPage() {
<Stack gap="lg">
<PageHeader
title={`Shipment request ${request.reference}`}
subtitle={`On contract ${contractRef}`}
subtitle={
<Group gap={6} wrap="wrap">
<Text size="sm" c="dimmed">
On contract
</Text>
<EntityLink
to={`/dashboard/contract-requests/${request.contractId}`}
label={contractRef}
/>
</Group>
}
backTo="/dashboard/shipment-requests"
breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },

View File

@@ -17,7 +17,8 @@ import {
Textarea,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import {
ArrowUpDown,
FilterX,
@@ -460,25 +461,19 @@ export default function ShipmentRequestsPage() {
allowDeselect={false}
aria-label="Cargo type"
/>
<DateInput
<DatePickerInput
type="range"
radius="md"
w={150}
placeholder="Preferred from"
value={preferredFrom}
onChange={(v) => setPreferredFrom(v ? new Date(v) : null)}
maxDate={preferredTo ?? undefined}
w={230}
placeholder="Preferred date range"
value={[preferredFrom, preferredTo]}
onChange={([from, to]) => {
setPreferredFrom(from ? new Date(from) : null);
setPreferredTo(to ? new Date(to) : null);
}}
presets={getDateRangePresets()}
clearable
aria-label="Preferred date from"
/>
<DateInput
radius="md"
w={150}
placeholder="Preferred to"
value={preferredTo}
onChange={(v) => setPreferredTo(v ? new Date(v) : null)}
minDate={preferredFrom ?? undefined}
clearable
aria-label="Preferred date to"
aria-label="Preferred date range"
/>
<Select
radius="md"

View File

@@ -24,6 +24,7 @@ import {
Contact,
Download,
Eye,
FileSignature,
FileText,
History,
Hourglass,
@@ -55,7 +56,6 @@ import {
ProfileStatusBadge,
ProfileTypeBadge,
RequestDocumentChangeModal,
ResetPasswordAction,
TableCard,
formatBytes,
formatDate,
@@ -63,6 +63,8 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { useContractList } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
@@ -85,6 +87,7 @@ import {
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(company: Company) {
@@ -170,6 +173,7 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const contractsQuery = useContractList({ companyId: id, pageSize: 100 }, Boolean(id));
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
usePagination({
@@ -191,6 +195,7 @@ export default function CustomerDetailPage() {
);
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const contracts = contractsQuery.data?.items ?? [];
const documents = Array.isArray(documentsQuery.data)
? documentsQuery.data
: [];
@@ -402,6 +407,59 @@ export default function CustomerDetailPage() {
[],
);
const contractColumns: ColumnDef<Freight.IContract>[] = useMemo(
() => [
{
id: "reference",
header: "Contract",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "kind",
header: "Kind",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => (
<ContractStatusBadge
status={row.original.status}
isRenewal={Boolean(row.original.renewalOfId)}
/>
),
},
{
id: "validUntil",
header: "Valid until",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.contractValidUntil)}
</Text>
),
},
{
id: "createdAt",
header: "Created",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
],
[],
);
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
() => [
{
@@ -709,7 +767,6 @@ export default function CustomerDetailPage() {
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
action={<ResetPasswordAction company={company} />}
/>
<Tabs defaultValue="overview">
@@ -720,6 +777,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
Bookings
</Tabs.Tab>
<Tabs.Tab value="contracts" leftSection={<FileSignature size={16} />}>
Contracts
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
Documents
</Tabs.Tab>
@@ -1187,6 +1247,7 @@ export default function CustomerDetailPage() {
status={tableStatus(bookingsQuery)}
emptyMessage="No bookings for this customer."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) => navigate(`/dashboard/booking-requests/${row.id}`)}
error={
bookingsQuery.isError
? {
@@ -1199,6 +1260,28 @@ export default function CustomerDetailPage() {
</TableCard>
</Tabs.Panel>
{/* CONTRACTS */}
<Tabs.Panel value="contracts" pt="lg">
<TableCard minWidth={860}>
<DataTable
columns={contractColumns}
data={contracts}
status={tableStatus(contractsQuery)}
emptyMessage="No contracts for this customer."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) => navigate(`/dashboard/contract-requests/${row.id}`)}
error={
contractsQuery.isError
? {
message: "Failed to load contracts.",
onRetry: () => void contractsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<Stack gap="lg">

View File

@@ -1,8 +1,12 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard";
import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard";
export default function MyProfilePage() {
return (
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
<ChangeEmailCard />
<ChangePasswordCard />
<div id="signature">
<MySignatureCard />
</div>

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -613,26 +614,19 @@ const FleetResourcePage = () => {
filters={
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
aria-label="Created from"
placeholder="Created from"
value={dateFrom}
onChange={setDateFrom}
maxDate={dateTo ?? undefined}
type="range"
aria-label="Created date range"
placeholder="Created date range"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
setDateFrom(from);
setDateTo(to);
}}
presets={getDateRangePresets()}
clearable
size="sm"
radius="lg"
w={160}
/>
<DatePickerInput
aria-label="Created to"
placeholder="Created to"
value={dateTo}
onChange={setDateTo}
minDate={dateFrom ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
w={240}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">

View File

@@ -0,0 +1,100 @@
import { Tabs } from "@mantine/core";
import { Landmark, Receipt, Wallet } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import InvoicesPanel from "./InvoicesPage";
import UsdPaymentsPanel from "./UsdPaymentsPage";
import PaymentsPanel from "../payments/PaymentsPage";
/**
* Invoices, Payments, and USD Payments used to be three separate routes/pages
* with near-identical chrome. They're merged here as URL-linkable tabs
* (`?tab=`) on one page — each tab keeps the permission it was individually
* gated on before, and just doesn't render if the user lacks it.
*/
const TABS = [
{
key: "invoices",
label: "Invoices",
icon: Receipt,
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"Every invoice issued across bookings, warehouse fees and clearance charges.",
Panel: InvoicesPanel,
},
{
key: "payments",
label: "Payments",
icon: Wallet,
permission: FREIGHT_PERMS.payments.view,
subtitle: "View and reconcile booking payment transactions.",
Panel: PaymentsPanel,
},
{
key: "usd-payments",
label: "USD Payments",
icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
},
] as const;
type TabKey = (typeof TABS)[number]["key"];
export default function FinanceHubPage() {
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission));
const requested = searchParams.get("tab");
const active: TabKey =
visibleTabs.find((tab) => tab.key === requested)?.key ??
visibleTabs[0]?.key ??
"invoices";
const activeTab = visibleTabs.find((tab) => tab.key === active);
const handleChange = (value: string | null) => {
if (!value) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", value);
return next;
},
{ replace: true },
);
};
return (
<PageContainer>
<PageHeader title={activeTab?.label ?? "Invoices"} subtitle={activeTab?.subtitle} />
<Tabs value={active} onChange={handleChange} keepMounted={false}>
<Tabs.List>
{visibleTabs.map((tab) => (
<Tabs.Tab
key={tab.key}
value={tab.key}
leftSection={<tab.icon size={16} />}
>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
{visibleTabs.map((tab) => (
<Tabs.Panel key={tab.key} value={tab.key} pt="lg">
<tab.Panel />
</Tabs.Panel>
))}
</Tabs>
</PageContainer>
);
}

View File

@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import {
ActionIcon,
Button,
Card,
Center,
Container,
Grid,
Group,
Loader,
SimpleGrid,
@@ -12,7 +14,7 @@ import {
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
@@ -26,8 +28,11 @@ import {
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { LinkedEntityCard, type FieldRowProps } from "@/components/detail";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import type { Invoice } from "@/types/invoice";
function openPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
@@ -43,7 +48,17 @@ function openPdfBlob(blob: Blob, filename: string) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function InfoField({ label, value }: { label: string; value?: string | null }) {
function InfoField({
label,
value,
}: {
label: string;
value?: ReactNode;
}) {
const isEmpty =
value === undefined ||
value === null ||
(typeof value === "string" && !value.trim());
return (
<Stack gap={2}>
<Text
@@ -56,12 +71,75 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
{label}
</Text>
<Text size="sm" c="edr-text">
{value && value.trim() ? value : "—"}
{isEmpty ? "—" : value}
</Text>
</Stack>
);
}
/** Billed-to company, with its contact/registration details as quick-info rows. */
function RecipientCard({ invoice }: { invoice: Invoice }) {
const company = invoice.company;
const rows: FieldRowProps[] = [
{ label: "Profile", value: invoice.companyProfile?.reference },
{ label: "TIN", value: company?.tin },
{ label: "VAT No.", value: company?.vatNumber },
{ label: "Phone", value: company?.phone },
{ label: "Email", value: company?.email },
{ label: "Address", value: company?.address },
];
return (
<LinkedEntityCard
icon={Building2}
title="Recipient"
name={company?.name ?? "Unnamed company"}
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
rows={rows}
emptyMessage="No additional recipient details available."
/>
);
}
/** What the invoice was raised for — a booking's route/wagons when the
* source is a booking; otherwise just the source type and its raw id
* (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */
function SourceCard({ invoice }: { invoice: Invoice }) {
const isBooking = invoice.source === "booking";
const { data: booking } = useBookingDetail(
isBooking ? invoice.sourceId : undefined,
);
if (!isBooking) {
return (
<LinkedEntityCard
icon={FileText}
title="Source"
name={humanize(invoice.source)}
rows={[{ label: "Reference", value: invoice.sourceId }]}
/>
);
}
const route =
booking?.originYard && booking?.destinationYard
? `${booking.originYard.label}${booking.destinationYard.label}`
: undefined;
return (
<LinkedEntityCard
icon={FileText}
title="Source"
name={booking?.reference ?? invoice.sourceId}
to={`/dashboard/booking-requests/${invoice.sourceId}`}
rows={[
{ label: "Type", value: humanize(invoice.type) },
{ label: "Route", value: route },
{ label: "Wagons", value: booking?.wagonsRequired ?? undefined },
]}
/>
);
}
export default function InvoiceDetailPage() {
const { user } = useAuth();
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
@@ -121,7 +199,7 @@ export default function InvoiceDetailPage() {
]}
backTo="/dashboard/invoices"
title={invoice.invoiceNumber}
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
subtitle={humanize(invoice.source)}
meta={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
@@ -138,125 +216,130 @@ export default function InvoiceDetailPage() {
}
/>
<Stack gap="lg">
<Card>
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Summary
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Billed to" value={invoice.company?.name} />
<InfoField
label="Profile"
value={invoice.companyProfile?.reference}
/>
<InfoField label="Type" value={humanize(invoice.type)} />
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Amounts
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
</Stack>
</Card>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
</Stack>
</Card>
</Stack>
</Card>
</Grid.Col>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<RecipientCard invoice={invoice} />
<SourceCard invoice={invoice} />
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</PageContainer>
);
}

View File

@@ -21,7 +21,6 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
@@ -31,7 +30,8 @@ import {
type ColumnDef,
} from "@edr/ui-common";
export default function InvoicesPage() {
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
@@ -127,112 +127,103 @@ export default function InvoicesPage() {
);
return (
<PageContainer>
<PageHeader
title="Invoices"
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(
v === "all" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(
v === "all" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
</PageContainer>
</Box>
</Stack>
</Card>
);
}

View File

@@ -25,7 +25,6 @@ import {
humanize,
} from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
@@ -95,7 +94,8 @@ function windowClosed(row: OfflineUsdInvoice): boolean {
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
}
export default function UsdPaymentsPage() {
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function UsdPaymentsPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
@@ -262,24 +262,7 @@ export default function UsdPaymentsPage() {
);
return (
<PageContainer>
<PageHeader
title="USD Payments"
subtitle="USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -324,6 +307,16 @@ export default function UsdPaymentsPage() {
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
@@ -421,6 +414,6 @@ export default function UsdPaymentsPage() {
</Stack>
)}
</Modal>
</PageContainer>
</>
);
}

View File

@@ -25,7 +25,7 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { KpiStrip } from "@/components/page";
import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import {
@@ -101,7 +101,8 @@ function formatDate(iso: string | null): string {
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
/** Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function PaymentsPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
@@ -205,12 +206,7 @@ export default function PaymentsPage() {
];
return (
<PageContainer>
<PageHeader
title="Payments"
subtitle="View and reconcile booking payment transactions."
/>
<Stack gap="lg">
<KpiStrip
loading={summaryLoading}
items={[
@@ -360,6 +356,6 @@ export default function PaymentsPage() {
</Box>
</Stack>
</Card>
</PageContainer>
</Stack>
);
}

View File

@@ -1,445 +1,14 @@
import {
Button,
Card,
Group,
MultiSelect,
Select,
Text,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { Download, FileSpreadsheet, Printer, RotateCcw } from "lucide-react";
import { useMemo } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import * as XLSX from "xlsx";
import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS } from "@edr/types";
import { useParams } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { overviewChartColors } from "@/components/overview/overview.styles";
import { api } from "@/services/api";
import type { ReportQueryInput, ReportRow } from "@/types/reports";
import {
REPORT_CONFIG_BY_KEY,
type ReportColumn,
type ReportConfig,
} from "./reportConfigs";
const compact = new Intl.NumberFormat("en", { notation: "compact" });
const UNIT_SUFFIX = { ETB: " ETB", t: " t", "%": "%", min: " min" } as const;
function formatCell(value: unknown, col: ReportColumn): string {
if (value === null || value === undefined || value === "") return "—";
if (col.unit || col.numeric) {
const n = Number(value);
if (!Number.isNaN(n)) {
return `${n.toLocaleString()}${col.unit ? UNIT_SUFFIX[col.unit] : ""}`;
}
}
return String(value);
}
const toDate = (s: string | null): Date | null => (s ? new Date(s) : null);
// Mantine DateInput onChange emits a date string (or null).
const toParam = (d: Date | string | null): string | null => {
if (!d) return null;
return typeof d === "string" ? d.slice(0, 10) : d.toISOString().slice(0, 10);
};
function downloadBlob(content: BlobPart, type: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function exportCsv(config: ReportConfig, rows: ReportRow[]) {
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const lines = [
config.columns.map((c) => esc(c.label)).join(","),
...rows.map((r) => config.columns.map((c) => esc(r[c.key])).join(",")),
];
downloadBlob(lines.join("\n"), "text/csv;charset=utf-8", `${config.key}.csv`);
}
function exportXlsx(config: ReportConfig, rows: ReportRow[]) {
const sheetRows = rows.map((r) =>
Object.fromEntries(config.columns.map((c) => [c.label, r[c.key] ?? ""])),
);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
wb,
XLSX.utils.json_to_sheet(sheetRows),
config.title.slice(0, 31),
);
XLSX.writeFile(wb, `${config.key}.xlsx`);
}
function ReportChartView({
config,
rows,
}: {
config: ReportConfig;
rows: ReportRow[];
}) {
const chart = config.chart;
const data = useMemo(() => {
if (!chart) return [];
const sliced = chart.topN ? rows.slice(0, chart.topN) : rows;
// xKey "a+b" concatenates columns (e.g. origin+destination → "A → B").
const keys = chart.xKey.split("+");
return sliced.map((r) => ({
...r,
__x:
keys.length > 1
? keys.map((k) => String(r[k] ?? "")).join(" → ")
: String(r[chart.xKey] ?? ""),
}));
}, [chart, rows]);
if (!chart) return null;
if (data.length === 0) {
return (
<Card withBorder shadow="sm">
<Text c="dimmed" ta="center" py="xl">
No data for the selected filters
</Text>
</Card>
);
}
const ChartComponent =
chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart;
return (
<Card withBorder shadow="sm">
<ResponsiveContainer width="100%" height={280}>
<ChartComponent data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="__x" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(v: number) => compact.format(v)}
width={56}
/>
<Tooltip formatter={(value) => Number(value ?? 0).toLocaleString()} />
{chart.series.length > 1 ? <Legend /> : null}
{chart.series.map((s, i) => {
const color =
overviewChartColors.pipeline[i % overviewChartColors.pipeline.length];
if (chart.type === "bar") {
return (
<Bar key={s.key} dataKey={s.key} name={s.label} fill={color} radius={[4, 4, 0, 0]} />
);
}
if (chart.type === "line") {
return (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
strokeWidth={2}
dot={false}
/>
);
}
return (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
fill={color}
fillOpacity={0.15}
strokeWidth={2}
/>
);
})}
</ChartComponent>
</ResponsiveContainer>
</Card>
);
}
import { ReportView } from "@/components/reports/ReportView";
import { PageContainer } from "@/components/page";
export default function ReportPage() {
const { reportKey = "" } = useParams<{ reportKey: string }>();
const config = REPORT_CONFIG_BY_KEY.get(reportKey);
const [params, setParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const setParam = (name: string, value: string | null) => {
setParams(
(prev) => {
if (value) prev.set(name, value);
else prev.delete(name);
return prev;
},
{ replace: true },
);
setPagination((p) => ({ ...p, pageIndex: 0 }));
};
const input: ReportQueryInput = {
key: reportKey,
dateFrom: params.get("dateFrom") ?? undefined,
dateTo: params.get("dateTo") ?? undefined,
granularity:
(params.get("granularity") as ReportQueryInput["granularity"]) ?? undefined,
yardIds: params.get("yardIds") ?? undefined,
statuses: params.get("statuses") ?? undefined,
direction: params.get("direction") ?? undefined,
freightType: params.get("freightType") ?? undefined,
};
const reportQuery = useQuery(
api.reports.run.queryOptions({
input,
placeholderData: keepPreviousData,
staleTime: 30_000,
enabled: Boolean(config),
}),
);
const yardsQuery = useQuery(
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: Boolean(config?.filters.includes("yards")),
}),
);
if (!config) {
return (
<PageContainer>
<PageHeader title="Unknown report" backTo="/dashboard/reports" />
<Text>
This report does not exist. <Link to="/dashboard/reports">Back to reports</Link>
</Text>
</PageContainer>
);
}
const rows = reportQuery.data?.rows ?? [];
const kpis = reportQuery.data?.kpis ?? [];
const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize));
const columns: ColumnDef<ReportRow, unknown>[] = config.columns.map((col) => ({
accessorKey: col.key,
header: col.label,
cell: (info) => formatCell(info.getValue(), col),
}));
const tableStatus = reportQuery.isLoading
? "loading"
: reportQuery.isError
? "error"
: "success";
return (
<PageContainer>
<PageHeader
title={config.title}
subtitle={config.description}
backTo="/dashboard/reports"
action={
<Group gap="xs">
<Button
variant="default"
size="xs"
leftSection={<Download size={14} />}
onClick={() => exportCsv(config, rows)}
disabled={rows.length === 0}
>
CSV
</Button>
<Button
variant="default"
size="xs"
leftSection={<FileSpreadsheet size={14} />}
onClick={() => exportXlsx(config, rows)}
disabled={rows.length === 0}
>
Excel
</Button>
<Button
variant="default"
size="xs"
leftSection={<Printer size={14} />}
onClick={() => window.print()}
>
Print
</Button>
</Group>
}
/>
<Card withBorder shadow="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="From"
size="xs"
clearable
value={toDate(params.get("dateFrom"))}
maxDate={toDate(params.get("dateTo")) ?? undefined}
onChange={(d) => setParam("dateFrom", toParam(d))}
placeholder="All time"
/>
<DateInput
label="To"
size="xs"
clearable
value={toDate(params.get("dateTo"))}
minDate={toDate(params.get("dateFrom")) ?? undefined}
onChange={(d) => setParam("dateTo", toParam(d))}
placeholder="All time"
/>
{config.filters.includes("granularity") ? (
<Select
label="Group by"
size="xs"
data={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={params.get("granularity") ?? "day"}
onChange={(v) => setParam("granularity", v)}
allowDeselect={false}
/>
) : null}
{config.filters.includes("yards") ? (
<MultiSelect
label="Yards"
size="xs"
searchable
clearable
w={220}
data={(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label,
}))}
value={params.get("yardIds")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)}
placeholder="All yards"
/>
) : null}
{config.filters.includes("direction") ? (
<Select
label="Direction"
size="xs"
clearable
data={ALL_TRADE_DIRECTIONS.map((d) => ({
value: d,
label: TRADE_DIRECTION_LABELS[d],
}))}
value={params.get("direction")}
onChange={(v) => setParam("direction", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("freightType") ? (
<Select
label="Freight type"
size="xs"
clearable
data={["CONTAINER", "BULK"]}
value={params.get("freightType")}
onChange={(v) => setParam("freightType", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("statuses") && config.statusOptions ? (
<MultiSelect
label="Status"
size="xs"
searchable
clearable
w={220}
data={config.statusOptions}
value={params.get("statuses")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("statuses", v.length ? v.join(",") : null)}
placeholder="Default (active)"
/>
) : null}
<Button
variant="subtle"
size="xs"
leftSection={<RotateCcw size={14} />}
onClick={() => setParams({}, { replace: true })}
>
Reset
</Button>
</Group>
</Card>
<KpiStrip
loading={reportQuery.isLoading}
items={kpis.map((k) => ({
label: k.label,
value: k.value.toLocaleString(),
hint: k.unit,
}))}
/>
<ReportChartView config={config} rows={rows} />
<DataTable
columns={columns}
data={rows}
status={tableStatus}
emptyMessage="No data for the selected filters"
error={
reportQuery.isError
? {
message: "Failed to load report",
onRetry: () => void reportQuery.refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: rows.length,
}}
tableOptions={{
manualPagination: false,
state: { pagination },
onPaginationChange: setPagination,
autoResetPageIndex: false,
}}
footer={({ table, pagination: p }) => (
<DataTableFooter
table={table}
pagination={p}
options={{ labels: { items: "rows" } }}
/>
)}
/>
<ReportView reportKey={reportKey} pageHeader />
</PageContainer>
);
}

View File

@@ -1,159 +0,0 @@
import {
ActionIcon,
Badge,
Card,
Group,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Search, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import {
REPORT_CONFIGS,
REPORT_DOMAINS,
type ReportConfig,
} from "./reportConfigs";
const FAVORITES_KEY = "reports.favorites";
const loadFavorites = (): string[] => {
try {
return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]");
} catch {
return [];
}
};
function ReportCard({
config,
favorite,
onToggleFavorite,
}: {
config: ReportConfig;
favorite: boolean;
onToggleFavorite: () => void;
}) {
const navigate = useNavigate();
return (
<Card
withBorder
shadow="sm"
className="cursor-pointer transition-colors hover:bg-gray-50"
onClick={() => navigate(`/dashboard/reports/${config.key}`)}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{config.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{config.description}
</Text>
</div>
<ActionIcon
variant="subtle"
color={favorite ? "yellow" : "gray"}
aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
>
<Star size={16} fill={favorite ? "currentColor" : "none"} />
</ActionIcon>
</Group>
<Badge mt="sm" size="sm" variant="light">
{config.domain}
</Badge>
</Card>
);
}
export default function ReportsHubPage() {
const [search, setSearch] = useState("");
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
const toggleFavorite = (key: string) => {
setFavorites((prev) => {
const next = prev.includes(key)
? prev.filter((k) => k !== key)
: [...prev, key];
localStorage.setItem(FAVORITES_KEY, JSON.stringify(next));
return next;
});
};
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return REPORT_CONFIGS;
return REPORT_CONFIGS.filter(
(c) =>
c.title.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q),
);
}, [search]);
const pinned = visible.filter((c) => favorites.includes(c.key));
const renderGrid = (configs: ReportConfig[]) => (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{configs.map((c) => (
<ReportCard
key={c.key}
config={c}
favorite={favorites.includes(c.key)}
onToggleFavorite={() => toggleFavorite(c.key)}
/>
))}
</SimpleGrid>
);
return (
<PageContainer>
<PageHeader
title="Reports"
subtitle="Operational, commercial and financial reporting"
action={
<TextInput
size="xs"
w={240}
leftSection={<Search size={14} />}
placeholder="Search reports…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
}
/>
{pinned.length ? (
<Stack gap="sm">
<Title order={4}>Favorites</Title>
{renderGrid(pinned)}
</Stack>
) : null}
{REPORT_DOMAINS.map((domain) => {
const configs = visible.filter((c) => c.domain === domain);
if (!configs.length) return null;
return (
<Stack key={domain} gap="sm">
<Title order={4}>{domain}</Title>
{renderGrid(configs)}
</Stack>
);
})}
{visible.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No reports match {search}
</Text>
) : null}
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { api } from "@/services/api";
/**
* `/dashboard/reports` has no page of its own — it forwards to the first
* report the caller has access to (catalog order = registration order,
* already permission-filtered server-side), or home if they have none.
*/
export default function ReportsIndexRedirect() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}

View File

@@ -1,445 +0,0 @@
import { BookingStatus } from "@edr/types";
export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data";
export type ReportColumnUnit = "ETB" | "t" | "%" | "min";
export interface ReportColumn {
key: string;
label: string;
/** Numeric unit — formats the cell (thousands separators, suffix). */
unit?: ReportColumnUnit;
numeric?: boolean;
}
export interface ReportChart {
type: "area" | "line" | "bar";
xKey: string;
series: { key: string; label: string }[];
/** Chart only the first N rows (rows arrive sorted by the backend). */
topN?: number;
}
export type ReportFilterKey =
| "granularity"
| "yards"
| "direction"
| "freightType"
| "statuses";
export interface ReportConfig {
key: string;
title: string;
description: string;
domain: ReportDomain;
filters: ReportFilterKey[];
/** Options for the `statuses` filter, when enabled. */
statusOptions?: string[];
chart?: ReportChart;
columns: ReportColumn[];
}
// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias.
const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))];
// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum
// in @edr/types yet).
const CONTRACT_STATUSES = [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"SUSPENDED",
"CONTRACT_CLOSED",
"EXPIRED",
"REJECTED",
"CANCELLED",
"RENEWAL_DRAFT",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
"AMENDMENTS_PROPOSED",
"ARCHIVED",
];
const INVOICE_STATUSES = [
"ISSUED",
"PENDING",
"PAYMENT_PROCESSING",
"PARTIALLY_PAID",
"PAID",
"OVERDUE",
"REFUNDED",
];
export const REPORT_CONFIGS: ReportConfig[] = [
{
key: "bookings-trend",
title: "Bookings Trend",
description: "Booking volume, tonnage and revenue over time",
domain: "Commercial",
filters: ["granularity", "yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "area",
xKey: "period",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
},
columns: [
{ key: "period", label: "Period" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-customer",
title: "Revenue by Customer",
description: "Ranked customers by booking revenue",
domain: "Commercial",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-lane",
title: "Revenue by Lane",
description: "Origin → destination lanes by tonnage and revenue",
domain: "Commercial",
filters: ["direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "contract-utilization",
title: "Contract Utilization",
description: "Committed scope caps vs booked tonnage per contract",
domain: "Commercial",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Contract" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "kind", label: "Kind" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "committed", label: "Committed", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "utilization_pct", label: "Utilization", unit: "%" },
],
},
{
key: "train-on-time",
title: "Train On-Time Performance",
description: "Departure punctuality and delays by lane (60-min grace)",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "on_time_pct", label: "On-time %" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "departed", label: "Departed", numeric: true },
{ key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" },
{ key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" },
{ key: "on_time_pct", label: "On-time", unit: "%" },
],
},
{
key: "schedule-fill-rate",
title: "Schedule Fill Rate",
description: "Booked tonnage vs wagon capacity per train schedule",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "line",
xKey: "departure",
series: [{ key: "fill_pct", label: "Fill %" }],
},
columns: [
{ key: "train_number", label: "Train" },
{ key: "departure", label: "Departure" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "wagon_count", label: "Wagons", numeric: true },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "fill_pct", label: "Fill", unit: "%" },
],
},
{
key: "trips-per-route",
title: "Trips per Route",
description: "Completed trips and tonnage hauled per lane",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "trips", label: "Trips" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "tons_hauled", label: "Tonnage hauled", unit: "t" },
{ key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" },
],
},
{
key: "invoiced-vs-collected",
title: "Invoiced vs Collected",
description: "Billing issued vs payments received over time",
domain: "Finance",
filters: ["granularity", "direction"],
chart: {
type: "line",
xKey: "period",
series: [
{ key: "invoiced", label: "Invoiced (ETB)" },
{ key: "collected", label: "Collected (ETB)" },
],
},
columns: [
{ key: "period", label: "Period" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "invoiced", label: "Invoiced", unit: "ETB" },
{ key: "collected", label: "Collected", unit: "ETB" },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
],
},
{
key: "aging-receivables",
title: "Aging Receivables",
description: "Outstanding invoice balances by age bucket per customer",
domain: "Finance",
filters: ["direction", "statuses"],
statusOptions: INVOICE_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "outstanding", label: "Outstanding (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
{ key: "current", label: "Current", unit: "ETB" },
{ key: "overdue_0_30", label: "030d", unit: "ETB" },
{ key: "overdue_31_60", label: "3160d", unit: "ETB" },
{ key: "overdue_61_90", label: "6190d", unit: "ETB" },
{ key: "overdue_90_plus", label: "90d+", unit: "ETB" },
],
},
{
key: "revenue-by-payment-method",
title: "Revenue by Payment Method",
description: "Successful payments broken down by method",
domain: "Finance",
filters: ["direction"],
chart: {
type: "bar",
xKey: "method",
series: [{ key: "amount", label: "Amount (ETB)" }],
},
columns: [
{ key: "method", label: "Method" },
{ key: "payments", label: "Payments", numeric: true },
{ key: "amount", label: "Amount", unit: "ETB" },
],
},
// --- Record-level list exports (Data domain) — filtered or full dumps ---
{
key: "bookings-list",
title: "Bookings Export",
description: "Booking records with customer, lane, cargo, amounts",
domain: "Data",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "created", label: "Created" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "freight_type", label: "Freight" },
{ key: "direction", label: "Direction" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "cargo", label: "Cargo" },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "payment_status", label: "Payment" },
{ key: "scheduling_status", label: "Scheduling" },
],
},
{
key: "contracts-list",
title: "Contracts Export",
description: "Contract records with validity, status, customer",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "customer", label: "Customer" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "direction", label: "Direction" },
{ key: "freight_type", label: "Freight" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "created", label: "Created" },
],
},
{
key: "schedules-list",
title: "Train Schedules Export",
description: "Schedule records with planned vs actual times",
domain: "Data",
filters: ["yards", "direction", "statuses"],
statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"],
columns: [
{ key: "train_number", label: "Train" },
{ key: "reference", label: "Reference" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "scheduled_departure", label: "Sched. departure" },
{ key: "actual_departure", label: "Actual departure" },
{ key: "scheduled_arrival", label: "Sched. arrival" },
{ key: "actual_arrival", label: "Actual arrival" },
{ key: "max_wagons", label: "Max wagons", numeric: true },
{ key: "wagon_count", label: "Wagons", numeric: true },
],
},
{
key: "fleet-wagons",
title: "Wagons Export",
description: "Wagon fleet with type, capacity, status, location",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"],
columns: [
{ key: "wagon_number", label: "Wagon" },
{ key: "type", label: "Type" },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "fleet-locomotives",
title: "Locomotives Export",
description: "Locomotive fleet with type, pull capacity, status",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"],
columns: [
{ key: "code", label: "Code" },
{ key: "name", label: "Name" },
{ key: "locomotive_type", label: "Type" },
{ key: "max_pull_tons", label: "Max pull", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "customers-list",
title: "Customers Export",
description: "Company records with type, status, TIN",
domain: "Data",
filters: ["statuses"],
statusOptions: ["pending", "active"],
columns: [
{ key: "name", label: "Name" },
{ key: "type", label: "Type" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "tin", label: "TIN" },
{ key: "approved", label: "Approved" },
{ key: "created", label: "Created" },
],
},
{
key: "payments-list",
title: "Payments Export",
description: "Payment transactions with method, status, references",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: [
"action-required",
"processing",
"success",
"failed",
"canceled",
"refunded",
],
columns: [
{ key: "created", label: "Created" },
{ key: "method", label: "Method" },
{ key: "status", label: "Status" },
{ key: "currency", label: "Currency" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "transaction_id", label: "Transaction" },
{ key: "merchant_order_id", label: "Merchant order" },
{ key: "paid", label: "Paid" },
],
},
];
export const REPORT_CONFIG_BY_KEY = new Map(
REPORT_CONFIGS.map((c) => [c.key, c]),
);
export const REPORT_DOMAINS: ReportDomain[] = [
"Commercial",
"Operations",
"Finance",
"Data",
];

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Save, Trash2 } from "lucide-react";
import { LogoUpload } from "@/components/contracts/LogoUpload";
import {
useClearLogo,
useSetLogo,
useLogoSettingsQuery,
} from "@/hooks/useLogoSettings";
/**
* The ONE company logo, read by every document path server-side via
* LogoSettingsService: invoices and receipts, contract cover pages, warehouse
* GRN/release/handover papers, train-scheduling manifests, and the payment
* receipt. Single global image — no per-document choice.
*/
export default function LogoSettingsPage() {
const { data, isLoading } = useLogoSettingsQuery();
const setLogo = useSetLogo();
const clearLogo = useClearLogo();
const [draft, setDraft] = useState<string | null>(null);
useEffect(() => {
setDraft(null);
}, [data?.logoImageUrl]);
const value = draft !== null ? draft : (data?.logoImageUrl ?? null);
const dirty = draft !== null && draft !== data?.logoImageUrl;
const handleSave = async () => {
if (!draft) return;
await setLogo.mutateAsync(draft);
};
const handleClear = async () => {
if (!data?.logoImageUrl) return;
await clearLogo.mutateAsync();
};
return (
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Company logo</CardTitle>
<CardDescription>
The single EDR logo, applied to every generated document
invoices and receipts, contracts, warehouse papers, train-scheduling
manifests, and payment receipts. Replacing it here changes it
everywhere at once.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<LogoUpload
value={isLoading ? null : value}
onChange={setDraft}
label="Company logo"
description="Shown in the header of every generated document."
/>
<div className="flex items-center gap-2">
<Button onClick={handleSave} disabled={!dirty || setLogo.isPending}>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{data?.logoImageUrl && !dirty && (
<Button
variant="outline"
onClick={handleClear}
disabled={clearLogo.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -19,7 +19,8 @@ import {
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -792,24 +793,19 @@ export default function BatchBoardPage() {
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
<DatePickerInput
type="range"
size="sm"
radius="lg"
placeholder="Departs from"
value={departureFrom}
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
placeholder="Departure date range"
value={[departureFrom, departureTo]}
onChange={([from, to]) => {
setDepartureFrom(from ? new Date(from) : null);
setDepartureTo(to ? new Date(to) : null);
}}
presets={getDateRangePresets()}
clearable
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
size="sm"
radius="lg"
placeholder="Departs to"
value={departureTo}
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
clearable
w={140}
w={230}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select

View File

@@ -1,4 +1,5 @@
import {
ActionIcon,
Alert,
Badge,
Box,
@@ -7,6 +8,7 @@ import {
Group,
List,
Loader,
Menu,
Modal,
Paper,
RingProgress,
@@ -19,7 +21,6 @@ import {
import { isAxiosError } from "axios";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
@@ -29,6 +30,7 @@ import {
FileText,
History as HistoryIcon,
LayoutGrid,
MoreHorizontal,
Navigation,
Package,
PackageCheck,
@@ -42,7 +44,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { KpiStrip, PageContainer } from "@/components/page";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
@@ -886,284 +888,248 @@ export default function TrainScheduleV2DetailPage() {
return (
<PageContainer>
<Button
component={Link}
to="/dashboard/operations/train-scheduling-v2"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedules
</Button>
<Paper
radius="xl"
p="xl"
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="#F2A516">
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.train?.trainName ? (
<Text fw={700} style={{ color: "#0f172a" }}>
{schedule.train.trainName}
</Text>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
Train {schedule.train.code}
</Text>
) : null}
</Group>
{/* Voyage (train) number and trade direction — the two things
operations identify a run by, so they read at a glance
rather than as small badges among the rest. */}
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Train No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.voyageNumber}
</Text>
</Box>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Direction
</Text>
<Text
fw={800}
lh={1.1}
tt="uppercase"
style={{
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
<PageHeader
title={schedule.route?.name ?? "Train schedule"}
backTo="/dashboard/operations/train-scheduling-v2"
breadcrumbs={[
{
label: "Train schedules",
href: "/dashboard/operations/train-scheduling-v2",
},
{ label: schedule.reference ?? "Schedule" },
]}
subtitle={
schedule.train ? (
<Text size="sm" c="dimmed">
{schedule.train.trainName ?? `Train ${schedule.train.code}`}
{schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""}
</Text>
) : undefined
}
meta={
<Group gap={6} wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
{gatepassApplies && gatepassSecured ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={12} />}
>
Gate pass secured
</Badge>
) : null}
{previewResult ? (
<Badge
radius="sm"
variant="light"
color={previewResult.valid ? "edr-green" : "red"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
</Group>
</Stack>
</Group>
<Group gap="sm">
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
gradient={{ from: "#0f172a", to: "#334155" }}
radius="lg"
size="sm"
leftSection={<Eye size={16} />}
onClick={() => setVisualization3DOpen(true)}
>
3D Visualization
</Button>
) : null}
{canPrintMarshalling ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={downloadMarshalling.isPending}
onClick={() => void openMarshallingDocument()}
>
Marshalling PDF
</Button>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: "Intercity marshalling ready",
variant: "INTERCITY",
})
}
>
Intercity Marshalling
</Button>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
color="edr-green"
radius="lg"
size="sm"
leftSection={<Navigation size={16} />}
>
Track train
</Button>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Clock size={16} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="default"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
{gatepassApplies ? (
gatepassSecured ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<CheckCircle2 size={16} />}
disabled
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Group>
}
action={
<Group gap="sm" wrap="nowrap">
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
gradient={{ from: "#0f172a", to: "#334155" }}
radius="lg"
size="compact-sm"
leftSection={<Eye size={16} />}
onClick={() => setVisualization3DOpen(true)}
>
3D Visualization
</Button>
) : null}
<Menu position="bottom-end" width={240} withinPortal>
<Menu.Target>
<ActionIcon variant="default" size="lg" radius="md" aria-label="More actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{canPrintMarshalling ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() => void openMarshallingDocument()}
>
Gate pass secured
</Button>
) : (
<Button
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={secureGatepass.isPending}
Marshalling PDF
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: "Intercity marshalling ready",
variant: "INTERCITY",
})
}
>
Intercity Marshalling
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
leftSection={<Navigation size={15} />}
>
Track train
</Menu.Item>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Menu.Item
leftSection={<Clock size={15} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
Reschedule train
</Menu.Item>
) : null}
{gatepassApplies && !gatepassSecured ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Secure gate pass
</Button>
)
) : null}
</Group>
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
</Group>
}
/>
{previewResult ? (
<Badge
size="lg"
radius="sm"
variant="light"
color={previewResult.valid ? "edr-green" : "red"}
leftSection={
<Box
w={8}
h={8}
{/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
across the room, so these stay large rather than folding into the
numeric KpiStrip below. */}
<Paper radius="xl" p="lg">
<Stack gap="md">
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Train No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.voyageNumber}
</Text>
</Box>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Direction
</Text>
<Text
fw={800}
lh={1.1}
tt="uppercase"
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
/>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
</Stack>
</Paper>

View File

@@ -143,6 +143,9 @@ export default function TrainScheduleV2ListPage() {
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// "" = a normal customer train; an id dedicates the departure to that
// shipping line and hides it from every customer-facing view.
const [shippingLineCompanyId, setShippingLineCompanyId] = useState("");
// Booking window for the schedule being created: off = inherit the live global
// rules (the default), on = the values in `windowForm` are frozen onto it.
const [configureWindow, setConfigureWindow] = useState(false);
@@ -219,6 +222,15 @@ export default function TrainScheduleV2ListPage() {
enabled: Boolean(routeId),
}),
);
// For the create modal's dedication picker. 100 covers every line EDR deals
// with; fetched only while the modal is open.
const shippingLinesQuery = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
enabled: createOpen,
staleTime: 5 * 60_000,
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const dispatchSchedule = useMutation(
api.trainScheduling.dispatchSchedule.mutationOptions(),
@@ -559,12 +571,14 @@ export default function TrainScheduleV2ListPage() {
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
reverseWagonOrder,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(windowRule ? { windowRule } : {}),
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setReverseWagonOrder(false);
setShippingLineCompanyId("");
setConfigureWindow(false);
setWindowForm(null);
setCreateOpen(false);
@@ -857,6 +871,19 @@ export default function TrainScheduleV2ListPage() {
: "Select a route first"
}
/>
<Select
label="Shipping line (optional)"
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
placeholder="None — normal customer train"
clearable
searchable
data={(shippingLinesQuery.data?.items ?? [])
.filter((line) => line.status === "active")
.map((line) => ({ value: line.id, label: line.name }))}
value={shippingLineCompanyId || null}
onChange={(v) => setShippingLineCompanyId(v ?? "")}
comboboxProps={{ withinPortal: true }}
/>
<Checkbox
label="Reverse wagon order"
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."

View File

@@ -551,7 +551,7 @@ const Top: React.FC<HeaderProps> = ({
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
>
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.changePassword")}</span>

View File

@@ -14,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
export interface ReportFilterOption {
id: string;
@@ -167,33 +168,18 @@ export function SectorReportFilters({
</Select>
</label>
<label className="space-y-1.5 text-xs font-medium">
<label className="space-y-1.5 text-xs font-medium sm:col-span-2">
<span className="flex items-center gap-2">
<CalendarDays className="size-4 text-primary" />
From date
Date range
</span>
<input
type="date"
value={fromDate}
max={toDate || undefined}
<DateRangePicker
value={{ from: parseDay(fromDate), to: parseDay(toDate) }}
onChange={(range) => {
onFromDateChange(formatDay(range.from));
onToDateChange(formatDay(range.to));
}}
disabled={!unitId || loading}
onChange={(event) => onFromDateChange(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</label>
<label className="space-y-1.5 text-xs font-medium">
<span className="flex items-center gap-2">
<CalendarDays className="size-4 text-primary" />
To date
</span>
<input
type="date"
value={toDate}
min={fromDate || undefined}
disabled={!unitId || loading}
onChange={(event) => onToDateChange(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</label>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { DataTable } from "@/record-management/common/DataTable";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectContent,
@@ -383,23 +384,13 @@ const ExternalIncomingRecords = () => {
) : (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDispatchedDate}
onChange={(e) => setFromDispatchedDate(e.target.value)}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDispatchedDate}
onChange={(e) => setToDispatchedDate(e.target.value)}
min={fromDispatchedDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDispatchedDate), to: parseDay(toDispatchedDate) }}
onChange={(range) => {
setFromDispatchedDate(formatDay(range.from));
setToDispatchedDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
{/* From Date */}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { DataTable } from "@/record-management/common/DataTable";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectContent,
@@ -326,23 +327,13 @@ const InternalIncomingRecordsV2 = () => {
) : (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDispatchedDate}
onChange={(e) => setFromDispatchedDate(e.target.value)}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDispatchedDate}
onChange={(e) => setToDispatchedDate(e.target.value)}
min={fromDispatchedDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDispatchedDate), to: parseDay(toDispatchedDate) }}
onChange={(range) => {
setFromDispatchedDate(formatDay(range.from));
setToDispatchedDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
{/* From Date */}

View File

@@ -17,6 +17,7 @@ import {
} from "@/shared/common/ui/select";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import { Badge } from "@/shared/common/ui/badge";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useLocalizedName } from "@/shared/common/localizedName";
@@ -521,29 +522,13 @@ const UserRecords = () => {
{selectedFilter?.type === "dateRange" ? (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddFilter();
}}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddFilter();
}}
min={fromDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDate), to: parseDay(toDate) }}
onChange={(range) => {
setFromDate(formatDay(range.from));
setToDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
<div className="relative">

View File

@@ -0,0 +1,50 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
export type ContactChannel = "email" | "phone";
export interface ChangePasswordPayload {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
export interface SendContactOtpPayload {
channel: ContactChannel;
/** The NEW email/phone to verify — the OTP is sent here, not to the current one. */
value: string;
}
export interface UpdateContactPayload extends SendContactOtpPayload {
otp: string;
}
export const accountService = {
/** PATCH /auth/change-password — generic IAM route, works for any user type. */
changePassword: async (payload: ChangePasswordPayload): Promise<void> => {
const response = await client.patch("/auth/change-password", payload);
unwrap(response.data);
},
/** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */
sendContactOtp: async (
payload: SendContactOtpPayload,
): Promise<{ sentTo: string }> => {
const response = await client.post<{ sentTo: string }>(
"/me/contact/otp",
payload,
);
return unwrap(response.data);
},
/** PATCH /me/contact — verifies the OTP and writes the new email/phone. */
updateContact: async (
payload: UpdateContactPayload,
): Promise<{ success: true; value: string }> => {
const response = await client.patch<{ success: true; value: string }>(
"/me/contact",
payload,
);
return unwrap(response.data);
},
};

View File

@@ -142,6 +142,12 @@ import type {
WarehouseZone,
} from "@/types/warehouse";
import { endpoint } from "@/utils/endpoint";
import {
accountService,
type ChangePasswordPayload,
type SendContactOtpPayload,
type UpdateContactPayload,
} from "./account.service";
import {
BookingListFilter,
bookingsService,
@@ -175,7 +181,7 @@ import {
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import { reportsService } from "./reports.service";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports";
import {
paymentsService,
type PaginatedPayments,
@@ -2189,6 +2195,29 @@ export const api = {
),
},
account: {
changePassword: endpoint<ChangePasswordPayload, void>(
"me",
"change-password",
(payload) => accountService.changePassword(payload),
),
sendContactOtp: endpoint<SendContactOtpPayload, { sentTo: string }>(
"me",
"send-contact-otp",
(payload) => accountService.sendContactOtp(payload),
),
updateContact: endpoint<
UpdateContactPayload,
{ success: true; value: string }
>(
"me",
"update-contact",
(payload) => accountService.updateContact(payload),
),
},
signatures: {
mySignature: endpoint<void, SavedSignature | null>(
"me",
@@ -3045,7 +3074,10 @@ export const api = {
},
reports: {
run: endpoint<ReportQueryInput, ReportResult>(
catalog: endpoint<void, ReportCatalogEntry[]>("reports", "catalog", () =>
reportsService.catalog(),
),
run: endpoint<ReportRunParams, ReportRunResult>(
"reports",
"run",
(input) => reportsService.run(input),

View File

@@ -16,6 +16,8 @@ export interface BookingListFilter {
tab?: string;
// customerId?: string;
companyId?: string;
/** Bookings drawn down under this contract (contract detail's Shipments tab). */
contractId?: string;
freightType?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string;
@@ -169,6 +171,7 @@ export const bookingsService = {
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.contractId) params.contractId = filter.contractId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;

View File

@@ -0,0 +1,31 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = "/logo-settings";
/** Company logo used on every generated document. */
export interface LogoSettings {
logoImageUrl: string | null;
updatedById: string | null;
updatedAt: string | null;
}
export const logoSettingsService = {
get: async (): Promise<LogoSettings> => {
const response = await client.get<ApiResponse<LogoSettings>>(BASE);
return unwrap(response.data);
},
set: async (logoImageBase64: string): Promise<LogoSettings> => {
const response = await client.put<ApiResponse<LogoSettings>>(BASE, {
logoImageBase64,
});
return unwrap(response.data);
},
clear: async (): Promise<LogoSettings> => {
const response = await client.delete<ApiResponse<LogoSettings>>(BASE);
return unwrap(response.data);
},
};

View File

@@ -1,14 +1,31 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports";
const R = URL_CONSTANTS.REPORTS;
export const reportsService = {
run: async ({ key, ...params }: ReportQueryInput): Promise<ReportResult> => {
const response = await client.get<ReportResult>(
URL_CONSTANTS.REPORTS.RUN(key),
{ params },
);
catalog: async (): Promise<ReportCatalogEntry[]> => {
const response = await client.get(R.CATALOG);
return unwrap(response.data);
},
run: async ({ key, ...params }: ReportRunParams): Promise<ReportRunResult> => {
const response = await client.get(R.RUN(key), { params });
return unwrap(response.data);
},
/** Streams the export file as a blob — caller triggers the browser save. */
download: async (
key: string,
format: "xlsx" | "pdf",
params: Omit<ReportRunParams, "key" | "page" | "pageSize">,
): Promise<Blob> => {
const response = await client.get(R.EXPORT(key), {
params: { ...params, format },
responseType: "blob",
});
return response.data as Blob;
},
};

View File

@@ -0,0 +1,197 @@
import * as React from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import {
format,
isSameDay,
isSameYear,
startOfDay,
endOfDay,
startOfMonth,
endOfMonth,
startOfYear,
subDays,
subMonths,
} from "date-fns";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/common/ui/button";
import { Calendar } from "@/shared/common/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
export interface DateRangeValue {
from?: Date;
to?: Date;
}
/** Parse a `YYYY-MM-DD` string as a local calendar day (avoids the UTC-midnight timezone shift `new Date(str)` has). */
export function parseDay(value?: string | null): Date | undefined {
if (!value) return undefined;
const [y, m, d] = value.split("-").map(Number);
if (!y || !m || !d) return undefined;
return new Date(y, m - 1, d);
}
/** Format a `Date` back to `YYYY-MM-DD`, for call sites still storing plain date strings. */
export function formatDay(date?: Date): string {
return date ? format(date, "yyyy-MM-dd") : "";
}
interface DateRangePreset {
label: string;
range: () => DateRangeValue;
}
const DEFAULT_PRESETS: DateRangePreset[] = [
{ label: "Today", range: () => ({ from: startOfDay(new Date()), to: endOfDay(new Date()) }) },
{
label: "Yesterday",
range: () => ({ from: startOfDay(subDays(new Date(), 1)), to: endOfDay(subDays(new Date(), 1)) }),
},
{ label: "Last 7 days", range: () => ({ from: startOfDay(subDays(new Date(), 6)), to: endOfDay(new Date()) }) },
{ label: "Last 30 days", range: () => ({ from: startOfDay(subDays(new Date(), 29)), to: endOfDay(new Date()) }) },
{ label: "This month", range: () => ({ from: startOfMonth(new Date()), to: endOfDay(new Date()) }) },
{
label: "Last month",
range: () => ({
from: startOfMonth(subMonths(new Date(), 1)),
to: endOfMonth(subMonths(new Date(), 1)),
}),
},
{ label: "Year to date", range: () => ({ from: startOfYear(new Date()), to: endOfDay(new Date()) }) },
];
function formatRangeLabel(value?: DateRangeValue, placeholder = "Select date range") {
if (!value?.from) return placeholder;
if (!value.to || isSameDay(value.from, value.to)) {
return format(value.from, "MMM d, y");
}
if (isSameYear(value.from, value.to)) {
return `${format(value.from, "MMM d")} ${format(value.to, "MMM d, y")}`;
}
return `${format(value.from, "MMM d, y")} ${format(value.to, "MMM d, y")}`;
}
interface DateRangePickerProps {
value?: DateRangeValue;
onChange?: (range: DateRangeValue) => void;
presets?: DateRangePreset[];
placeholder?: string;
numberOfMonths?: number;
className?: string;
align?: "start" | "center" | "end";
disabled?: boolean;
}
export function DateRangePicker({
value,
onChange,
presets = DEFAULT_PRESETS,
placeholder = "Select date range",
numberOfMonths = 2,
className,
align = "start",
disabled = false,
}: DateRangePickerProps) {
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<DateRangeValue | undefined>(value);
// Reset the draft to the committed value each time the popover opens
React.useEffect(() => {
if (open) setDraft(value);
}, [open, value]);
const activePresetLabel = React.useMemo(() => {
if (!draft?.from || !draft?.to) return null;
const match = presets.find((preset) => {
const r = preset.range();
return r.from && r.to && isSameDay(r.from, draft.from!) && isSameDay(r.to, draft.to!);
});
return match?.label ?? null;
}, [draft, presets]);
function applyPreset(preset: DateRangePreset) {
const range = preset.range();
setDraft(range);
onChange?.(range);
setOpen(false);
}
function handleApply() {
if (draft?.from) onChange?.({ from: draft.from, to: draft.to ?? draft.from });
setOpen(false);
}
function handleCancel() {
setDraft(value);
setOpen(false);
}
return (
<Popover open={open && !disabled} onOpenChange={(next: boolean) => setOpen(next && !disabled)}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className={cn(
"min-w-[240px] justify-start gap-2 font-normal text-slate-700 dark:text-slate-200",
!value?.from && "text-slate-500 dark:text-slate-400",
className,
)}
>
<CalendarIcon className="size-4 text-slate-500 dark:text-slate-400" />
<span className="flex-1 text-left">{formatRangeLabel(value, placeholder)}</span>
<ChevronDown className="size-4 text-slate-400" />
</Button>
</PopoverTrigger>
<PopoverContent align={align} className="w-auto p-0">
<div className="flex flex-col sm:flex-row">
<div className="flex shrink-0 flex-col gap-0.5 border-b p-2 sm:border-b-0 sm:border-r sm:w-[160px]">
{presets.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => applyPreset(preset)}
className={cn(
"rounded-md px-2.5 py-1.5 text-left text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
activePresetLabel === preset.label &&
"bg-primary-50 text-primary-700 hover:bg-primary-50 dark:bg-primary-900/30 dark:text-primary-300",
)}
>
{preset.label}
</button>
))}
</div>
<div className="flex flex-col">
<Calendar
mode="range"
selected={draft}
onSelect={(range: DateRangeValue | undefined) => setDraft(range ?? undefined)}
numberOfMonths={numberOfMonths}
defaultMonth={draft?.from ?? value?.from}
className="border-none shadow-none"
/>
<div className="flex items-center justify-between gap-3 border-t p-3">
<span className="text-sm text-slate-500 dark:text-slate-400">
{formatRangeLabel(draft, "No dates selected")}
</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" disabled={!draft?.from} onClick={handleApply}>
Apply
</Button>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,6 +1,5 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/common/ui/input'
import {
@@ -11,14 +10,8 @@ import {
SelectValue,
} from '@/shared/common/ui/select'
import { Button } from '@/shared/common/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/shared/common/ui/popover'
import { Calendar } from '@/shared/common/ui/calendar'
import { Search, Filter, CalendarIcon, X } from 'lucide-react'
import { format } from 'date-fns'
import { DateRangePicker } from '@/shared/common/ui/date-range-picker'
import { Search, Filter, X } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
interface ActivityFiltersProps {
@@ -53,7 +46,6 @@ export function ActivityFilters({
onClearFilters,
}: ActivityFiltersProps) {
const { t } = useTranslation()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const textStrong = 'text-gray-900 dark:text-gray-100'
const textMuted = 'text-gray-500 dark:text-gray-400'
@@ -168,92 +160,11 @@ export function ActivityFilters({
</div>
<div className="flex items-center gap-4">
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full md:w-auto justify-start text-left font-normal',
border,
surface,
textStrong,
)}
>
<CalendarIcon
className={cn('mr-2 h-4 w-4', textMuted)}
/>
{dateRange.from ? (
dateRange.to ? (
<>
{format(dateRange.from, 'LLL dd, y')} -{' '}
{format(dateRange.to, 'LLL dd, y')}
</>
) : (
format(dateRange.from, 'LLL dd, y')
)
) : (
<span className={textMuted}>
{t('auditLog.filters.dateRange')}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className={cn('w-auto p-0', border, surface)}
align="start"
>
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange.from}
selected={{
from: dateRange.from,
to: dateRange.to,
}}
onSelect={(range) => {
onDateRangeChange({
from: range?.from,
to: range?.to,
})
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<Button
variant="outline"
onClick={() => {
const today = new Date()
const last7Days = new Date(today)
last7Days.setDate(last7Days.getDate() - 7)
onDateRangeChange({
from: last7Days,
to: today,
})
}}
className={cn(border, surface, textStrong, hoverText)}
>
{t('auditLog.filters.last7Days')}
</Button>
<Button
variant="outline"
onClick={() => {
const today = new Date()
const last30Days = new Date(today)
last30Days.setDate(last30Days.getDate() - 30)
onDateRangeChange({
from: last30Days,
to: today,
})
}}
className={cn(border, surface, textStrong, hoverText)}
>
{t('auditLog.filters.last30Days')}
</Button>
<DateRangePicker
value={dateRange}
onChange={onDateRangeChange}
placeholder={t('auditLog.filters.dateRange')}
/>
</div>
</div>
)

View File

@@ -8,6 +8,7 @@ import EthiopianDatePickerModal, {
import { Badge } from "@/shared/common/ui/badge";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectContent,
@@ -225,29 +226,13 @@ export function CombinedFilterBar({
{selectedFilter?.type === "dateRange" ? (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDate}
onChange={(event) => setFromDate(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addFilter();
}}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDate}
onChange={(event) => setToDate(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addFilter();
}}
min={fromDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDate), to: parseDay(toDate) }}
onChange={(range) => {
setFromDate(formatDay(range.from));
setToDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
<div className="relative">

View File

@@ -9,7 +9,8 @@ export type EimsInvoiceStatus =
| "SUBMITTING"
| "REGISTERED"
| "FAILED"
| "UNKNOWN";
| "UNKNOWN"
| "CANCELLED";
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
export interface EimsInvoiceError {
@@ -30,6 +31,28 @@ export interface EimsInvoiceStatusView {
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
/** Base64 PNG from MoR, already rendered on their side — embed as `data:image/png;base64,...`. */
eimsSignedQr: string | null;
eimsCancelledAt: string | null;
/** MoR's own confirmation string (a Java `Date#toString()`), stored verbatim — display as-is. */
eimsCancellationDate: string | null;
eimsCancellationReasonCode: string | null;
eimsCancellationRemark: string | null;
}
/** One `EimsReceipt` row — mirrors `entities/eims-receipt.entity.ts`. */
export interface EimsReceiptView {
id: string;
invoiceId: string;
kind: "SALES" | "WITHHOLDING";
status: EimsInvoiceStatus;
receiptNumber: string;
rrn: string | null;
/** Base64 PNG, same convention as `eimsSignedQr`. */
qr: string | null;
ackStatus: string | null;
submittedAt: string | null;
lastError: EimsInvoiceError | null;
}
/** `POST /v1/verify` response, echoed back from the gateway. */

View File

@@ -1,27 +1,86 @@
export type ReportColumnType =
| "string"
| "number"
| "money"
| "tons"
| "percent"
| "date";
export interface ReportColumn {
key: string;
label: string;
type: ReportColumnType;
sortable?: boolean;
}
export type ReportFilterType = "daterange" | "date" | "select" | "multiselect" | "text";
export interface ReportFilterOption {
value: string;
label: string;
}
export interface ReportFilterDef {
key: string;
label: string;
type: ReportFilterType;
options?: ReportFilterOption[];
}
export interface ReportIdKey {
key: string;
label: string;
}
export interface ReportKpi {
label: string;
value: number;
unit?: string;
}
export type ReportRow = Record<string, unknown>;
export type ReportChartType = "line" | "bar";
export interface ReportResult {
kpis: ReportKpi[];
rows: ReportRow[];
export interface ReportChartDef {
type: ReportChartType;
x: string;
y: string[];
}
/** Query params for GET /reports/:key. List filters are comma-separated. */
export interface ReportQueryInput {
/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */
export interface ReportCatalogEntry {
key: string;
dateFrom?: string;
dateTo?: string;
granularity?: "day" | "week" | "month";
companyIds?: string;
routeIds?: string;
yardIds?: string;
cargoTypeIds?: string;
statuses?: string;
direction?: string;
freightType?: string;
title: string;
description: string;
group: "Commercial" | "Operations" | "Finance";
idKey?: ReportIdKey;
filters: ReportFilterDef[];
columns: ReportColumn[];
defaultSort?: { key: string; dir: "ASC" | "DESC" };
hasSummary: boolean;
chart?: ReportChartDef;
}
export interface ReportPageMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
export interface ReportRunResult {
columns: ReportColumn[];
items: Record<string, unknown>[];
meta: ReportPageMeta;
kpis: ReportKpi[];
}
/** Query params for GET /reports/:key — page/sort plus whatever filters the report declares. */
export type ReportRunParams = Record<string, string | number | undefined> & {
key: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
};

View File

@@ -960,6 +960,11 @@ export interface CreateTrainSchedulePayload {
maxWagonsPerTrain?: number;
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
reverseWagonOrder?: boolean;
/**
* Dedicate this departure to one shipping line — hidden from customers,
* visible only to that line in its portal. Omit for a normal customer train.
*/
shippingLineCompanyId?: string;
/**
* Configure the booking window for THIS schedule instead of inheriting the
* live global rules. Omit to follow the global rules (the default).

View File

@@ -224,6 +224,7 @@ export const URL_CONSTANTS = {
},
LAST_MILE_REQUESTS: {
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,

View File

@@ -1,5 +1,6 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
@@ -8,6 +9,7 @@ import type {
MileLegSummary,
MileVehicleSummary,
} from "@/services/bookings.service";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import { CardTitle, SectionCard } from "./layout";
@@ -171,12 +173,85 @@ function LegBlock({
);
}
/**
* Reference row for the stored last-mile contract: signed status, open the
* contract page (view / sign), download the PDF.
*/
function LastMileContractRow({
bookingId,
requestId,
signedAt,
signerDisplayName,
}: {
bookingId: string;
requestId: string;
signedAt?: string | null;
signerDisplayName?: string | null;
}) {
const navigate = useNavigate();
const download = async () => {
const blob = await lastMileRequestsService.downloadContractDocument(requestId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "last-mile-contract.pdf";
a.click();
URL.revokeObjectURL(url);
};
return (
<Group
justify="space-between"
align="center"
wrap="wrap"
pt={8}
mt={4}
style={{ borderTop: "1px solid #F2F5F8" }}
>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F">
Last-mile contract
</Text>
<Text fz="12px" c={signedAt ? "#0A6F4D" : "#B45309"}>
{signedAt
? `Signed ${new Date(signedAt).toLocaleDateString()}${
signerDisplayName ? ` by ${signerDisplayName}` : ""
}`
: "Awaiting your signature"}
</Text>
</Stack>
<Group gap={8}>
<Button
size="xs"
variant="light"
onClick={() =>
navigate(`/bookings/${bookingId}/last-mile-contract?requestId=${requestId}`)
}
>
{signedAt ? "View contract" : "View & sign"}
</Button>
<Button size="xs" variant="default" onClick={() => void download()}>
Download PDF
</Button>
</Group>
</Group>
);
}
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
const { data } = useQuery({
queryKey: ["booking-mile-summary", booking.id],
queryFn: () => bookingsService.mileSummary(booking.id),
});
// The stored LM contract lives on the booking's approved last-mile request.
const { data: lmRequests } = useQuery({
queryKey: ["booking-last-mile-requests", booking.id],
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
enabled: !!booking.lastMileDeliveryAddress,
});
const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED");
const firstLeg = data?.firstMile ?? null;
const lastLeg = data?.lastMile ?? null;
@@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
/>
)}
{showLast && (
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
<Box>
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
{approvedRequest && (
<LastMileContractRow
bookingId={booking.id}
requestId={approvedRequest.id}
signedAt={approvedRequest.customerSignedAt}
signerDisplayName={approvedRequest.signerDisplayName}
/>
)}
</Box>
)}
</Stack>
</SectionCard>

View File

@@ -21,6 +21,7 @@ import {
Clock,
FileText,
Package,
PackageCheck,
Upload,
} from "lucide-react";
import { useState } from "react";
@@ -44,6 +45,7 @@ import {
DOC_STATE_COLOR,
DOC_STATE_LABEL,
} from "./booking-doc-state";
import ShippingLineCompleteModal from "./ShippingLineCompleteModal";
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
/**
@@ -69,6 +71,7 @@ export default function ShippingLineBookingDetailPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [docsOpen, setDocsOpen] = useState(false);
const [completeOpen, setCompleteOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -78,6 +81,14 @@ export default function ShippingLineBookingDetailPage() {
enabled: Boolean(id),
});
// Trains dedicated to this shipping line — matched to the booking below by
// lane (+ shipment day when one is set) so the detail shows which departure
// will carry it.
const trainsQuery = useQuery({
queryKey: ["shipping-line-my-trains"],
queryFn: shippingLineBookingsService.myTrains,
});
const cancelMutation = useMutation({
mutationFn: () => shippingLineBookingsService.cancel(id, cancelReason),
onSuccess: () => {
@@ -108,6 +119,19 @@ export default function ShippingLineBookingDetailPage() {
}
const booking: ShippingLineBooking = bookingQuery.data;
// A train matches when it runs the booking's lane; with a shipment day set,
// it must also depart that calendar day.
const sameDay = (a: string | Date, b: string | Date) =>
new Date(a).toDateString() === new Date(b).toDateString();
const matchedTrains = (trainsQuery.data ?? []).filter(
(train) =>
train.originYardId === booking.originYard?.id &&
train.destinationYardId === booking.destinationYard?.id &&
(!booking.scheduledDate ||
sameDay(train.scheduledDepartureDate, booking.scheduledDate)),
);
const status = booking.status as string;
const docState = bookingDocState(booking);
const showDocs = hasDocuments(docState);
@@ -120,6 +144,12 @@ export default function ShippingLineBookingDetailPage() {
const canCancel =
CANCELLABLE_STATUSES.has(status) && !(Number(booking.totalAmount ?? 0) > 0);
// Approved documents (or an operations return) unlock the completion step —
// cargo + shipment day, the customer's post-clearance move. Mirrors the
// statuses completeMine accepts on the API.
const canComplete =
status === "CLEARANCE_READY" || status === "OPERATION_CHANGES_REQUESTED";
return (
<PageShell>
<Button
@@ -181,6 +211,18 @@ export default function ShippingLineBookingDetailPage() {
{DOC_STATE_ACTION_LABEL[docState]}
</Button>
)}
{canComplete && (
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
onClick={() => setCompleteOpen(true)}
>
{status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
: "Complete booking"}
</Button>
)}
</Group>
</Group>
@@ -231,6 +273,16 @@ export default function ShippingLineBookingDetailPage() {
Your documents are with Operations for review. You can still
open them, and replace any that come back with a query.
</Alert>
) : status === "OPERATION_REQUEST_PENDING" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
Your booking is complete and with Operations for review. The
charge has been recorded on your credit account.
</Alert>
) : status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="orange" radius="md" icon={<AlertCircle size={18} />}>
Operations returned your booking request for changes.
Resubmit it with an updated shipment day or cargo.
</Alert>
) : (
<Alert
color="teal"
@@ -238,21 +290,37 @@ export default function ShippingLineBookingDetailPage() {
icon={<CheckCircle2 size={18} />}
>
Your documents are approved.
{status === "CLEARANCE_READY" &&
" Complete the booking with your cargo and shipment day to proceed."}
</Alert>
)}
<Button
color={actionNeeded ? "red" : "edr-green"}
variant={wantsUpload ? "filled" : "light"}
radius="md"
w="fit-content"
leftSection={
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
}
onClick={() => setDocsOpen(true)}
>
{DOC_STATE_ACTION_LABEL[docState]}
</Button>
<Group gap="sm">
<Button
color={actionNeeded ? "red" : "edr-green"}
variant={wantsUpload ? "filled" : "light"}
radius="md"
w="fit-content"
leftSection={
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
}
onClick={() => setDocsOpen(true)}
>
{DOC_STATE_ACTION_LABEL[docState]}
</Button>
{canComplete && (
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
onClick={() => setCompleteOpen(true)}
>
{status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
: "Complete booking"}
</Button>
)}
</Group>
</Stack>
</SectionCard>
</Tabs.Panel>
@@ -297,6 +365,26 @@ export default function ShippingLineBookingDetailPage() {
: "Not scheduled yet"
}
/>
<DetailRow
label="Train"
value={
matchedTrains.length
? matchedTrains
.map(
(t) =>
`${t.trainNumber ?? t.reference ?? "Train"} — departs ${new Date(
t.scheduledDepartureDate,
).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}`,
)
.join(" · ")
: "No train assigned for this lane and day yet"
}
/>
</Stack>
</SectionCard>
}
@@ -314,6 +402,16 @@ export default function ShippingLineBookingDetailPage() {
: "—"
}
/>
{/* Set at completion — the charge sits on the line's credit
account (pay-later), so no pay button follows it. */}
{Number(booking.totalAmount ?? 0) > 0 && (
<DetailRow
label="Amount (on credit)"
value={`${Number(booking.totalAmount).toLocaleString()} ${
booking.paymentCurrency ?? ""
}`.trim()}
/>
)}
</Stack>
</SectionCard>
}
@@ -326,6 +424,12 @@ export default function ShippingLineBookingDetailPage() {
onClose={() => setDocsOpen(false)}
/>
<ShippingLineCompleteModal
booking={completeOpen ? booking : null}
onClose={() => setCompleteOpen(false)}
onCompleted={() => setCompleteOpen(false)}
/>
{/* Cancelling is irreversible, so it asks first rather than firing on the
button press. The reason is optional but recorded. */}
<Modal

View File

@@ -22,6 +22,7 @@ import {
FileText,
MoreVertical,
Package,
PackageCheck,
Plus,
Upload,
} from "lucide-react";
@@ -213,6 +214,21 @@ export default function ShippingLineBookingsPage() {
{DOC_STATE_ACTION_LABEL[state]}
</Menu.Item>
)}
{/* Approved documents (or an operations return) unlock the
completion step — it lives on the detail page. */}
{(booking.status === "CLEARANCE_READY" ||
booking.status === "OPERATION_CHANGES_REQUESTED") && (
<Menu.Item
leftSection={<PackageCheck size={15} />}
onClick={() =>
navigate(`/shipping-line/bookings/${booking.id}`)
}
>
{booking.status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
: "Complete booking"}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>

View File

@@ -0,0 +1,372 @@
import {
ActionIcon,
Alert,
Box,
Button,
Center,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CalendarDays,
PackageCheck,
Plus,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
shippingLineBookingsService,
type CompleteBookingContainerLine,
type ShippingLineBooking,
} from "@/services/shipping-line-bookings.service";
const CURRENCIES = [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
];
interface ContainerLineDraft {
containerTypeId: string | null;
quantity: number | string;
vgmPerUnitTons: number | string;
}
const EMPTY_LINE: ContainerLineDraft = {
containerTypeId: null,
quantity: 1,
vgmPerUnitTons: 0,
};
/**
* Complete an approved shipping-line booking — the step a customer does after
* clearance: enter the cargo and the binding shipment day. The server prices
* the booking off the line's negotiated rates and puts the charge on the
* credit ledger (pay-later), so no payment step follows here.
*/
export default function ShippingLineCompleteModal({
booking,
onClose,
onCompleted,
}: {
/** The booking to complete, or null when the modal is closed. */
booking: ShippingLineBooking | null;
onClose: () => void;
onCompleted: (booking: ShippingLineBooking) => void;
}) {
const queryClient = useQueryClient();
const opened = Boolean(booking);
const bookingId = booking?.id ?? "";
const isContainer = (booking?.freightType ?? "CONTAINER") === "CONTAINER";
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
const [currency, setCurrency] = useState<string>("ETB");
const [lines, setLines] = useState<ContainerLineDraft[]>([{ ...EMPTY_LINE }]);
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const [cargoWeightTons, setCargoWeightTons] = useState<number | string>(0);
const [cargoFreeText, setCargoFreeText] = useState("");
// Fresh sheet each open, prefilled with the day picked at initiate (if any).
useEffect(() => {
if (opened && booking) {
setScheduledDate(
booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: null,
);
setCurrency(booking.paymentCurrency ?? "ETB");
setLines([{ ...EMPTY_LINE }]);
setCargoTypeId(null);
setCargoWeightTons(0);
setCargoFreeText("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, bookingId]);
const referenceQuery = useQuery({
queryKey: ["shipping-line-reference-data"],
queryFn: shippingLineBookingsService.referenceData,
enabled: opened,
});
// Only schedule-backed days are offered — same rule the server enforces.
const daysQuery = useQuery({
queryKey: ["shipping-line-bookings", bookingId, "available-days"],
queryFn: () => shippingLineBookingsService.availableDays(bookingId),
enabled: opened && Boolean(bookingId),
});
const completeMutation = useMutation({
mutationFn: () => {
const payload = isContainer
? {
scheduledDate: scheduledDate!,
paymentCurrency: currency,
cargoFreeText: cargoFreeText || undefined,
containers: lines
.filter((l) => l.containerTypeId && Number(l.quantity) > 0)
.map(
(l): CompleteBookingContainerLine => ({
containerTypeId: l.containerTypeId!,
quantity: Number(l.quantity),
vgmPerUnitTons: Number(l.vgmPerUnitTons) || 0,
}),
),
}
: {
scheduledDate: scheduledDate!,
paymentCurrency: currency,
cargoFreeText: cargoFreeText || undefined,
cargoTypeId: cargoTypeId!,
cargoWeightTons: Number(cargoWeightTons),
};
return shippingLineBookingsService.complete(bookingId, payload);
},
onSuccess: (updated) => {
void queryClient.invalidateQueries({
queryKey: ["shipping-line-bookings"],
});
onCompleted(updated);
},
});
const containerTypeOptions = useMemo(
() =>
(referenceQuery.data?.containerTypes ?? []).map((ct) => ({
value: ct.id,
label: ct.sizeFt ? `${ct.label} (${ct.sizeFt}ft)` : ct.label,
})),
[referenceQuery.data],
);
// Grouping headers are rows other rows point at via parentGroupId — only
// leaves are bookable cargo.
const cargoTypeOptions = useMemo(() => {
const all = referenceQuery.data?.cargoTypes ?? [];
const parents = new Set(
all.map((c) => c.parentGroupId).filter((id): id is string => Boolean(id)),
);
return all
.filter((c) => !parents.has(c.id))
.map((c) => ({ value: c.id, label: c.name }));
}, [referenceQuery.data]);
const dayOptions = useMemo(
() =>
(daysQuery.data?.days ?? []).map((day) => ({
value: day,
label: new Date(day).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
})),
[daysQuery.data],
);
const validCargo = isContainer
? lines.some((l) => l.containerTypeId && Number(l.quantity) > 0)
: Boolean(cargoTypeId) && Number(cargoWeightTons) > 0;
const canSubmit = Boolean(scheduledDate) && validCargo;
const loading = referenceQuery.isLoading || daysQuery.isLoading;
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
radius="md"
title={
<Box>
<Text fw={700} fz={16}>
Complete booking
</Text>
<Text fz={12} c="dimmed">
Your documents are approved enter the cargo and shipment day. The
charge goes on your credit account.
</Text>
</Box>
}
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
{loading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<Stack gap="md">
{dayOptions.length === 0 ? (
<Alert color="yellow" radius="md" icon={<AlertCircle size={16} />}>
No departures are currently open on this route. Please check back
or contact Operations.
</Alert>
) : (
<Select
label="Shipment day"
description="Only days with an open train departure on your route are offered."
placeholder="Pick the shipment day"
withAsterisk
searchable
leftSection={<CalendarDays size={16} />}
data={dayOptions}
value={scheduledDate}
onChange={setScheduledDate}
comboboxProps={{ withinPortal: true }}
/>
)}
{isContainer ? (
<Stack gap="xs">
<Text fz={13} fw={600}>
Containers
</Text>
{lines.map((line, index) => (
<Group key={index} gap="xs" align="flex-end" wrap="nowrap">
<Select
label={index === 0 ? "Container type" : undefined}
placeholder="Type"
searchable
style={{ flex: 2 }}
data={containerTypeOptions}
value={line.containerTypeId}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, containerTypeId: v } : l,
),
)
}
comboboxProps={{ withinPortal: true }}
/>
<NumberInput
label={index === 0 ? "Quantity" : undefined}
min={1}
style={{ flex: 1 }}
value={line.quantity}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, quantity: v } : l,
),
)
}
/>
<NumberInput
label={index === 0 ? "VGM / unit (tons)" : undefined}
min={0}
decimalScale={3}
style={{ flex: 1 }}
value={line.vgmPerUnitTons}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, vgmPerUnitTons: v } : l,
),
)
}
/>
<ActionIcon
variant="subtle"
color="red"
size="lg"
disabled={lines.length === 1}
onClick={() =>
setLines((prev) => prev.filter((_, i) => i !== index))
}
aria-label="Remove line"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="light"
size="compact-sm"
w="fit-content"
leftSection={<Plus size={14} />}
onClick={() => setLines((prev) => [...prev, { ...EMPTY_LINE }])}
>
Add container line
</Button>
</Stack>
) : (
<Group grow align="flex-start" gap="sm">
<Select
label="Cargo type"
placeholder="Select cargo..."
withAsterisk
searchable
data={cargoTypeOptions}
value={cargoTypeId}
onChange={setCargoTypeId}
comboboxProps={{ withinPortal: true }}
/>
<NumberInput
label="Total weight (tons)"
withAsterisk
min={0}
decimalScale={3}
value={cargoWeightTons}
onChange={setCargoWeightTons}
/>
</Group>
)}
<Group grow align="flex-start" gap="sm">
<Select
label="Billing currency"
data={CURRENCIES}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
comboboxProps={{ withinPortal: true }}
/>
<Textarea
label="Cargo description (optional)"
placeholder="What the shipment carries"
autosize
minRows={1}
maxRows={3}
maxLength={500}
value={cargoFreeText}
onChange={(e) => setCargoFreeText(e.currentTarget.value)}
/>
</Group>
{completeMutation.isError && (
<Alert color="red" icon={<AlertCircle size={16} />}>
{(completeMutation.error as Error)?.message ??
"Could not complete the booking."}
</Alert>
)}
<Group justify="flex-end" mt="sm" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
loading={completeMutation.isPending}
disabled={!canSubmit}
onClick={() => completeMutation.mutate()}
>
Complete booking
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -1,17 +1,129 @@
import { Home } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
import {
Badge,
Card,
Center,
Group,
Loader,
Stack,
Text,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, CalendarClock, TrainFront } from "lucide-react";
import {
shippingLineBookingsService,
type ShippingLineTrain,
} from "@/services/shipping-line-bookings.service";
const STATUS_COLOR: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "teal",
};
function TrainCard({ train }: { train: ShippingLineTrain }) {
const departure = new Date(train.scheduledDepartureDate);
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Stack gap={4}>
<Group gap="xs">
<TrainFront size={16} />
<Text fw={600} fz={14}>
{train.trainNumber ?? train.reference ?? "Train"}
</Text>
{train.reference && train.trainNumber ? (
<Text fz={12} c="dimmed">
{train.reference}
</Text>
) : null}
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[train.status] ?? "gray"}
>
{train.status}
</Badge>
</Group>
<Group gap={6}>
<Text fz={13}>{train.originLabel}</Text>
<ArrowRight size={13} />
<Text fz={13}>{train.destinationLabel}</Text>
</Group>
</Stack>
<Stack gap={2} align="flex-end">
<Group gap={6}>
<CalendarClock size={14} />
<Text fz={13} fw={500}>
{departure.toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</Group>
<Text fz={12} c="dimmed">
Departs{" "}
{departure.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Stack>
</Group>
</Card>
);
}
/**
* Shipping-line home / dashboard. Deliberately separate from the customer
* dashboard (`MyPortalPage`): shipping lines have no company, no operational
* profiles and no contracts, so almost none of that page's data applies.
*
* Lists the train departures dedicated to this shipping line — those trains
* are hidden from customers, so this page (and the booking detail's lane/day
* match) is where the line sees them.
*/
export default function ShippingLineHomePage() {
const trainsQuery = useQuery({
queryKey: ["shipping-line-my-trains"],
queryFn: shippingLineBookingsService.myTrains,
});
const trains = trainsQuery.data ?? [];
return (
<ShippingLinePlaceholder
title="Home"
description="Overview of your shipping-line activity."
icon={<Home size={28} className="text-slate-300" />}
/>
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap={4}>
<Title order={2}>Home</Title>
<Text c="dimmed" size="sm">
Overview of your shipping-line activity.
</Text>
</Stack>
<Stack gap="sm">
<Text fw={600} fz={15}>
Your trains
</Text>
{trainsQuery.isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : trains.length === 0 ? (
<Card withBorder radius="md" py={48}>
<Stack align="center" gap="xs">
<TrainFront size={28} className="text-slate-300" />
<Text c="dimmed" size="sm">
No trains have been assigned to you yet.
</Text>
</Stack>
</Card>
) : (
trains.map((train) => <TrainCard key={train.id} train={train} />)
)}
</Stack>
</Stack>
);
}

View File

@@ -10,8 +10,9 @@ import {
Stack,
Text,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, MapPin, Plus } from "lucide-react";
import { AlertCircle, CalendarDays, MapPin, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -51,6 +52,7 @@ export default function ShippingLineInitiateModal({
);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [freightType, setFreightType] = useState<string>("CONTAINER");
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
// Fresh sheet each time it opens.
useEffect(() => {
@@ -59,6 +61,7 @@ export default function ShippingLineInitiateModal({
setDestinationYardId(null);
setServiceTypeId(null);
setFreightType("CONTAINER");
setScheduledDate(null);
}
}, [opened]);
@@ -74,6 +77,7 @@ export default function ShippingLineInitiateModal({
routeId: selectedRoute!.id,
serviceTypeId: serviceTypeId ?? undefined,
freightType,
scheduledDate: scheduledDate ?? undefined,
}),
onSuccess: (booking) => {
void queryClient.invalidateQueries({
@@ -214,6 +218,20 @@ export default function ShippingLineInitiateModal({
comboboxProps={{ withinPortal: true }}
/>
{/* Picked up front, unlike the customer flow — a shipping line has no
later operation-request step to choose its shipment day at. */}
<DatePickerInput
label="Scheduled date"
placeholder="Pick the shipment day"
withAsterisk
minDate={new Date().toISOString().slice(0, 10)}
leftSection={<CalendarDays size={16} />}
value={scheduledDate}
onChange={(v) => setScheduledDate(v ?? null)}
radius="md"
popoverProps={{ withinPortal: true }}
/>
{/* Only services that do NOT bundle customs are offered — the API
filters them and rejects the rest. If none are configured the
field says so rather than vanishing, which would read as a
@@ -254,7 +272,7 @@ export default function ShippingLineInitiateModal({
radius="md"
leftSection={<Plus size={16} />}
loading={initiateMutation.isPending}
disabled={!selectedRoute}
disabled={!selectedRoute || !scheduledDate}
onClick={() => initiateMutation.mutate()}
>
Initiate booking

View File

@@ -12,6 +12,7 @@ export interface LastMileRequest {
requestedContainerNumbers?: string[] | null;
requestedDeliveryDate?: string | null;
customerSignedAt?: string | null;
signerDisplayName?: string | null;
rejectionReason?: string | null;
createdAt: string;
updatedAt: string;
@@ -46,6 +47,12 @@ export const lastMileRequestsService = {
return data.data ?? data;
},
/** The booking's requests, newest first — links the stored LM contract. */
listForBooking: async (bookingId: string): Promise<LastMileRequest[]> => {
const { data } = await client.get(L.BY_BOOKING(bookingId));
return data.data ?? data;
},
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
submit: async (
id: string,

View File

@@ -37,6 +37,41 @@ export interface ShippingLineRouteOption {
export interface ShippingLineReferenceData {
routes: ShippingLineRouteOption[];
serviceTypes: { id: string; name: string }[];
/** For the completion form — what ships in a CONTAINER booking. */
containerTypes: {
id: string;
label: string;
sizeFt: number | null;
isReefer: boolean;
}[];
/**
* For the completion form — what ships in a BULK booking. Rows with a
* `parentGroupId` are leaf types; rows without may be grouping headers.
*/
cargoTypes: {
id: string;
name: string;
parentGroupId: string | null;
unitOfMeasure: string | null;
}[];
}
/**
* A train departure dedicated to the signed-in shipping line. Hidden from
* customers server-side; `/my-trains` is the only portal read that returns it.
*/
export interface ShippingLineTrain {
id: string;
reference: string | null;
trainNumber: string | null;
status: string;
direction: string | null;
scheduledDepartureDate: string;
scheduledArrivalDate: string | null;
originYardId: string;
originLabel: string;
destinationYardId: string;
destinationLabel: string;
}
/**
@@ -47,6 +82,31 @@ export interface InitiateShippingLineBookingPayload {
routeId: string;
serviceTypeId?: string;
freightType?: string;
/** Intended shipment day (YYYY-MM-DD), picked up front by the shipping line. */
scheduledDate?: string;
}
/** One container line of a CONTAINER completion. */
export interface CompleteBookingContainerLine {
containerTypeId: string;
quantity: number;
vgmPerUnitTons?: number;
hazardousQuantity?: number;
reeferQuantity?: number;
}
/**
* Completion payload — the cargo and the binding shipment day, the two things
* `initiate` leaves empty. CONTAINER bookings send `containers`; BULK ones
* send `cargoTypeId` + `cargoWeightTons`.
*/
export interface CompleteShippingLineBookingPayload {
scheduledDate: string;
paymentCurrency?: string;
containers?: CompleteBookingContainerLine[];
cargoTypeId?: string;
cargoWeightTons?: number;
cargoFreeText?: string;
}
/**
@@ -73,11 +133,40 @@ export const shippingLineBookingsService = {
return data.data ?? data;
},
/** Train departures dedicated to the signed-in shipping line, soonest first. */
myTrains: async (): Promise<ShippingLineTrain[]> => {
const { data } = await client.get(`${BASE}/my-trains`);
return data.data ?? data;
},
getById: async (id: string): Promise<ShippingLineBooking> => {
const { data } = await client.get(`${BASE}/${id}`);
return data.data ?? data;
},
/**
* Days with an open departure that can carry this booking — for the
* completion form's shipment-day picker.
*/
availableDays: async (id: string): Promise<{ days: string[] }> => {
const { data } = await client.get(`${BASE}/${id}/available-days`);
return data.data ?? data;
},
/**
* Complete an approved (CLEARANCE_READY) booking: cargo + shipment day.
* The server prices it off the line's negotiated rates, records the charge
* on the credit ledger (pay-later — no invoice is issued here) and moves the
* booking to OPERATION_REQUEST_PENDING for Operations to review.
*/
complete: async (
id: string,
payload: CompleteShippingLineBookingPayload,
): Promise<ShippingLineBooking> => {
const { data } = await client.post(`${BASE}/${id}/complete`, payload);
return data.data ?? data;
},
/**
* Cancel one of the signed-in shipping line's own bookings. Only accepted
* before the booking is priced — the server enforces the same rule.