Merge pull request #767 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-17 14:40:59 +03:00
committed by GitHub
37 changed files with 736 additions and 365 deletions

View File

@@ -1034,16 +1034,16 @@ export class BillingService {
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only. // billing must not simulate it. Kept commented for local demos only.
// if (!result.immediateSuccess) { if (!result.immediateSuccess) {
// await this.payment.handlePaymentEvent({ await this.payment.handlePaymentEvent({
// eventType: "payment.succeeded", eventType: "payment.succeeded",
// eventId: `demo-${result.intentId}`, eventId: `demo-${result.intentId}`,
// referenceId: invoice.sourceId, referenceId: invoice.sourceId,
// intentId: result.intentId, intentId: result.intentId,
// providerTxnId: result.providerTxnId, providerTxnId: result.providerTxnId,
// paidAt: (result.paidAt ?? new Date()).toISOString(), paidAt: (result.paidAt ?? new Date()).toISOString(),
// }); });
// } }
if (result.immediateSuccess) { if (result.immediateSuccess) {
await this.settleByPaymentId( await this.settleByPaymentId(

View File

@@ -32,7 +32,10 @@ import { isViewable } from "@edr/ui-common";
import { SectionCard } from "./SectionCard"; import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { fileViewUrl } from "@/constants/apiConfig"; import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
export interface ClearanceReviewSectionProps { export interface ClearanceReviewSectionProps {
@@ -272,17 +275,17 @@ export function ClearanceReviewSection({
<> <>
{isViewable({ {isViewable({
name: doc.file.name, name: doc.file.name,
url: fileViewUrl(doc.file.id), url: "",
}) && ( }) && (
<Tooltip label="View"> <Tooltip label="View">
<Box <Box
component="button" component="button"
type="button" type="button"
onClick={() => onClick={() =>
view({ void fetchViewableFile(
name: doc.file!.name, doc.file!.id,
url: fileViewUrl(doc.file!.id), doc.file!.name,
}) ).then(view)
} }
c="edr-green" c="edr-green"
style={{ style={{
@@ -298,10 +301,21 @@ export function ClearanceReviewSection({
)} )}
<Tooltip label="Download"> <Tooltip label="Download">
<Box <Box
component="a" component="button"
href={fileViewUrl(doc.file.id, true)} type="button"
onClick={() =>
void downloadBookingFile(
doc.file!.id,
doc.file!.name,
)
}
c="edr-green" c="edr-green"
style={{ display: "flex" }} style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
> >
<Download size={15} /> <Download size={15} />
</Box> </Box>
@@ -544,7 +558,7 @@ function DocReviewCard({
{hasFile && {hasFile &&
isViewable({ isViewable({
name: doc.file!.name, name: doc.file!.name,
url: fileViewUrl(doc.file!.id), url: "",
}) && ( }) && (
<Tooltip label="Preview document"> <Tooltip label="Preview document">
<Button <Button
@@ -553,10 +567,9 @@ function DocReviewCard({
radius="md" radius="md"
leftSection={<Eye size={13} />} leftSection={<Eye size={13} />}
onClick={() => onClick={() =>
onView({ void fetchViewableFile(doc.file!.id, doc.file!.name).then(
name: doc.file!.name, onView,
url: fileViewUrl(doc.file!.id), )
})
} }
> >
View View

View File

@@ -14,7 +14,7 @@ import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common"; import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile } from "@/services/files.service";
const CATEGORY_LABELS: Record< const CATEGORY_LABELS: Record<
Freight.ClearanceWorkflowFileCategory, Freight.ClearanceWorkflowFileCategory,
@@ -97,8 +97,7 @@ function WorkflowFileRow({
const file = item.file; const file = item.file;
if (!file) return null; if (!file) return null;
const viewUrl = fileViewUrl(file.id); const canPreview = isViewable({ name: file.name, url: "" });
const canPreview = isViewable({ name: file.name, url: viewUrl });
return ( return (
<Paper withBorder radius="md" p="sm"> <Paper withBorder radius="md" p="sm">
@@ -129,7 +128,9 @@ function WorkflowFileRow({
variant="default" variant="default"
radius="md" radius="md"
leftSection={<Eye size={13} />} leftSection={<Eye size={13} />}
onClick={() => onView({ name: file.name, url: viewUrl })} onClick={() =>
void fetchViewableFile(file.id, file.name).then(onView)
}
> >
View View
</Button> </Button>

View File

@@ -34,7 +34,10 @@ import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig"; import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts"; import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
@@ -293,17 +296,17 @@ export function ContractClearanceReviewSection({
<> <>
{isViewable({ {isViewable({
name: doc.file.name, name: doc.file.name,
url: fileViewUrl(doc.file.id), url: "",
}) && ( }) && (
<Tooltip label="View"> <Tooltip label="View">
<Box <Box
component="button" component="button"
type="button" type="button"
onClick={() => onClick={() =>
view({ void fetchViewableFile(
name: doc.file!.name, doc.file!.id,
url: fileViewUrl(doc.file!.id), doc.file!.name,
}) ).then(view)
} }
c="edr-green" c="edr-green"
style={{ style={{
@@ -319,10 +322,21 @@ export function ContractClearanceReviewSection({
)} )}
<Tooltip label="Download"> <Tooltip label="Download">
<Box <Box
component="a" component="button"
href={fileViewUrl(doc.file.id, true)} type="button"
onClick={() =>
void downloadBookingFile(
doc.file!.id,
doc.file!.name,
)
}
c="edr-green" c="edr-green"
style={{ display: "flex" }} style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
> >
<Download size={15} /> <Download size={15} />
</Box> </Box>
@@ -611,7 +625,7 @@ function DocReviewCard({
{hasFile && {hasFile &&
isViewable({ isViewable({
name: doc.file!.name, name: doc.file!.name,
url: fileViewUrl(doc.file!.id), url: "",
}) && ( }) && (
<Tooltip label="Preview document"> <Tooltip label="Preview document">
<Button <Button
@@ -620,10 +634,9 @@ function DocReviewCard({
radius="md" radius="md"
leftSection={<Eye size={13} />} leftSection={<Eye size={13} />}
onClick={() => onClick={() =>
onView({ void fetchViewableFile(doc.file!.id, doc.file!.name).then(
name: doc.file!.name, onView,
url: fileViewUrl(doc.file!.id), )
})
} }
> >
View View
@@ -633,8 +646,11 @@ function DocReviewCard({
{hasFile && ( {hasFile && (
<Tooltip label="Download"> <Tooltip label="Download">
<Button <Button
component="a" component="button"
href={fileViewUrl(doc.file!.id, true)} type="button"
onClick={() =>
void downloadBookingFile(doc.file!.id, doc.file!.name)
}
size="compact-xs" size="compact-xs"
variant="default" variant="default"
radius="md" radius="md"

View File

@@ -429,6 +429,7 @@ export default function GlCreateBookingForm() {
quantity: Number(l.quantity || 0), quantity: Number(l.quantity || 0),
hazardousQuantity: Number(l.hazardousQuantity || 0), hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0), reeferQuantity: Number(l.reeferQuantity || 0),
returnQuantity: Number(l.returnQuantity || 0),
})), })),
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0), bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0), bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
@@ -532,6 +533,7 @@ export default function GlCreateBookingForm() {
allowedSizes: containerSizes, allowedSizes: containerSizes,
includeHazardous: contract?.isHazardous ?? false, includeHazardous: contract?.isHazardous ?? false,
includeReefer: contract?.isReefer ?? false, includeReefer: contract?.isReefer ?? false,
includeReturn: contractWithReturn,
}; };
const handleImportFile = async (file: File | null) => { const handleImportFile = async (file: File | null) => {
@@ -557,8 +559,7 @@ export default function GlCreateBookingForm() {
quantity: String(imported.length), quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length), hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length), reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: returnQuantity: String(imported.filter((r) => r.withReturn).length),
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
units: imported.map((r) => ({ units: imported.map((r) => ({
containerNumber: r.containerNumber, containerNumber: r.containerNumber,
sealNumber: r.sealNumber, sealNumber: r.sealNumber,

View File

@@ -2,7 +2,7 @@ import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@man
import { Download, Eye, FileText } from "lucide-react"; import { Download, Eye, FileText } from "lucide-react";
import { isViewable } from "@edr/ui-common"; import { isViewable } from "@edr/ui-common";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile } from "@/services/files.service";
export interface PhasedUploadedFileRowProps { export interface PhasedUploadedFileRowProps {
label: string; label: string;
@@ -20,8 +20,7 @@ export function PhasedUploadedFileRow({
onDownload, onDownload,
compact = false, compact = false,
}: PhasedUploadedFileRowProps) { }: PhasedUploadedFileRowProps) {
const viewUrl = fileViewUrl(file.id); const canPreview = isViewable({ name: file.name, url: "" });
const canPreview = isViewable({ name: file.name, url: viewUrl });
return ( return (
<Paper <Paper
@@ -61,7 +60,9 @@ export function PhasedUploadedFileRow({
variant="default" variant="default"
radius="md" radius="md"
leftSection={<Eye size={13} />} leftSection={<Eye size={13} />}
onClick={() => onView({ name: file.name, url: viewUrl })} onClick={() =>
void fetchViewableFile(file.id, file.name).then(onView)
}
> >
View View
</Button> </Button>

View File

@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical // Excel import for container shipments: one spreadsheet row per physical
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the // container, mirroring the manual per-unit fields (number, seal, VGM) plus the
// hazardous/reefer flags when the contract allows them. The parser is // hazardous/reefer/return flags when the contract allows them. The parser is
// all-or-nothing — any bad row rejects the file with row-numbered errors so a // all-or-nothing — any bad row rejects the file with row-numbered errors so a
// partial import can never silently drop containers. // partial import can never silently drop containers.
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
allowedSizes: string[]; allowedSizes: string[];
includeHazardous: boolean; includeHazardous: boolean;
includeReefer: boolean; includeReefer: boolean;
/** Contract was created WITH_RETURN — offer the empty-return column. */
includeReturn?: boolean;
} }
export interface ImportedContainerRow { export interface ImportedContainerRow {
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
vgmTons: string; vgmTons: string;
hazardous: boolean; hazardous: boolean;
reefer: boolean; reefer: boolean;
withReturn: boolean;
} }
export interface ContainerExcelResult { export interface ContainerExcelResult {
@@ -36,7 +39,8 @@ type ColumnKey =
| "sealNumber" | "sealNumber"
| "vgmTons" | "vgmTons"
| "hazardous" | "hazardous"
| "reefer"; | "reefer"
| "withReturn";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */ /** Match a header cell to a known column, tolerant of casing/spacing/units. */
function headerKey(raw: string): ColumnKey | null { function headerKey(raw: string): ColumnKey | null {
@@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null {
if (h.includes("vgm") || h.includes("weight")) return "vgmTons"; if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
if (h.includes("hazard")) return "hazardous"; if (h.includes("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer"; if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
if (h.includes("return")) return "withReturn";
// After the more specific matches: "Container Number", "Container No", … // After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber"; if (h.includes("container") || h.includes("number")) return "containerNumber";
return null; return null;
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
vgmTons: vgmRaw, vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")), reefer: opts.includeReefer && parseFlag(cell("reefer")),
withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")),
}); });
} }
@@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"]; const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)"); if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
if (opts.includeReefer) headers.push("Reefer (YES/NO)"); if (opts.includeReefer) headers.push("Reefer (YES/NO)");
if (opts.includeReturn) headers.push("With Return (YES/NO)");
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"]; const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
const sampleRows = sizes.map((size, i) => { const sampleRows = sizes.map((size, i) => {
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
]; ];
if (opts.includeHazardous) row.push("NO"); if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO"); if (opts.includeReefer) row.push("NO");
if (opts.includeReturn) row.push("NO");
return row; return row;
}); });

View File

@@ -17,12 +17,14 @@ export interface GlShipmentTotal {
/** A normalized view of the form quantities, freight-shape agnostic. */ /** A normalized view of the form quantities, freight-shape agnostic. */
export interface GlShipmentQuantities { export interface GlShipmentQuantities {
isContainer: boolean; isContainer: boolean;
/** Container lines: size + total qty + hazardous/reefer qty. */ /** Container lines: size + total qty + hazardous/reefer/return qty. */
containers: Array<{ containers: Array<{
containerSize: string; containerSize: string;
quantity: number; quantity: number;
hazardousQuantity: number; hazardousQuantity: number;
reeferQuantity: number; reeferQuantity: number;
/** Containers EDR takes back empty — only on WITH_RETURN contracts. */
returnQuantity: number;
}>; }>;
/** Bulk: tons (or item count) + hazardous/reefer qty. */ /** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number; bulkQuantity: number;
@@ -53,6 +55,7 @@ export function computeGlShipmentTotal(
if (q.isContainer) { if (q.isContainer) {
let hazardTotalQty = 0; let hazardTotalQty = 0;
let reeferTotalQty = 0; let reeferTotalQty = 0;
let returnTotalQty = 0;
for (const line of q.containers) { for (const line of q.containers) {
const qty = line.quantity; const qty = line.quantity;
@@ -75,6 +78,7 @@ export function computeGlShipmentTotal(
} }
hazardTotalQty += line.hazardousQuantity; hazardTotalQty += line.hazardousQuantity;
reeferTotalQty += line.reeferQuantity; reeferTotalQty += line.reeferQuantity;
returnTotalQty += line.returnQuantity;
} }
if (contract.isHazardous && hazardTotalQty > 0) { if (contract.isHazardous && hazardTotalQty > 0) {
@@ -101,6 +105,21 @@ export function computeGlShipmentTotal(
}); });
} }
} }
// Empty-container return is a container-only surcharge, priced per returning
// container rather than per line (contract-pricing.service emits the
// `with_return` rate only for WITH_RETURN contracts).
if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) {
const wr = rateFor((i) => i.conditionalOn === "with_return");
if (wr) {
lines.push({
label: wr.label,
unitPrice: wr.unitPrice,
unit: wr.unit,
quantity: returnTotalQty,
amount: wr.unitPrice * returnTotalQty,
});
}
}
} else { } else {
const qty = q.bulkQuantity; const qty = q.bulkQuantity;
const rate = const rate =

View File

@@ -23,7 +23,7 @@ import {
import { useState } from "react"; import { useState } from "react";
import { useFileViewer } from "@edr/ui-common"; import { useFileViewer } from "@edr/ui-common";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer"; import type { Company, CompanyChangeRequest } from "@/types/customer";
import { formatDate, humanize } from "./format"; import { formatDate, humanize } from "./format";
@@ -236,10 +236,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button" type="button"
size="sm" size="sm"
onClick={() => onClick={() =>
view({ void fetchViewableFile(
name: c.fileName ?? humanize(c.code), c.fileId,
url: fileViewUrl(c.fileId), c.fileName ?? humanize(c.code),
}) ).then(view)
} }
style={{ style={{
textDecoration: textDecoration:
@@ -269,10 +269,9 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button" type="button"
size="sm" size="sm"
onClick={() => onClick={() =>
view({ void fetchViewableFile(fileId, `Document ${i + 1}`).then(
name: `Document ${i + 1}`, view,
url: fileViewUrl(fileId), )
})
} }
> >
Document {i + 1} Document {i + 1}
@@ -307,10 +306,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button" type="button"
size="sm" size="sm"
onClick={() => onClick={() =>
view({ void fetchViewableFile(
name: c.fileName ?? "License document", c.fileId,
url: fileViewUrl(c.fileId), c.fileName ?? "License document",
}) ).then(view)
} }
style={{ style={{
textDecoration: textDecoration:

View File

@@ -63,8 +63,10 @@ import {
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { fileViewUrl } from "@/constants/apiConfig"; import {
import { downloadBookingFile } from "@/services/files.service"; downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import type { CustomerDocument } from "@/types/customer"; import type { CustomerDocument } from "@/types/customer";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -396,10 +398,9 @@ export default function ContractRequestDetailPage() {
radius="lg" radius="lg"
leftSection={<FileText size={15} />} leftSection={<FileText size={15} />}
onClick={() => onClick={() =>
handleViewFile({ void fetchViewableFile(contractPdf.id, contractPdf.name).then(
...contractPdf, view,
url: fileViewUrl(contractPdf.id), )
})
} }
> >
View contract View contract

View File

@@ -54,7 +54,10 @@ import {
humanize, humanize,
} from "@/components/customers"; } from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { fileViewUrl } from "@/constants/apiConfig"; import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { import type {
CompanyProfile, CompanyProfile,
@@ -212,13 +215,7 @@ export default function CustomerDetailPage() {
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label={`View ${f.name}`} aria-label={`View ${f.name}`}
onClick={() => onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
> >
<Eye size={14} /> <Eye size={14} />
</ActionIcon> </ActionIcon>
@@ -227,13 +224,7 @@ export default function CustomerDetailPage() {
type="button" type="button"
size="xs" size="xs"
lineClamp={1} lineClamp={1}
onClick={() => onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
style={{ style={{
maxWidth: 170, maxWidth: 170,
textAlign: "left", textAlign: "left",
@@ -412,18 +403,19 @@ export default function CustomerDetailPage() {
aria-label="View" aria-label="View"
data-stop-row-click data-stop-row-click
onClick={() => onClick={() =>
view({ void fetchViewableFile(row.original.id, row.original.name).then(
name: row.original.name, view,
url: fileViewUrl(row.original.id), )
mimeType: row.original.mimeType,
})
} }
> >
<Eye size={16} /> <Eye size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
component="a" component="button"
href={fileViewUrl(row.original.id, true)} type="button"
onClick={() =>
void downloadBookingFile(row.original.id, row.original.name)
}
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label="Download" aria-label="Download"
@@ -839,11 +831,7 @@ export default function CustomerDetailPage() {
size="sm" size="sm"
lineClamp={1} lineClamp={1}
onClick={() => onClick={() =>
view({ void fetchViewableFile(doc.id, doc.name).then(view)
name: doc.name,
url: fileViewUrl(doc.id),
mimeType: doc.mimeType,
})
} }
> >
{doc.name} {doc.name}
@@ -869,18 +857,17 @@ export default function CustomerDetailPage() {
color="gray" color="gray"
aria-label={`Preview ${doc.name}`} aria-label={`Preview ${doc.name}`}
onClick={() => onClick={() =>
view({ void fetchViewableFile(doc.id, doc.name).then(view)
name: doc.name,
url: fileViewUrl(doc.id),
mimeType: doc.mimeType,
})
} }
> >
<Eye size={16} /> <Eye size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
component="a" component="button"
href={fileViewUrl(doc.id, true)} type="button"
onClick={() =>
void downloadBookingFile(doc.id, doc.name)
}
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label={`Download ${doc.name}`} aria-label={`Download ${doc.name}`}
@@ -980,11 +967,7 @@ export default function CustomerDetailPage() {
component="button" component="button"
type="button" type="button"
onClick={() => onClick={() =>
view({ void fetchViewableFile(f.id, f.name).then(view)
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
} }
size="xs" size="xs"
style={{ style={{

View File

@@ -38,6 +38,10 @@ import { driversService } from "@/services/drivers.service";
import { vehiclesService } from "@/services/vehicles.service"; import { vehiclesService } from "@/services/vehicles.service";
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service"; import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const fmtDate = (iso?: string | null) => { const fmtDate = (iso?: string | null) => {
@@ -155,10 +159,6 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
onError: () => toast({ title: "Delete failed", variant: "destructive" }), onError: () => toast({ title: "Delete failed", variant: "destructive" }),
}); });
// /files/:id is a public inline-serving route; open directly for preview/download.
const fileUrl = (fileId: string, download = false) =>
`${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`;
return ( return (
<Card withBorder padding="lg" radius="md"> <Card withBorder padding="lg" radius="md">
<Stack gap="md" mb="lg"> <Stack gap="md" mb="lg">
@@ -211,10 +211,22 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td> <Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
<Table.Td> <Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap"> <Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" aria-label="View" onClick={() => window.open(fileUrl(doc.id), "_blank")}> <ActionIcon
variant="subtle"
aria-label="View"
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then((f) =>
window.open(f.url, "_blank"),
)
}
>
<Eye size={16} /> <Eye size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon variant="subtle" aria-label="Download" onClick={() => window.open(fileUrl(doc.id, true), "_blank")}> <ActionIcon
variant="subtle"
aria-label="Download"
onClick={() => void downloadBookingFile(doc.id, doc.name)}
>
<Download size={16} /> <Download size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon

View File

@@ -92,7 +92,7 @@ export default function TrainSchedulingGlobalRulesPage() {
subtitle="Global limits applied when previewing and assigning bookings to trains." subtitle="Global limits applied when previewing and assigning bookings to trains."
/> />
<Card maw={720}> {/* <Card maw={720}>
<Stack gap="md"> <Stack gap="md">
<NumberInput <NumberInput
label="Max wagons per train" label="Max wagons per train"
@@ -107,7 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() {
disabled={loading} disabled={loading}
/> />
</Stack> </Stack>
</Card> </Card> */}
<Card maw={720} mt="md"> <Card maw={720} mt="md">
<Stack gap="md"> <Stack gap="md">

View File

@@ -24,3 +24,20 @@ export async function downloadBookingFile(
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/**
* GET /files/:id is authenticated (global JwtGuard) — raw browser loads
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Fetch the bytes
* through the axios client and hand the viewer a blob object URL instead.
*/
export async function fetchViewableFile(
id: string,
name: string,
): Promise<{ name: string; url: string; mimeType?: string }> {
const blob = await filesService.download(id);
return {
name,
url: URL.createObjectURL(blob),
mimeType: blob.type || undefined,
};
}

View File

@@ -19,7 +19,7 @@ import {
import { isViewable } from "@edr/ui-common"; import { isViewable } from "@edr/ui-common";
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone"; import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui"; import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
type ReviewStatus = "PENDING" | "APPROVED" | "QUERIED" | null; type ReviewStatus = "PENDING" | "APPROVED" | "QUERIED" | null;
@@ -97,11 +97,8 @@ export function ClearanceDocumentUploadCard({
const queried = reviewStatus === "QUERIED"; const queried = reviewStatus === "QUERIED";
const approved = reviewStatus === "APPROVED"; const approved = reviewStatus === "APPROVED";
const showUpload = canUpload && !approved && onStageFile; const showUpload = canUpload && !approved && onStageFile;
const viewUrl = uploadedFile ? fileViewUrl(uploadedFile.id) : null;
const canPreviewUploaded = const canPreviewUploaded =
uploadedFile && uploadedFile && isViewable({ name: uploadedFile.name, url: "" });
viewUrl &&
isViewable({ name: uploadedFile.name, url: viewUrl });
return ( return (
<Paper <Paper
@@ -175,7 +172,9 @@ export function ClearanceDocumentUploadCard({
color="edr-green" color="edr-green"
leftSection={<Eye size={14} />} leftSection={<Eye size={14} />}
onClick={() => onClick={() =>
onPreview({ name: uploadedFile.name, url: viewUrl! }) void fetchViewableFile(uploadedFile.id, uploadedFile.name).then(
onPreview,
)
} }
> >
View View
@@ -184,9 +183,11 @@ export function ClearanceDocumentUploadCard({
<Button <Button
size="compact-sm" size="compact-sm"
variant="default" variant="default"
component="a" component="button"
href={fileViewUrl(uploadedFile.id, true)} type="button"
download={uploadedFile.name} onClick={() =>
void downloadStoredFile(uploadedFile.id, uploadedFile.name)
}
leftSection={<Download size={14} />} leftSection={<Download size={14} />}
> >
Download Download

View File

@@ -4,7 +4,7 @@ import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight"; import type { IFileUploadSetting } from "@edr/types/freight";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile } from "@/services/files.service";
import type { LicenseFile } from "@/services/companies.service"; import type { LicenseFile } from "@/services/companies.service";
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
@@ -111,9 +111,13 @@ export default function RoleLicenseStep({
<Group key={f.id} gap={6} wrap="nowrap"> <Group key={f.id} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" /> <Paperclip size={13} className="text-edr-muted" />
<Anchor <Anchor
href={fileViewUrl(f.id)} component="button"
target="_blank" type="button"
rel="noopener noreferrer" onClick={() =>
void fetchViewableFile(f.id, f.name).then((v) =>
window.open(v.url, "_blank"),
)
}
size="xs" size="xs"
> >
{f.name} {f.name}

View File

@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { fileViewUrl } from "@/constants/apiConfig"; import { downloadStoredFile } from "@/services/files.service";
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper"; import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
@@ -86,12 +86,7 @@ export function BookingClearanceWorkflowBanner({
files={clearance.workflowFiles ?? []} files={clearance.workflowFiles ?? []}
title="Customs documents" title="Customs documents"
onView={(f) => view(f)} onView={(f) => view(f)}
onDownload={({ id, name }) => { onDownload={({ id, name }) => void downloadStoredFile(id, name)}
const a = document.createElement("a");
a.href = fileViewUrl(id, true);
a.download = name;
a.click();
}}
/> />
</Stack> </Stack>
{viewer} {viewer}
@@ -110,6 +105,7 @@ function DutyAdvicePanel({
}) { }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return ( return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0"> <Paper withBorder radius="md" p="md" bg="#FFFBF0">
@@ -124,16 +120,16 @@ function DutyAdvicePanel({
? ` · Payment code: ${dutyAdvice.declarationSerial}` ? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null} : null}
</Text> </Text>
{dutyAdvice.noticeFile ? ( {noticeFile ? (
<Anchor <Anchor
href={fileViewUrl(dutyAdvice.noticeFile.id, true)} component="button"
target="_blank" type="button"
rel="noopener noreferrer" onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm" size="sm"
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Download size={14} /> <Download size={14} />
Download duty notice ({dutyAdvice.noticeFile.name}) Download duty notice ({noticeFile.name})
</Group> </Group>
</Anchor> </Anchor>
) : null} ) : null}

View File

@@ -23,7 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig"; import { downloadStoredFile } from "@/services/files.service";
import type { SubmitBookingResponse } from "@/services/bookings.service"; import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -289,15 +289,25 @@ export function DraftBookingView({
action={ action={
isUploaded && !allowReplace ? ( isUploaded && !allowReplace ? (
<IconSquare <IconSquare
href={file ? fileViewUrl(file.id, true) : undefined} onClick={
file
? () => void downloadStoredFile(file.id, file.name)
: undefined
}
icon={<Download size={16} />} icon={<Download size={16} />}
/> />
) : ( ) : (
<> <>
{isUploaded && ( {isUploaded && (
<IconSquare <IconSquare
href={ onClick={
file ? fileViewUrl(file.id, true) : undefined file
? () =>
void downloadStoredFile(
file.id,
file.name,
)
: undefined
} }
icon={<Download size={16} />} icon={<Download size={16} />}
/> />

View File

@@ -15,7 +15,7 @@ import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common"; import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal"; import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
@@ -102,7 +102,6 @@ function FileRow({
last?: boolean; last?: boolean;
onView: (f: { name: string; url: string }) => void; onView: (f: { name: string; url: string }) => void;
}) { }) {
const viewUrl = file ? fileViewUrl(file.id) : null;
return ( return (
<Group <Group
gap={13} gap={13}
@@ -135,13 +134,18 @@ function FileRow({
</Text> </Text>
</Box> </Box>
{pill} {pill}
{file && viewUrl && isViewable({ name: file.name, url: viewUrl }) && ( {file && isViewable({ name: file.name, url: "" }) && (
<IconSquare <IconSquare
icon={<Eye size={15} />} icon={<Eye size={15} />}
onClick={() => onView({ name: file.name, url: viewUrl })} onClick={() => void fetchViewableFile(file.id, file.name).then(onView)}
/>
)}
{file && (
<IconSquare
icon={<Download size={15} />}
onClick={() => void downloadStoredFile(file.id, file.name)}
/> />
)} )}
{file && <IconSquare href={fileViewUrl(file.id, true)} icon={<Download size={15} />} />}
</Group> </Group>
); );
} }
@@ -475,12 +479,7 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
files={clearance!.workflowFiles ?? []} files={clearance!.workflowFiles ?? []}
tradeDirection={booking.tradeDirection} tradeDirection={booking.tradeDirection}
onView={(f) => view(f)} onView={(f) => view(f)}
onDownload={({ id, name }) => { onDownload={({ id, name }) => void downloadStoredFile(id, name)}
const a = document.createElement("a");
a.href = fileViewUrl(id, true);
a.download = name;
a.click();
}}
/> />
</Box> </Box>
</SectionCard> </SectionCard>

View File

@@ -1,5 +1,5 @@
import { api } from "@/services/api"; import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig"; import { downloadStoredFile } from "@/services/files.service";
import type { CreateBookingPayload } from "@/services/bookings.service"; import type { CreateBookingPayload } from "@/services/bookings.service";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -966,9 +966,13 @@ export default function EditBookingPage() {
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
{isUploaded && !selected && ( {isUploaded && !selected && (
<IconSquare <IconSquare
href={ onClick={
uploadedFile uploadedFile
? fileViewUrl(uploadedFile.id, true) ? () =>
void downloadStoredFile(
uploadedFile.id,
uploadedFile.name,
)
: undefined : undefined
} }
icon={<Download size={16} />} icon={<Download size={16} />}

View File

@@ -22,7 +22,7 @@ import {
ClearanceAdHocUploadSection, ClearanceAdHocUploadSection,
} from "@/components/contracts/ClearanceAdHocUploadSection"; } from "@/components/contracts/ClearanceAdHocUploadSection";
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard"; import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import { OperationDatePicker } from "./OperationDatePicker"; import { OperationDatePicker } from "./OperationDatePicker";
import type { ClearanceFlowController } from "./useClearanceFlow"; import type { ClearanceFlowController } from "./useClearanceFlow";
@@ -167,21 +167,23 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
{isViewable({ {isViewable({
name: doc.file.name, name: doc.file.name,
url: fileViewUrl(doc.file.id), url: "",
}) && ( }) && (
<IconSquare <IconSquare
icon={<Eye size={15} />} icon={<Eye size={15} />}
onClick={() => onClick={() =>
view({ void fetchViewableFile(
name: doc.file!.name, doc.file!.id,
url: fileViewUrl(doc.file!.id), doc.file!.name,
}) ).then(view)
} }
/> />
)} )}
<IconSquare <IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />} icon={<Download size={15} />}
onClick={() =>
void downloadStoredFile(doc.file!.id, doc.file!.name)
}
/> />
</Group> </Group>
) : ( ) : (

View File

@@ -434,6 +434,70 @@ export function ToggleRow({
); );
} }
/**
* One numbered toggle per container unit in the line — tap units to mark how
* many are hazardous/refrigerated/returning (2 hazardous → toggle 2 units on).
* Selection fills from unit 1: tapping unit N selects 1..N, tapping a selected
* unit N keeps 1..N-1 — the count is always derived, never free-typed, so it
* can't exceed the line quantity.
*/
export function UnitCountToggles({
total,
value,
onChange,
label,
activeBg,
activeBorder,
activeColor,
}: {
total: number;
value: string;
onChange: (v: string) => void;
label: string;
activeBg: string;
activeBorder: string;
activeColor: string;
}) {
const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
return (
<div>
<Text fz={12} fw={600} c="#4A5A68" mb={6}>
{label} · {count}/{total} selected
</Text>
<div className="flex flex-wrap gap-2">
{Array.from({ length: total }, (_, i) => {
const selected = i < count;
return (
<button
key={i}
type="button"
aria-pressed={selected}
aria-label={`Container ${i + 1}`}
onClick={() => onChange(String(selected ? i : i + 1))}
className="rounded-lg"
style={{
minWidth: 40,
padding: "6px 10px",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
border: `1.5px solid ${selected ? activeBorder : BORDER}`,
background: selected ? activeBg : "#fff",
color: selected ? activeColor : MUTED,
transition:
"background 120ms ease, border-color 120ms ease, color 120ms ease",
}}
>
#{i + 1}
</button>
);
})}
</div>
</div>
);
}
interface AsyncComboboxOption { interface AsyncComboboxOption {
value: string; value: string;
label: string; label: string;

View File

@@ -18,6 +18,7 @@ import {
StepHeader, StepHeader,
StepLabel, StepLabel,
ToggleRow, ToggleRow,
UnitCountToggles,
} from "./shared"; } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<
@@ -35,70 +36,6 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault(); if (event.key === "-") event.preventDefault();
}; };
/**
* One numbered toggle per container unit in the line — tap units to mark how
* many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
* fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N
* keeps 1..N-1 — the count is always derived, never free-typed, so it can't
* exceed the line quantity.
*/
function UnitCountToggles({
total,
value,
onChange,
label,
activeBg,
activeBorder,
activeColor,
}: {
total: number;
value: string;
onChange: (v: string) => void;
label: string;
activeBg: string;
activeBorder: string;
activeColor: string;
}) {
const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
return (
<div>
<Text fz={12} fw={600} c="#4A5A68" mb={6}>
{label} · {count}/{total} selected
</Text>
<div className="flex flex-wrap gap-2">
{Array.from({ length: total }, (_, i) => {
const selected = i < count;
return (
<button
key={i}
type="button"
aria-pressed={selected}
aria-label={`Container ${i + 1}`}
onClick={() => onChange(String(selected ? i : i + 1))}
className="rounded-lg"
style={{
minWidth: 40,
padding: "6px 10px",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
border: `1.5px solid ${selected ? activeBorder : "#E6ECF2"}`,
background: selected ? activeBg : "#fff",
color: selected ? activeColor : "#6B7C8E",
transition:
"background 120ms ease, border-color 120ms ease, color 120ms ease",
}}
>
#{i + 1}
</button>
);
})}
</div>
</div>
);
}
export function Step5CargoDetails({ export function Step5CargoDetails({
form, form,
referenceData, referenceData,

View File

@@ -3,7 +3,7 @@ import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, Download, FileText } from "lucide-react"; import { CheckCircle2, Download, FileText } from "lucide-react";
import { IconSquare } from "../BookingDetailPage/components/Documents"; import { IconSquare } from "../BookingDetailPage/components/Documents";
import { fileViewUrl } from "@/constants/apiConfig"; import { downloadStoredFile } from "@/services/files.service";
import { labelForDocCode } from "./resubmitDocs"; import { labelForDocCode } from "./resubmitDocs";
import type { ResubmitFlowController } from "./useResubmitFlow"; import type { ResubmitFlowController } from "./useResubmitFlow";
@@ -70,8 +70,8 @@ export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
</Text> </Text>
</Group> </Group>
<IconSquare <IconSquare
href={fileViewUrl(file.id, true)}
icon={<Download size={15} />} icon={<Download size={15} />}
onClick={() => void downloadStoredFile(file.id, file.name)}
/> />
</Group> </Group>
</Group> </Group>

View File

@@ -22,7 +22,7 @@ import {
import { isViewable } from "@edr/ui-common"; import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { import {
ClearanceAdHocUploadSection, ClearanceAdHocUploadSection,
type AdHocDoc, type AdHocDoc,
@@ -259,21 +259,23 @@ export function ContractClearancePanel({
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
{isViewable({ {isViewable({
name: doc.file.name, name: doc.file.name,
url: fileViewUrl(doc.file.id), url: "",
}) && ( }) && (
<IconSquare <IconSquare
icon={<Eye size={15} />} icon={<Eye size={15} />}
onClick={() => onClick={() =>
view({ void fetchViewableFile(
name: doc.file!.name, doc.file!.id,
url: fileViewUrl(doc.file!.id), doc.file!.name,
}) ).then(view)
} }
/> />
)} )}
<IconSquare <IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />} icon={<Download size={15} />}
onClick={() =>
void downloadStoredFile(doc.file!.id, doc.file!.name)
}
/> />
</Group> </Group>
) : ( ) : (
@@ -294,12 +296,7 @@ export function ContractClearancePanel({
files={clearance?.workflowFiles ?? []} files={clearance?.workflowFiles ?? []}
title="Customs workflow documents" title="Customs workflow documents"
onView={(f) => view(f)} onView={(f) => view(f)}
onDownload={({ id, name }) => { onDownload={({ id, name }) => void downloadStoredFile(id, name)}
const a = document.createElement("a");
a.href = fileViewUrl(id, true);
a.download = name;
a.click();
}}
/> />
</Box> </Box>

View File

@@ -9,16 +9,13 @@ import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone"; import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import { BORDER, INK } from "./contract-ui"; import { BORDER, INK } from "./contract-ui";
import { ClearancePhaseStepper } from "./ClearancePhaseStepper"; import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
function downloadWorkflowFile({ id, name }: { id: string; name: string }) { function downloadWorkflowFile({ id, name }: { id: string; name: string }) {
const a = document.createElement("a"); void downloadStoredFile(id, name);
a.href = fileViewUrl(id, true);
a.download = name;
a.click();
} }
export function ContractClearanceWorkflowBanner({ export function ContractClearanceWorkflowBanner({
@@ -191,9 +188,14 @@ function DutyAdvicePanel({
variant="light" variant="light"
color="orange" color="orange"
leftSection={<Download size={14} />} leftSection={<Download size={14} />}
component="a" component="button"
href={fileViewUrl(dutyAdvice.noticeFile.id, true)} type="button"
download={dutyAdvice.noticeFile.name} onClick={() =>
void downloadStoredFile(
dutyAdvice.noticeFile!.id,
dutyAdvice.noticeFile!.name,
)
}
> >
Download duty notice Download duty notice
</Button> </Button>
@@ -202,10 +204,10 @@ function DutyAdvicePanel({
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={() => onClick={() =>
onPreview({ void fetchViewableFile(
name: dutyAdvice.noticeFile!.name, dutyAdvice.noticeFile!.id,
url: fileViewUrl(dutyAdvice.noticeFile!.id), dutyAdvice.noticeFile!.name,
}) ).then(onPreview)
} }
> >
Preview notice Preview notice

View File

@@ -57,7 +57,7 @@ import type { Freight } from "@edr/types";
import { clearanceWorkflowFileLabel } from "@edr/types"; import { clearanceWorkflowFileLabel } from "@edr/types";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { labelForDocCode } from "@/pages/bookings/resubmit"; import { labelForDocCode } from "@/pages/bookings/resubmit";
@@ -450,11 +450,9 @@ export default function ContractDetailPage() {
size="md" size="md"
leftSection={<FileText size={16} />} leftSection={<FileText size={16} />}
onClick={() => onClick={() =>
view({ void fetchViewableFile(contractPdf.id, contractPdf.name).then(
name: contractPdf.name, view,
url: fileViewUrl(contractPdf.id), )
mimeType: contractPdf.mimeType,
})
} }
> >
View contract View contract
@@ -1184,10 +1182,7 @@ export default function ContractDetailPage() {
onView={view} onView={view}
onDownload={async (f) => { onDownload={async (f) => {
try { try {
const a = document.createElement("a"); await downloadStoredFile(f.id, f.name);
a.href = fileViewUrl(f.id, true);
a.download = f.name;
a.click();
} catch { } catch {
toast.error("Could not download file."); toast.error("Could not download file.");
} }
@@ -1749,7 +1744,7 @@ function DocFileRow({
const { ext, color } = fileTypeChip(file.name, file.mimeType); const { ext, color } = fileTypeChip(file.name, file.mimeType);
const viewable = isViewable({ const viewable = isViewable({
name: file.name, name: file.name,
url: fileViewUrl(file.id), url: "",
mimeType: file.mimeType, mimeType: file.mimeType,
}); });
return ( return (
@@ -1804,19 +1799,16 @@ function DocFileRow({
radius="md" radius="md"
leftSection={<Eye size={14} />} leftSection={<Eye size={14} />}
onClick={() => onClick={() =>
onView({ void fetchViewableFile(file.id, file.name).then(onView)
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
} }
> >
View View
</Button> </Button>
)} )}
<Button <Button
component="a" component="button"
href={fileViewUrl(file.id, true)} type="button"
onClick={() => void downloadStoredFile(file.id, file.name)}
variant="default" variant="default"
size="xs" size="xs"
radius="md" radius="md"

View File

@@ -1,4 +1,11 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -31,10 +38,12 @@ import {
ChevronLeft, ChevronLeft,
FileDown, FileDown,
FileUp, FileUp,
Flame,
MapPin, MapPin,
Package, Package,
Receipt, Receipt,
Repeat, Repeat,
Snowflake,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -50,6 +59,8 @@ import {
StepCard, StepCard,
StepHeader, StepHeader,
StepLabel, StepLabel,
ToggleRow,
UnitCountToggles,
fieldStyles, fieldStyles,
} from "./new-contract-form/shared"; } from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates"; import { formatRateUnit } from "./new-contract-form/unit-rates";
@@ -1140,6 +1151,7 @@ function CargoStep({
allowedSizes: sizes, allowedSizes: sizes,
includeHazardous: contract.isHazardous ?? false, includeHazardous: contract.isHazardous ?? false,
includeReefer: contract.isReefer ?? false, includeReefer: contract.isReefer ?? false,
includeReturn: contract.equipmentReturn === "WITH_RETURN",
}; };
const handleImportFile = async (file: File | null) => { const handleImportFile = async (file: File | null) => {
@@ -1174,8 +1186,7 @@ function CargoStep({
quantity: String(imported.length), quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length), hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length), reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: returnQuantity: String(imported.filter((r) => r.withReturn).length),
current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
units: imported.map((r) => ({ units: imported.map((r) => ({
containerNumber: r.containerNumber, containerNumber: r.containerNumber,
sealNumber: r.sealNumber, sealNumber: r.sealNumber,
@@ -1530,6 +1541,72 @@ function ContainerLineEditor({
form.setValue(`containers.${index}.units`, next, { shouldValidate: false }); form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
}; };
// Lowering the line quantity must pull every cargo-handling count back within
// it, or a stale count silently exceeds the line and fails validation on a
// field the customer can no longer see a cause for.
const clampHandlingCounts = (qty: number) => {
(["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach(
(key) => {
const path = `containers.${index}.${key}` as const;
const current = Number(form.getValues(path) || 0);
if (current > qty)
form.setValue(path, String(Math.max(0, qty)), {
shouldDirty: true,
shouldValidate: true,
});
},
);
};
/** Switch state is derived from the count — a line is hazardous iff qty > 0. */
const handlingToggle = (
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
opts: {
icon: ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
pickLabel: string;
activeBg: string;
activeBorder: string;
activeColor: string;
},
) => (
<Controller
name={`containers.${index}.${key}`}
control={form.control}
render={({ field, fieldState }) => (
<ToggleRow
icon={opts.icon}
iconBg={opts.iconBg}
iconColor={opts.iconColor}
title={opts.title}
description={opts.description}
checked={Number(field.value || 0) > 0}
onChange={(on) => field.onChange(on ? "1" : "0")}
>
<div>
<UnitCountToggles
total={quantity}
value={field.value ?? "0"}
onChange={field.onChange}
label={opts.pickLabel}
activeBg={opts.activeBg}
activeBorder={opts.activeBorder}
activeColor={opts.activeColor}
/>
{fieldState.error?.message ? (
<Text fz={11} c="red.7" mt={4}>
{fieldState.error.message}
</Text>
) : null}
</div>
</ToggleRow>
)}
/>
);
return ( return (
<Box <Box
className="rounded-xl" className="rounded-xl"
@@ -1538,7 +1615,7 @@ function ContainerLineEditor({
<Text fz={14} fw={700} c="#10202F" mb={10}> <Text fz={14} fw={700} c="#10202F" mb={10}>
{size} containers {size} containers
</Text> </Text>
<Group gap={12} grow mb={12} align="flex-start"> <Box maw={220} mb={14}>
<Controller <Controller
name={`containers.${index}.quantity`} name={`containers.${index}.quantity`}
control={form.control} control={form.control}
@@ -1554,67 +1631,61 @@ function ContainerLineEditor({
styles={fieldStyles} styles={fieldStyles}
onChange={(e) => { onChange={(e) => {
field.onChange(e.currentTarget.value); field.onChange(e.currentTarget.value);
syncUnits(Number(e.currentTarget.value || 0)); const qty = Number(e.currentTarget.value || 0);
syncUnits(qty);
clampHandlingCounts(qty);
}} }}
/> />
)} )}
/> />
{isHazardous && ( </Box>
<Controller
name={`containers.${index}.hazardousQuantity`} {/* Cargo handling — only the services this contract was created with are
control={form.control} offered, since the server rejects quantities for the others. Each
render={({ field, fieldState }) => ( switch reveals a bounded picker: tap the containers it applies to. */}
<TextInput {(isHazardous || isReefer || withReturnService) && quantity > 0 && (
{...field} <>
type="number" <StepLabel>Cargo handling</StepLabel>
onKeyDown={blockNegative} <div className="grid gap-3 sm:grid-cols-2" style={{ marginBottom: 14 }}>
label="Hazardous qty" {isHazardous &&
min={0} handlingToggle("hazardousQuantity", {
error={fieldState.error?.message} icon: <Flame size={18} />,
radius={10} iconBg: "#FBEAE7",
styles={fieldStyles} iconColor: "#C0392B",
/> title: "Hazardous",
)} description: "Some of these containers carry hazardous cargo.",
/> pickLabel: "Tap the hazardous containers",
)} activeBg: "#FBEAE7",
{isReefer && ( activeBorder: "#E4A69B",
<Controller activeColor: "#C0392B",
name={`containers.${index}.reeferQuantity`} })}
control={form.control} {isReefer &&
render={({ field, fieldState }) => ( handlingToggle("reeferQuantity", {
<TextInput icon: <Snowflake size={18} />,
{...field} iconBg: "#E9F0F8",
type="number" iconColor: "#2E5B96",
onKeyDown={blockNegative} title: "Refrigerated",
label="Reefer qty" description: "Some of these containers need reefer transport.",
min={0} pickLabel: "Tap the refrigerated containers",
error={fieldState.error?.message} activeBg: "#E9F0F8",
radius={10} activeBorder: "#A9C2E0",
styles={fieldStyles} activeColor: "#2E5B96",
/> })}
)} {withReturnService &&
/> handlingToggle("returnQuantity", {
)} icon: <Repeat size={18} />,
{withReturnService && ( iconBg: "#ECF6F1",
<Controller iconColor: "#0A6F4D",
name={`containers.${index}.returnQuantity`} title: "With return",
control={form.control} description: "Some of these containers come back to EDR empty.",
render={({ field, fieldState }) => ( pickLabel: "Tap the containers EDR returns",
<TextInput activeBg: "#ECF6F1",
{...field} activeBorder: "#A9D6C2",
type="number" activeColor: "#0A6F4D",
onKeyDown={blockNegative} })}
label="With return qty" </div>
description="Containers EDR returns empty" </>
min={0} )}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel> <StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}> <Stack gap={10} mt={8}>

View File

@@ -4,7 +4,7 @@ import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile } from "@/services/files.service";
// Brand palette (mirrors the booking form's shared constants). // Brand palette (mirrors the booking form's shared constants).
export const INK = "#10202F"; export const INK = "#10202F";
@@ -275,11 +275,14 @@ export function ContractDocButton({
return ( return (
<Tooltip label="Contract document" withArrow> <Tooltip label="Contract document" withArrow>
<Box <Box
component="a" component="button"
href={fileViewUrl(file.id)} type="button"
target="_blank" onClick={(e: React.MouseEvent) => {
rel="noreferrer" onClick?.(e);
onClick={onClick} void fetchViewableFile(file.id, file.name).then((f) =>
window.open(f.url, "_blank"),
);
}}
aria-label="Open contract document" aria-label="Open contract document"
style={{ style={{
display: "flex", display: "flex",
@@ -287,6 +290,8 @@ export function ContractDocButton({
justifyContent: "center", justifyContent: "center",
width: 34, width: 34,
height: 34, height: 34,
padding: 0,
cursor: "pointer",
borderRadius: 8, borderRadius: 8,
border: `1px solid ${BORDER}`, border: `1px solid ${BORDER}`,
background: "#FFFFFF", background: "#FFFFFF",

View File

@@ -8,7 +8,7 @@ import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { CompanyDocument } from "@/services/companies.service"; import type { CompanyDocument } from "@/services/companies.service";
import { fileViewUrl } from "@/constants/apiConfig"; import { downloadStoredFile } from "@/services/files.service";
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs"; import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
import { BORDER, GREEN, INK } from "../contract-ui"; import { BORDER, GREEN, INK } from "../contract-ui";
@@ -181,8 +181,9 @@ export function ContractDocsEditor({
</Group> </Group>
)} )}
<Button <Button
component="a" component="button"
href={fileViewUrl(file.id, true)} type="button"
onClick={() => void downloadStoredFile(file.id, file.name)}
variant="default" variant="default"
size="compact-xs" size="compact-xs"
radius="md" radius="md"

View File

@@ -16,7 +16,8 @@ import type {
} from "react-hook-form"; } from "react-hook-form";
// The contract wizard reuses the booking wizard's premium card chrome verbatim // The contract wizard reuses the booking wizard's premium card chrome verbatim
// (OptionCard / StepCard / StepHeader / AlertBox / StepLabel / fieldStyles). // (OptionCard / StepCard / StepHeader / AlertBox / StepLabel / fieldStyles),
// plus the cargo-handling switch + tap-to-count pair used per container line.
export { export {
AlertBox, AlertBox,
fieldStyles, fieldStyles,
@@ -25,6 +26,8 @@ export {
StepCard, StepCard,
StepHeader, StepHeader,
StepLabel, StepLabel,
ToggleRow,
UnitCountToggles,
} from "@/pages/bookings/new-booking-form/shared"; } from "@/pages/bookings/new-booking-form/shared";
import { import {

View File

@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical // Excel import for container shipments: one spreadsheet row per physical
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the // container, mirroring the manual per-unit fields (number, seal, VGM) plus the
// hazardous/reefer flags when the contract allows them. The parser is // hazardous/reefer/return flags when the contract allows them. The parser is
// all-or-nothing — any bad row rejects the file with row-numbered errors so a // all-or-nothing — any bad row rejects the file with row-numbered errors so a
// partial import can never silently drop containers. // partial import can never silently drop containers.
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
allowedSizes: string[]; allowedSizes: string[];
includeHazardous: boolean; includeHazardous: boolean;
includeReefer: boolean; includeReefer: boolean;
/** Contract was created WITH_RETURN — offer the empty-return column. */
includeReturn?: boolean;
} }
export interface ImportedContainerRow { export interface ImportedContainerRow {
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
vgmTons: string; vgmTons: string;
hazardous: boolean; hazardous: boolean;
reefer: boolean; reefer: boolean;
withReturn: boolean;
} }
export interface ContainerExcelResult { export interface ContainerExcelResult {
@@ -36,7 +39,8 @@ type ColumnKey =
| "sealNumber" | "sealNumber"
| "vgmTons" | "vgmTons"
| "hazardous" | "hazardous"
| "reefer"; | "reefer"
| "withReturn";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */ /** Match a header cell to a known column, tolerant of casing/spacing/units. */
function headerKey(raw: string): ColumnKey | null { function headerKey(raw: string): ColumnKey | null {
@@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null {
if (h.includes("vgm") || h.includes("weight")) return "vgmTons"; if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
if (h.includes("hazard")) return "hazardous"; if (h.includes("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer"; if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
if (h.includes("return")) return "withReturn";
// After the more specific matches: "Container Number", "Container No", … // After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber"; if (h.includes("container") || h.includes("number")) return "containerNumber";
return null; return null;
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
vgmTons: vgmRaw, vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")), reefer: opts.includeReefer && parseFlag(cell("reefer")),
withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")),
}); });
} }
@@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"]; const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)"); if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
if (opts.includeReefer) headers.push("Reefer (YES/NO)"); if (opts.includeReefer) headers.push("Reefer (YES/NO)");
if (opts.includeReturn) headers.push("With Return (YES/NO)");
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"]; const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
const sampleRows = sizes.map((size, i) => { const sampleRows = sizes.map((size, i) => {
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
]; ];
if (opts.includeHazardous) row.push("NO"); if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO"); if (opts.includeReefer) row.push("NO");
if (opts.includeReturn) row.push("NO");
return row; return row;
}); });

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import type { Freight } from "@edr/types";
import { computeShipmentTotal } from "./total";
import type { ShipmentFormValues } from "./schema";
// The with_return surcharge is contract-gated and priced per returning
// container; it was previously omitted from the estimate entirely.
const contract = (over: Partial<Freight.IContract> = {}) =>
({
freightType: "CONTAINER",
paymentCurrency: "ETB",
isHazardous: true,
isReefer: true,
equipmentReturn: "WITH_RETURN",
pricingBreakdown: {
currency: "ETB",
lineItems: [
{
label: "40ft container",
unit: "per_container",
unitPrice: 100,
containerSize: "40ft",
},
{ label: "Hazardous", unit: "per_container", unitPrice: 10, conditionalOn: "is_hazardous" },
{ label: "Reefer", unit: "per_container", unitPrice: 20, conditionalOn: "is_reefer" },
{ label: "Empty return", unit: "per_container", unitPrice: 30, conditionalOn: "with_return" },
],
},
...over,
}) as unknown as Freight.IContract;
const values = (over: Record<string, unknown> = {}) =>
({
containers: [
{
containerSize: "40ft",
quantity: "10",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [],
},
],
cargoWeightTons: "",
itemCount: "",
bulkHazardousQuantity: "0",
bulkReeferQuantity: "0",
...over,
}) as unknown as ShipmentFormValues;
const line = (t: ReturnType<typeof computeShipmentTotal>, label: string) =>
t.lines.find((l) => l.label === label);
describe("computeShipmentTotal — with_return surcharge", () => {
it("charges the return surcharge per returning container", () => {
const t = computeShipmentTotal(
contract(),
values({
containers: [
{
containerSize: "40ft",
quantity: "10",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "4",
units: [],
},
],
}),
);
expect(line(t, "Empty return")).toMatchObject({ quantity: 4, amount: 120 });
expect(t.total).toBe(100 * 10 + 30 * 4);
});
it("omits it when no container returns", () => {
const t = computeShipmentTotal(contract(), values());
expect(line(t, "Empty return")).toBeUndefined();
expect(t.total).toBe(1000);
});
it("omits it when the contract is not WITH_RETURN", () => {
const t = computeShipmentTotal(
contract({ equipmentReturn: "WITHOUT_RETURN" } as Partial<Freight.IContract>),
values({
containers: [
{
containerSize: "40ft",
quantity: "10",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "4",
units: [],
},
],
}),
);
expect(line(t, "Empty return")).toBeUndefined();
expect(t.total).toBe(1000);
});
it("sums return counts across container lines and stacks with haz/reefer", () => {
const t = computeShipmentTotal(
contract(),
values({
containers: [
{
containerSize: "40ft",
quantity: "10",
hazardousQuantity: "2",
reeferQuantity: "3",
returnQuantity: "4",
units: [],
},
{
containerSize: "40ft",
quantity: "5",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "1",
units: [],
},
],
}),
);
expect(line(t, "Empty return")).toMatchObject({ quantity: 5, amount: 150 });
expect(line(t, "Hazardous")).toMatchObject({ quantity: 2, amount: 20 });
expect(line(t, "Reefer")).toMatchObject({ quantity: 3, amount: 60 });
expect(t.total).toBe(100 * 10 + 100 * 5 + 20 + 60 + 150);
});
it("does not double-count the base rate against the return surcharge", () => {
const t = computeShipmentTotal(
contract(),
values({
containers: [
{
containerSize: "40ft",
quantity: "2",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "2",
units: [],
},
],
}),
);
expect(line(t, "40ft container")).toMatchObject({ quantity: 2, amount: 200 });
expect(t.total).toBe(200 + 60);
});
});

View File

@@ -38,6 +38,7 @@ export function computeShipmentTotal(
if (isContainer) { if (isContainer) {
let hazardTotalQty = 0; let hazardTotalQty = 0;
let reeferTotalQty = 0; let reeferTotalQty = 0;
let returnTotalQty = 0;
for (const line of values.containers) { for (const line of values.containers) {
const qty = Number(line.quantity || 0); const qty = Number(line.quantity || 0);
@@ -60,6 +61,7 @@ export function computeShipmentTotal(
} }
hazardTotalQty += Number(line.hazardousQuantity || 0); hazardTotalQty += Number(line.hazardousQuantity || 0);
reeferTotalQty += Number(line.reeferQuantity || 0); reeferTotalQty += Number(line.reeferQuantity || 0);
returnTotalQty += Number(line.returnQuantity || 0);
} }
if (contract.isHazardous && hazardTotalQty > 0) { if (contract.isHazardous && hazardTotalQty > 0) {
@@ -86,6 +88,21 @@ export function computeShipmentTotal(
}); });
} }
} }
// Empty-container return is a container-only surcharge, priced per returning
// container rather than per line (contract-pricing.service emits the
// `with_return` rate only for WITH_RETURN contracts).
if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) {
const wr = rateFor((i) => i.conditionalOn === "with_return");
if (wr) {
lines.push({
label: wr.label,
unitPrice: wr.unitPrice,
unit: wr.unit,
quantity: returnTotalQty,
amount: wr.unitPrice * returnTotalQty,
});
}
}
} else { } else {
const qty = Number(values.cargoWeightTons || values.itemCount || 0); const qty = Number(values.cargoWeightTons || values.itemCount || 0);
const rate = const rate =

View File

@@ -1,4 +1,4 @@
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { import {
companiesService, companiesService,
@@ -113,9 +113,11 @@ export default function TabDocuments({
{ name: string; url: string; size?: number; mimeType?: string | null }[] { name: string; url: string; size?: number; mimeType?: string | null }[]
> = {}; > = {};
for (const doc of docsQuery.data ?? []) { for (const doc of docsQuery.data ?? []) {
// GET /api/files/:id is JWT-guarded, so no raw URL is stored here — the
// file id rides in `url` and onViewFile resolves it to a blob URL.
(map[doc.code] ??= []).push({ (map[doc.code] ??= []).push({
name: doc.name, name: doc.name,
url: fileViewUrl(doc.id), url: doc.id,
size: doc.size, size: doc.size,
mimeType: doc.mimeType, mimeType: doc.mimeType,
}); });
@@ -199,7 +201,7 @@ export default function TabDocuments({
errors={fieldErrors} errors={fieldErrors}
uploadedKeys={uploadedKeys} uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey} existingFiles={existingFilesByKey}
onViewFile={view} onViewFile={(f) => void fetchViewableFile(f.url, f.name).then(view)}
/> />
)} )}
@@ -439,11 +441,7 @@ function ProfileLicenseRow({
size="sm" size="sm"
fw={600} fw={600}
onClick={() => onClick={() =>
onViewFile({ void fetchViewableFile(f.id, f.name).then(onViewFile)
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
} }
style={{ style={{
textAlign: "left", textAlign: "left",

View File

@@ -33,7 +33,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useFileViewer, type ViewableFile } from "@edr/ui-common"; import { useFileViewer, type ViewableFile } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig"; import { fetchViewableFile } from "@/services/files.service";
import { import {
companiesService, companiesService,
type LicenseFile, type LicenseFile,
@@ -528,11 +528,7 @@ function LetterRow({
size="sm" size="sm"
fw={600} fw={600}
onClick={() => onClick={() =>
onViewFile({ void fetchViewableFile(file.id, file.name).then(onViewFile)
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
} }
style={{ style={{
textAlign: "left", textAlign: "left",

View File

@@ -0,0 +1,43 @@
import { client } from "@/utils/api";
/**
* GET /api/files/:id is authenticated (global JwtGuard) — raw browser loads
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Always fetch the
* bytes through the axios client; hand out blob object URLs for previews.
*/
export const filesService = {
/** Stream a stored file by id (backend route: GET /api/files/:id). */
download: async (id: string): Promise<Blob> => {
const response = await client.get(`/api/files/${id}`, {
responseType: "blob",
});
return response.data as Blob;
},
};
/** Fetch a stored file and return a viewer-ready blob object URL. */
export async function fetchViewableFile(
id: string,
name: string,
): Promise<{ name: string; url: string; mimeType?: string }> {
const blob = await filesService.download(id);
return {
name,
url: URL.createObjectURL(blob),
mimeType: blob.type || undefined,
};
}
/** Download a stored file and trigger a browser save with the given name. */
export async function downloadStoredFile(
id: string,
filename: string,
): Promise<void> {
const blob = await filesService.download(id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}