diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index caa25fbaa..513d8f9e5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -413,6 +413,32 @@ export class BillingService { return `data:image/png;base64,${signedQr}`; } + /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ + private async bookingSummaryRows( + invoice: Invoice, + ): Promise { + if (invoice.source !== Freight.InvoiceSource.Booking) return []; + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: invoice.sourceId }, + relations: { originYard: true, destinationYard: true }, + }); + if (!booking) return []; + return [ + { + label: "Route", + value: + booking.originYard && booking.destinationYard + ? `${booking.originYard.label} → ${booking.destinationYard.label}` + : null, + }, + { + label: "Wagons", + value: + booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + }, + ]; + } + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, @@ -446,6 +472,7 @@ export class BillingService { { label: "Status", value: invoice.status }, { label: "Type", value: invoice.type }, { label: "Reference", value: invoice.sourceId }, + ...(await this.bookingSummaryRows(invoice)), { label: "Currency", value: invoice.currency }, { label: "Issued", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 2b1463a95..ea1523882 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -59,6 +59,7 @@ export interface BookingListFilterOptions { assignedToSchedule?: 'true' | 'false'; companyId?: string; companyProfileId?: string; + contractId?: string; contractType?: string; serviceTypeId?: string; cargoTypeId?: string; @@ -936,6 +937,11 @@ export class BookingsRepository extends BaseRepository { companyProfileId: options.companyProfileId, }); } + if (options.contractId) { + qb.andWhere('booking.contract_id = :contractId', { + contractId: options.contractId, + }); + } if (options.contractType) { qb.andWhere('booking.contract_type = :contractType', { contractType: options.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 6c3f14ecb..4f26c4415 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1805,6 +1805,7 @@ export class BookingsService { // ANDs both, so cross-company access is impossible. companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, + contractId: filter.contractId, tradeDirections, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 2d3297af8..43d489bac 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -47,6 +47,11 @@ export class FilterBookingDto { @IsUUID() companyProfileId?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Filter bookings drawn down under this contract' }) + @IsOptional() + @IsUUID() + contractId?: string; + @ApiPropertyOptional() @IsOptional() contractType?: string; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx index 06fafc099..92df02ab6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx @@ -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 ( - - - - - {label} - - - - {value || "—"} - - - ); -} - 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 ( - + + {booking.governmentInstitution ?? "Government"} + ); } @@ -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 ( - - - {rows.length === 0 ? ( - - No additional company details available. - - ) : ( - rows.map((row, index) => ( -
- {index > 0 && } - -
- )) - )} -
-
+ rows={rows} + emptyMessage="No additional company details available." + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx new file mode 100644 index 000000000..f5fc3bf2a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx @@ -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 ( + + {booking.contractSummary} + + ) : undefined + } + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx deleted file mode 100644 index 1e86a7d2a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx +++ /dev/null @@ -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 ( - - - {summary} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 3236e5cae..3cebeacc5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -1,5 +1,6 @@ +import type { ReactNode } from "react"; import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +import { SimpleGrid, Stack } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; @@ -8,24 +9,40 @@ 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. Renders + * nothing when none of the three are present. */ +export function BookingMileServicesCard({ + booking, + handoverSection, +}: BookingMileServicesCardProps) { + const hasAddresses = + Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress); + if (!hasAddresses && !handoverSection) { return null; } return ( - - {booking.firstMilePickupAddress && ( - + + {hasAddresses && ( + + {booking.firstMilePickupAddress && ( + + )} + {booking.lastMileDeliveryAddress && ( + + )} + )} - {booking.lastMileDeliveryAddress && ( - - )} - + {handoverSection} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx deleted file mode 100644 index ed9802150..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - - Booking reference - - - - - {booking.reference} - - - - - - {booking.schedulingStatus ? ( - - ) : null} - - - {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - - Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} - - ) : null} - - - - - - - - - - {booking.nextStep ? ( - - - - ) : null} - - - - - - - - - - ); -} - -function MetaItem({ - icon: Icon, - text, - strong, -}: { - icon: LucideIcon; - text: ReactNode; - strong?: boolean; -}) { - return ( - - - - {text} - - - ); -} - -function HeroTile({ - icon: Icon, - label, - value, - hint, - accent = "edr-green", -}: { - icon: LucideIcon; - label: string; - value: ReactNode; - hint?: ReactNode; - accent?: string; -}) { - return ( - - - - - - - - {label} - - - {value} - - {hint ? ( - - {hint} - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index b0b024977..23f9b2bd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx new file mode 100644 index 000000000..dc742df4f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@mantine/core"; + +const STATUS_COLOR: Record = { + 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 ( + + {status} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index d29e045c4..5d875d98e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -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[number]; @@ -141,25 +142,23 @@ export function ContractCustomerCard({ return ( - - - + 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 }, + ]} + /> ; -interface InfoRowProps { - icon: LucideIcon; - label: string; - value?: string | null; -} - -function InfoRow({ icon: Icon, label, value }: InfoRowProps) { - return ( - - - - - {label} - - - - {value || "—"} - - - ); -} - -function InfoRows({ rows }: { rows: InfoRowProps[] }) { - const visible = rows.filter((r) => r.value); - if (visible.length === 0) { - return ( - - No details available. - - ); - } - return ( - - {visible.map((row, i) => ( -
- {i > 0 && } - -
- ))} -
- ); -} - /** 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 ( - - - + 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 ( - - - + 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", + }, + ]} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx deleted file mode 100644 index f0e9b266a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx +++ /dev/null @@ -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; -} - -/** - * 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("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 ( - <> - - - setOpened(false)} - title="Send a password-reset link" - centered - > - - - 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. - - - {targetQuery.isLoading ? ( - - - - ) : targetQuery.isError ? ( - - {targetQuery.error.message} - - ) : target ? ( - <> - setChannel(v as ResetChannel)} - label={`Send the link to ${target.name || "the primary contact"} via`} - > - - - - - - - - These are the primary contact's own login details, which may - differ from the company contact details on the profile. - - - - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index daeb11311..6f869173c 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx new file mode 100644 index 000000000..497c59d6c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx @@ -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 ( + + {label} + + ); + } + + return ( + e.stopPropagation()} + underline="hover" + c="edr-green" + fw={fw} + fz={size} + ff={mono ? "monospace" : undefined} + className={className} + > + + {Icon ? : null} + {label} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx new file mode 100644 index 000000000..7300005b9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx @@ -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 ( + + + {label} + + + {isEmpty ? "—" : value} + + + ); +} + +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 ( + + + {Icon ? : null} + + {label} + + + + {isEmpty ? "—" : value} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx new file mode 100644 index 000000000..931932fb5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx @@ -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 ( + + + + {visibleRows.length > 0 ? ( + + {visibleRows.map((row, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ ) : emptyMessage ? ( + + {emptyMessage} + + ) : null} + {footer} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/detail/index.ts new file mode 100644 index 000000000..15e379099 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/index.ts @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx index 087567585..803d3f5f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -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. */ diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx index c98a020a5..c006c9e03 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx @@ -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) => ( - - {b.reference} + + {b.route ? ( - {" "} ({b.route}) ) : null} - + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index de954742e..47b94d69b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -36,6 +36,7 @@ import { import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { EntityLink } from "@/components/detail"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { @@ -601,6 +602,7 @@ export function ScheduleWorkspacePanel({ {pool.map((b) => ( - - {reference} - + {bookingId ? ( + + ) : ( + + {reference} + + )} {status ? : null} {intercity ? ( - - {alloc.bookingReference ?? alloc.bookingId} - + {label === "BULK" ? ( {alloc.allocatedWeightTons}T cargo diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index b681a2909..1ee429bad 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -81,6 +81,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) => diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index d64b07a45..fdade7980 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -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 ( - + + + {booking.schedulingStatus ? ( + + ) : null} + + } + subtitle={ + + + + · Scheduled {booking.scheduledDate} + + + } + action={ + + refetch()} + > + + + + + + + + + + {hasSignableContract && ( + } + onClick={() => + navigate(`/dashboard/booking-requests/${booking.id}/contract`) + } + > + View / sign contract + + )} + } + 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 + + {booking.customsClearingEnabled && ( + } + onClick={() => + navigate(`/dashboard/bookings/${booking.id}/clearance`) + } + > + View document clearance + + )} + + + + } /> - navigate("/dashboard/booking-requests")} - onRefresh={() => refetch()} - isFetching={isFetching} - /> + + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} + + ) : null} + + {booking.nextStep ? ( + + + + ) : null} - + {isGeneralContract && ( @@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() { + - {booking.tradeDirection === "EXPORT" && ( - - - - How the cargo reaches the train - - { - 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", - ); - } - }} - /> - - {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."} - - - - )} - {booking.isGovernment && booking.contractSummary && ( - - )} - - {booking.customsClearingEnabled && ( - - )} @@ -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; + onRefetch: () => void; }) { + const row = toBookingListRow(booking); + return ( - + + + How the cargo reaches the train + + { + 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", + ); + } + }} + /> + + {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."} + + + ) : null + } + /> - {booking.contractSummary && ( - - )} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index dd7513a42..1ed0dd470 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -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"; @@ -171,6 +173,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 ( @@ -182,25 +196,46 @@ export default function DocumentClearanceDetailPage() { { label: reference }, ]} meta={ - clearance.allApproved ? ( - } - > - All approved + + + {direction} - ) : ( - } - > - Review pending - - ) + {clearance.includesCustoms ? ( + }> + Customs + + ) : null} + {clearance.allApproved ? ( + } + > + All approved + + ) : ( + } + > + Review pending + + )} + + } + subtitle={ + + + {origin} + + + + {destination} + + } action={ canCompleteBooking ? ( @@ -233,12 +268,16 @@ export default function DocumentClearanceDetailPage() { } /> - + + + {requestedLines ? ( + + + Requested cargo + + + + ) : null} {isPhasedGeneral ? ( @@ -273,67 +312,54 @@ export default function DocumentClearanceDetailPage() { - {isPhasedGeneral ? ( - 0} - bookingMilestones={bookingMilestones ?? []} - onChanged={() => void refetch()} - onViewFile={view} - onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} - /> - ) : ( - - - - - - {stats.pct}% - - - approved - - - } - /> - - + + {booking ? : null} + {booking ? : null} + {isPhasedGeneral ? ( + 0} + bookingMilestones={bookingMilestones ?? []} + onChanged={() => void refetch()} + onViewFile={view} + onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} + /> + ) : ( + + + + + {stats.pct}% + + + approved + + + } /> - - - - -
- - )} +
+ + )} + + } @@ -347,125 +373,3 @@ export default function DocumentClearanceDetailPage() { ); } - -function ClearanceHero({ - booking, - clearance, - stats, - requestedLines, -}: { - booking: ReturnType["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 ( - - - - - - - - - - {booking?.reference ?? "Clearance"} - - - {direction} - - {clearance.includesCustoms ? ( - } - > - Customs - - ) : null} - - - - {origin} - - - - {destination} - - - - - - - - - Document review - - - {stats.approved}/{stats.total} - - - - - - - {requestedLines ? ( - <> - - - - Requested cargo - - - - - ) : null} - - ); -} - -function ProgressStat({ - color, - label, - value, -}: { - color: string; - label: string; - value: number; -}) { - return ( - - - {value} - - - - - {label} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index b49eb419c..dacae6cea 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -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 ( @@ -206,57 +228,81 @@ export default function ContractClearanceDetailPage() { { label: hubLabel, href: hubHref }, { label: reference }, ]} + subtitle={ + + {id ? ( + + ) : null} + + · {origin} + + + + {destination} + + + } meta={ - bookingExpired ? ( - } - > - Payment expired — rebook + + + {directionLabel(direction)} - ) : bookingAlreadyCreated ? ( - } - > - Booking created - - ) : ready ? ( - } - > - Ready — create booking - - ) : clearance.allApproved ? ( - } - > - All approved - - ) : ( - } - > - Review pending - - ) + {customs ? ( + }> + Customs + + ) : null} + {bookingExpired ? ( + } + > + Payment expired — rebook + + ) : bookingAlreadyCreated ? ( + } + > + Booking created + + ) : ready ? ( + } + > + Ready — create booking + + ) : clearance.allApproved ? ( + } + > + All approved + + ) : ( + } + > + Review pending + + )} + } /> - + {/* 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() { + {phasedCustoms ? ( } /> - - - - - @@ -457,126 +487,3 @@ export default function ContractClearanceDetailPage() { ); } -function ClearanceHero({ - contract, - stats, -}: { - contract: ReturnType["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 ( - - - - - - - - - - {contract?.reference ?? "Clearance"} - - - {directionLabel(direction)} - - {customs ? ( - } - > - Customs - - ) : ( - - No customs - - )} - - {serviceName && ( - - {serviceName} - - )} - - - {origin} - - - - {destination} - - - - - - - - - Document review - - - {stats.approved}/{stats.total} - - - - - - - ); -} - -function ProgressStat({ - color, - label, - value, -}: { - color: string; - label: string; - value: number; -}) { - return ( - - - {value} - - - - - {label} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 8c0ef7b5c..5cb33c62a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -6,9 +6,8 @@ import { ArrowLeft, ArrowRight, Box as BoxIcon, - Building2, - Calendar, CalendarClock, + ClipboardList, Download, FileSignature, FileText, @@ -18,15 +17,16 @@ import { Info, LayoutGrid, Milestone, + MoreHorizontal, Package, Receipt, RefreshCw, Route as RouteIcon, - ShieldCheck, Snowflake, - Users, + Wallet, } from "lucide-react"; import { + ActionIcon, Alert, Badge, Box, @@ -36,21 +36,22 @@ import { Grid, Group, Loader, + Menu, Paper, SimpleGrid, Stack, Tabs, Text, - Title, } from "@mantine/core"; import toast from "react-hot-toast"; -import "@/components/overview/overview.css"; -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 { SectionCard } from "@/components/bookings/detail/SectionCard"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; +import { TableCard } from "@/components/customers"; import { ContractCourtBadge, ContractStatusBadge, @@ -60,14 +61,18 @@ import { ContractActionsToolbar } from "@/components/contracts/ContractActionsTo import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; +import { BookingRequestStatusBadge } from "@/components/contracts/BookingRequestStatusBadge"; import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline"; import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline"; import { ContractCustomerCard, ContractDocumentsCard, } from "@/components/contracts/detail/ContractDetailTabCards"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { getContractStatusMeta } from "@/features/contracts/contract-status.config"; +import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { useFileViewer } from "@/hooks/useFileViewer"; +import { useBookingList } from "@/hooks/bookings/useBookings"; import { useContractDetail, useContractMutations, @@ -75,11 +80,14 @@ import { import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { formatMoney } from "@/components/customers"; import { downloadBookingFile, fetchViewableFile, } from "@/services/files.service"; import type { CustomerDocument } from "@/types/customer"; +import type { BookingDetail } from "@/types/booking"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; import type { Freight } from "@edr/types"; // Clearance phase — actionable (docs approve / query / finalize on the hub). @@ -147,11 +155,11 @@ export default function ContractRequestDetailPage() { const [searchParams, setSearchParams] = useSearchParams(); const { view, viewer } = useFileViewer(); const requestedTab = searchParams.get("tab"); - const setTab = (tab: string) => + const setTab = (tab: string | null) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (tab === "details") next.delete("tab"); + if (!tab || tab === "details") next.delete("tab"); else next.set("tab", tab); return next; }, @@ -212,6 +220,18 @@ export default function ContractRequestDetailPage() { }) satisfies NonNullable[number], ); + // Bookings drawn down under this contract, and the customer's raw shipment + // requests against it — the two halves of "what has this contract produced". + const { data: contractBookings, isLoading: bookingsLoading } = useBookingList( + { contractId: id, pageSize: 100 }, + Boolean(id), + ); + const bookingRequestsQuery = useQuery({ + queryKey: QUERY_KEYS.CONTRACTS.bookingRequests(id ?? ""), + queryFn: () => contractsService.listBookingRequests(id!), + enabled: Boolean(id), + }); + const downloadContractPdf = async () => { if (!contract?.id) return; try { @@ -313,12 +333,12 @@ export default function ContractRequestDetailPage() { contract.status === "SIGNED_CUSTOMER") && Boolean(contract.contractGeneratedAt); // Resolve the active tab from the URL, falling back to details when the - // requested tab isn't available for this contract (e.g. clearance pre-phase). + // requested tab isn't available for this contract. const currentTab = - requestedTab === "documents" - ? "documents" - : requestedTab === "customer" - ? "customer" + requestedTab === "shipments" + ? "shipments" + : requestedTab === "documents" + ? "documents" : requestedTab === "history" ? "history" : "details"; @@ -327,98 +347,111 @@ export default function ContractRequestDetailPage() { ? (contract.governmentInstitution ?? "Government") : (contract.company?.name ?? "—"); + const kpis: KpiItem[] = [ + { + label: "Shipments", + value: contract.activeBookingCount ?? 0, + hint: "active", + icon: Package, + color: "edr-green", + }, + { + label: "Valid until", + value: contract.contractValidUntil ? formatDate(contract.contractValidUntil) : "—", + icon: CalendarClock, + color: "blue", + }, + { + label: "Routes", + value: routes.length, + icon: RouteIcon, + color: "teal", + }, + { + label: "Currency", + value: contract.paymentCurrency, + icon: Wallet, + color: "orange", + }, + ]; + return ( - - - - {/* Hero */} - - - - - - - - - - Contract reference - - - - {contract.reference} - - - - - {contract.contractKind === "GENERAL" ? "General" : "One-time"} - - - - - - {contract.contractValidUntil ? ( - - ) : null} - - {hasContractDocument && ( - + backTo="/dashboard/contract-requests" + title={contract.reference} + meta={ + + + + + {contract.contractKind === "GENERAL" ? "General" : "One-time"} + + + } + subtitle={ + + + + · Created {formatDate(contract.createdAt)} + {contract.contractValidUntil + ? ` · Valid until ${formatDateTime(contract.contractValidUntil)}` + : ""} + + + } + action={ + + refetch()} + > + + + {hasContractDocument && ( + + + + + + + {canViewSign && ( - + )} {contractPdf && ( - + )} - - - )} - - - + + + + )} + + } + /> + + + ) : null} - setTab(v ?? "details")} - variant="pills" - color="edr-green" - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - }> - Details - - } - rightSection={ - contractDocuments.length + profileDocuments.length > 0 ? ( - - {contractDocuments.length + profileDocuments.length} - - ) : null - } - > - Documents - - }> - Customer - - }> - History - - - - {/* LEFT — primary content */} - {currentTab === "documents" ? ( - - - + + }> + Details + + }> + Shipments + + } + rightSection={ + contractDocuments.length + profileDocuments.length > 0 ? ( + + {contractDocuments.length + profileDocuments.length} + + ) : null } - onView={handleViewFile} - onDownload={handleDownloadFile} - /> - {(clearanceView?.workflowFiles?.length ?? 0) > 0 ? ( - void handleDownloadFile({ id: f.id, name: f.name } as never)} + > + Documents + + }> + History + + + + + + + + + + + {contract.equipmentReturn ? ( + + ) : null} + + {contract.contractValidityDays != null ? ( + + ) : null} + {contract.estimatedShipmentDate ? ( + + ) : null} + {contract.firstMilePickupAddress ? ( + + ) : null} + {contract.lastMileDeliveryAddress ? ( + + ) : null} + + {contract.financialTerms ? ( + + + Financial terms + + + {contract.financialTerms} + + + ) : null} + + + + {routes.length === 0 ? ( + + No routes on this contract. + + ) : ( + + {routes.map((r) => ( + + + + {r.originYard?.label ?? + r.originYard?.code ?? + "Origin"} + + + + {r.destinationYard?.label ?? + r.destinationYard?.code ?? + "Destination"} + + + {r.km != null ? ( + + {r.km} km + + ) : null} + + ))} + + )} + + + + + + {directionLabel(contract.tradeDirection)} + + + {contract.freightType} + + {contract.isHazardous ? ( + } + > + Hazardous + + ) : null} + {contract.isReefer ? ( + } + > + Reefer + + ) : null} + + {contract.isHazardous ? ( + + + + ) : null} + {(contract.cargoScope ?? []).length === 0 ? ( + + No cargo scope lines. + + ) : ( + + {(contract.cargoScope ?? []).map((s) => { + const isContainer = Boolean(s.containerSize); + // Bulk lines carry their commodity detail (name + unit); + // container lines carry the size (20ft / 40ft). + const title = isContainer + ? `${s.containerSize} container` + : (s.cargoType?.cargoTypeName ?? + s.cargoFreeText ?? + s.cargoType?.code ?? + "Bulk cargo"); + // quantityCap unit: containers for a size line, else the + // cargo type's unit of measure (tons / items / …), default tons. + const capUnit = isContainer + ? "containers" + : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons"); + return ( + + +
+ + {title} + + + + {isContainer ? "Container" : "Bulk"} + + {s.cargoType?.code ? ( + + Code: {s.cargoType.code} + + ) : null} + + {s.quantityCap != null + ? `Cap: ${s.quantityCap} ${capUnit}` + : "Cap: uncapped"} + + +
+
+ ); + })} +
+ )} +
+ + {contract.pricingBreakdown?.lineItems?.length ? ( + + + {contract.pricingBreakdown.lineItems.map((li) => ( + + + {li.label} + {li.containerSize ? ` · ${li.containerSize}` : ""} + + + {contract.pricingBreakdown?.currency} {li.unitPrice} /{" "} + {li.unit} + + + ))} + + + ) : null} + + {contract.contractSummary ? ( + + + {contract.contractSummary} + + + ) : null} +
+
+ + + + + + + navigate(`/dashboard/booking-requests/${row.id}`) + } + /> + + + + + + + navigate(`/dashboard/shipment-requests/${row.id}`) + } + /> + + + + + + + + - ) : null} - - ) : currentTab === "history" ? ( - + + {(clearanceView?.workflowFiles?.length ?? 0) > 0 ? ( + void handleDownloadFile({ id: f.id, name: f.name } as never)} + /> + ) : null} + + + + - - - -
- ) : currentTab === "customer" ? ( - - ) : ( - - - - - - - {contract.equipmentReturn ? ( - - ) : null} - - {contract.contractValidityDays != null ? ( - - ) : null} - {contract.estimatedShipmentDate ? ( - - ) : null} - {contract.firstMilePickupAddress ? ( - - ) : null} - {contract.lastMileDeliveryAddress ? ( - - ) : null} - - {contract.financialTerms ? ( - + - - Financial terms - - - {contract.financialTerms} - - - ) : null} - - - - - - - - {routes.length === 0 ? ( - - No routes on this contract. - - ) : ( - - {routes.map((r) => ( - - - - {r.originYard?.label ?? - r.originYard?.code ?? - "Origin"} - - - - {r.destinationYard?.label ?? - r.destinationYard?.code ?? - "Destination"} - - - {r.km != null ? ( - - {r.km} km - - ) : null} - - ))} - - )} - - - - - - {directionLabel(contract.tradeDirection)} - - - {contract.freightType} - - {contract.isHazardous ? ( - } - > - Hazardous - - ) : null} - {contract.isReefer ? ( - } - > - Reefer - - ) : null} - - {contract.isHazardous ? ( - - - - ) : null} - {(contract.cargoScope ?? []).length === 0 ? ( - - No cargo scope lines. - - ) : ( - - {(contract.cargoScope ?? []).map((s) => { - const isContainer = Boolean(s.containerSize); - // Bulk lines carry their commodity detail (name + unit); - // container lines carry the size (20ft / 40ft). - const title = isContainer - ? `${s.containerSize} container` - : (s.cargoType?.cargoTypeName ?? - s.cargoFreeText ?? - s.cargoType?.code ?? - "Bulk cargo"); - // quantityCap unit: containers for a size line, else the - // cargo type's unit of measure (tons / items / …), default tons. - const capUnit = isContainer - ? "containers" - : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons"); - return ( - - -
- - {title} - - - - {isContainer ? "Container" : "Bulk"} - - {s.cargoType?.code ? ( - - Code: {s.cargoType.code} - - ) : null} - - {s.quantityCap != null - ? `Cap: ${s.quantityCap} ${capUnit}` - : "Cap: uncapped"} - - -
-
- ); - })} -
- )} -
- - {contract.pricingBreakdown?.lineItems?.length ? ( - - - {contract.pricingBreakdown.lineItems.map((li) => ( - - - {li.label} - {li.containerSize ? ` · ${li.containerSize}` : ""} - - - {contract.pricingBreakdown?.currency} {li.unitPrice} /{" "} - {li.unit} - - - ))} - - - ) : null} - - {contract.contractSummary ? ( - - - {contract.contractSummary} - - - ) : null} -
- )} + + + + +
{/* RIGHT — sticky action rail */} + [] = [ + { + id: "reference", + header: "Booking", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "route", + header: "Route", + cell: ({ row }) => { + const r = toBookingListRow(row.original); + return ( + + + {r.originLabel} + + + + {r.destinationLabel} + + + ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(Number(row.original.totalAmount), row.original.paymentCurrency)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, +]; + +const requestColumns: ColumnDef[] = [ + { + id: "reference", + header: "Request", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "scheduledDate", + header: "Requested for", + cell: ({ row }) => ( + + {row.original.scheduledDate ? formatDate(row.original.scheduledDate) : "—"} + + ), + }, + { + id: "createdAt", + header: "Submitted", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, +]; + function InfoRow({ label, value }: { label: string; value: string }) { return (
@@ -909,20 +1071,3 @@ function InfoRow({ label, value }: { label: string; value: string }) {
); } - -function MetaItem({ - icon: Icon, - text, -}: { - icon: typeof Building2; - text: string; -}) { - return ( - - - - {text} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index a501cd50a..76e0ff4ad 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -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 { 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={ + + } meta={ {directionLabel(data.tradeDirection)} @@ -278,37 +292,44 @@ export default function GlClearanceDetailPage() {
- setUploadKind("do")} - onUploadRoRequest={() => setUploadKind("ro")} - onChanged={() => { - void refetch(); - refetchBookingMilestonesIfLinked(); - }} - onViewFile={view} - onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} - /> + + {data.kind === "contract" ? ( + + ) : ( + + )} + setUploadKind("do")} + onUploadRoRequest={() => setUploadKind("ro")} + onChanged={() => { + void refetch(); + refetchBookingMilestonesIfLinked(); + }} + onViewFile={view} + onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} + /> +
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx index 89237af1f..54d0fb419 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx @@ -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() { + + On contract + + +
+ } backTo="/dashboard/shipment-requests" breadcrumbs={[ { label: "Shipment Requests", href: "/dashboard/shipment-requests" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 73f6a618b..7107ae7a5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -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[] = useMemo( + () => [ + { + id: "reference", + header: "Contract", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "kind", + header: "Kind", + cell: ({ row }) => ( + + {row.original.contractKind === "GENERAL" ? "General" : "One-time"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + ), + }, + { + id: "validUntil", + header: "Valid until", + cell: ({ row }) => ( + + {formatDate(row.original.contractValidUntil)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + const documentColumns: ColumnDef[] = useMemo( () => [ { @@ -709,7 +767,6 @@ export default function CustomerDetailPage() { } - action={} /> @@ -720,6 +777,9 @@ export default function CustomerDetailPage() { }> Bookings + }> + Contracts + }> Documents @@ -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() { + {/* CONTRACTS */} + + + navigate(`/dashboard/contract-requests/${row.id}`)} + error={ + contractsQuery.isError + ? { + message: "Failed to load contracts.", + onRetry: () => void contractsQuery.refetch(), + } + : undefined + } + /> + + + {/* DOCUMENTS */} diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 3188509c8..ef7f9d086 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -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 ( - {value && value.trim() ? value : "—"} + {isEmpty ? "—" : value} ); } +/** 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 ( + + ); +} + +/** 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 ( + + ); + } + + const route = + booking?.originYard && booking?.destinationYard + ? `${booking.originYard.label} → ${booking.destinationYard.label}` + : undefined; + + return ( + + ); +} + 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={} action={ - - + + - - Summary - - - - - - - - - - - + + + + Amounts + + + + + + + + + + + + + + + + + Line items + + + + + Description + Charge type + Quantity + Unit rate + Amount + + + + {(invoice.lines ?? []).map((line) => ( + + {line.description ?? line.chargeType} + + + {humanize(line.chargeType)} + + + {line.quantity} + + {formatMoney(line.unitRate, line.currency)} + + + {formatMoney(line.amount, line.currency)} + + + ))} + {(invoice.lines ?? []).length === 0 && ( + + + + No line items. + + + + )} + +
+ + + + + Subtotal + + + {formatMoney(invoice.subtotalAmount, invoice.currency)} + + + + + Tax + + + {formatMoney(invoice.taxAmount, invoice.currency)} + + + + + Paid + + + {formatMoney(invoice.paidAmount, invoice.currency)} + + + + + Total + + + {formatMoney(invoice.totalAmount, invoice.currency)} + + + +
+
-
+
- - - - - - Line items - - - - - Description - Charge type - Quantity - Unit rate - Amount - - - - {(invoice.lines ?? []).map((line) => ( - - {line.description ?? line.chargeType} - - - {humanize(line.chargeType)} - - - {line.quantity} - - {formatMoney(line.unitRate, line.currency)} - - - {formatMoney(line.amount, line.currency)} - - - ))} - {(invoice.lines ?? []).length === 0 && ( - - - - No line items. - - - - )} - -
- - - - - Subtotal - - - {formatMoney(invoice.subtotalAmount, invoice.currency)} - - - - - Tax - - - {formatMoney(invoice.taxAmount, invoice.currency)} - - - - - Paid - - - {formatMoney(invoice.paidAmount, invoice.currency)} - - - - - Total - - - {formatMoney(invoice.totalAmount, invoice.currency)} - - - + + + + -
-
+ +
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index cf6c37e80..acfc3c375 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -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 ( - - - - - - - - - - - - {schedule.reference ? ( - - {schedule.reference} - - ) : null} - - {schedule.route?.name ?? "Train schedule"} - - {schedule.train?.trainName ? ( - - {schedule.train.trainName} - - ) : null} - {schedule.train ? ( - - Train {schedule.train.code} - - ) : null} - - - {/* 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. */} - - {schedule.trainNumber ? ( - - - Train No. - - - {schedule.trainNumber} - - - ) : null} - {schedule.voyageNumber ? ( - - - Voyage No. - - - {schedule.voyageNumber} - - - ) : null} - {/* Merging rewrites the consist, so it is offered only while - the departure can still be edited. */} - {canEditBookings ? ( - - ) : null} - {schedule.direction ? ( - - - Direction - - - {schedule.direction} - - - ) : null} - - {(schedule.stops?.length ?? 0) >= 3 || - (schedule.bookings ?? []).some( - (b) => b.tradeDirection === "DOMESTIC", - ) ? ( - + {schedule.train.trainName ?? `Train ${schedule.train.code}`} + {schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""} + + ) : undefined + } + meta={ + + {schedule.reference ? ( + + {schedule.reference} + + ) : null} + + + {gatepassApplies && gatepassSecured ? ( + } + > + Gate pass secured + + ) : null} + {previewResult ? ( + - ) : ( - - - - )} - - - - - - - - {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( - - ) : null} - {canPrintMarshalling ? ( - - ) : null} - {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( - - ) : null} - {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( - - ) : null} - {schedule.windowPhase === "PRE_WINDOW" ? ( - - ) : null} - {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( - - ) : null} - {gatepassApplies ? ( - gatepassSecured ? ( - + ) : null} + {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + ) : null} + + + + + + + + {canPrintMarshalling ? ( + } + disabled={downloadMarshalling.isPending} + onClick={() => void openMarshallingDocument()} > - Gate pass secured - - ) : ( - - ) - ) : null} - + + ) : null} + + + } + /> - {previewResult ? ( - + + + {schedule.trainNumber ? ( + + + Train No. + + + {schedule.trainNumber} + + + ) : null} + {schedule.voyageNumber ? ( + + + Voyage No. + + + {schedule.voyageNumber} + + + ) : null} + {schedule.direction ? ( + + + Direction + + - } - > - Preview {previewResult.valid ? "valid" : "has issues"} - - ) : null} + > + {schedule.direction} + + + ) : null} + + {(schedule.stops?.length ?? 0) >= 3 || + (schedule.bookings ?? []).some( + (b) => b.tradeDirection === "DOMESTIC", + ) ? ( + + ) : ( + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index b49a11f83..6a86dba7d 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -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; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 68f48e187..2c9800836 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -930,10 +930,19 @@ export interface ClearanceView { importReleaseGranted?: boolean; } -/** Company an invoice is billed to (minimal projection). */ +/** + * Company an invoice is billed to. `findById` returns the full `Company` + * relation, not a stripped projection — these extra fields are what the + * backoffice invoice detail page's recipient card shows. + */ export interface IInvoiceCompany { id: string; name: string; + tin?: string | null; + vatNumber?: string | null; + phone?: string | null; + email?: string | null; + address?: string | null; } /** Company profile (importer/exporter/forwarder/…) an invoice is billed to. */