mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))] },
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'll send a single-use link to this customer'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -139,7 +139,9 @@ export const URL_CONSTANTS = {
|
||||
},
|
||||
|
||||
REPORTS: {
|
||||
CATALOG: "/reports",
|
||||
RUN: (key: string) => `/reports/${key}`,
|
||||
EXPORT: (key: string) => `/reports/${key}/export`,
|
||||
},
|
||||
|
||||
OVERVIEW: {
|
||||
|
||||
45
apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts
Normal file
45
apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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: "0–30d", unit: "ETB" },
|
||||
{ key: "overdue_31_60", label: "31–60d", unit: "ETB" },
|
||||
{ key: "overdue_61_90", label: "61–90d", 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",
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user