fix issue

This commit is contained in:
Marshal
2026-07-17 11:38:54 +00:00
parent 801872c106
commit 221c49fcda
36 changed files with 726 additions and 355 deletions

View File

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

View File

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

View File

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

View File

@@ -429,6 +429,7 @@ export default function GlCreateBookingForm() {
quantity: Number(l.quantity || 0),
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
returnQuantity: Number(l.returnQuantity || 0),
})),
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
@@ -532,6 +533,7 @@ export default function GlCreateBookingForm() {
allowedSizes: containerSizes,
includeHazardous: contract?.isHazardous ?? false,
includeReefer: contract?.isReefer ?? false,
includeReturn: contractWithReturn,
};
const handleImportFile = async (file: File | null) => {
@@ -557,8 +559,7 @@ export default function GlCreateBookingForm() {
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity:
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
returnQuantity: String(imported.filter((r) => r.withReturn).length),
units: imported.map((r) => ({
containerNumber: r.containerNumber,
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 { isViewable } from "@edr/ui-common";
import { fileViewUrl } from "@/constants/apiConfig";
import { fetchViewableFile } from "@/services/files.service";
export interface PhasedUploadedFileRowProps {
label: string;
@@ -20,8 +20,7 @@ export function PhasedUploadedFileRow({
onDownload,
compact = false,
}: PhasedUploadedFileRowProps) {
const viewUrl = fileViewUrl(file.id);
const canPreview = isViewable({ name: file.name, url: viewUrl });
const canPreview = isViewable({ name: file.name, url: "" });
return (
<Paper
@@ -61,7 +60,9 @@ export function PhasedUploadedFileRow({
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() => onView({ name: file.name, url: viewUrl })}
onClick={() =>
void fetchViewableFile(file.id, file.name).then(onView)
}
>
View
</Button>

View File

@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical
// 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
// partial import can never silently drop containers.
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
allowedSizes: string[];
includeHazardous: boolean;
includeReefer: boolean;
/** Contract was created WITH_RETURN — offer the empty-return column. */
includeReturn?: boolean;
}
export interface ImportedContainerRow {
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
vgmTons: string;
hazardous: boolean;
reefer: boolean;
withReturn: boolean;
}
export interface ContainerExcelResult {
@@ -36,7 +39,8 @@ type ColumnKey =
| "sealNumber"
| "vgmTons"
| "hazardous"
| "reefer";
| "reefer"
| "withReturn";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
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("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
if (h.includes("return")) return "withReturn";
// After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
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)"];
if (opts.includeHazardous) headers.push("Hazardous (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 sampleRows = sizes.map((size, i) => {
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
];
if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO");
if (opts.includeReturn) row.push("NO");
return row;
});

View File

@@ -17,12 +17,14 @@ export interface GlShipmentTotal {
/** A normalized view of the form quantities, freight-shape agnostic. */
export interface GlShipmentQuantities {
isContainer: boolean;
/** Container lines: size + total qty + hazardous/reefer qty. */
/** Container lines: size + total qty + hazardous/reefer/return qty. */
containers: Array<{
containerSize: string;
quantity: number;
hazardousQuantity: number;
reeferQuantity: number;
/** Containers EDR takes back empty — only on WITH_RETURN contracts. */
returnQuantity: number;
}>;
/** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number;
@@ -53,6 +55,7 @@ export function computeGlShipmentTotal(
if (q.isContainer) {
let hazardTotalQty = 0;
let reeferTotalQty = 0;
let returnTotalQty = 0;
for (const line of q.containers) {
const qty = line.quantity;
@@ -75,6 +78,7 @@ export function computeGlShipmentTotal(
}
hazardTotalQty += line.hazardousQuantity;
reeferTotalQty += line.reeferQuantity;
returnTotalQty += line.returnQuantity;
}
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 {
const qty = q.bulkQuantity;
const rate =

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,10 @@ import { driversService } from "@/services/drivers.service";
import { vehiclesService } from "@/services/vehicles.service";
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { useToast } from "@/hooks/use-toast";
const fmtDate = (iso?: string | null) => {
@@ -155,10 +159,6 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
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 (
<Card withBorder padding="lg" radius="md">
<Stack gap="md" mb="lg">
@@ -211,10 +211,22 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
<Table.Td>
<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} />
</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} />
</ActionIcon>
<ActionIcon

View File

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

View File

@@ -24,3 +24,20 @@ export async function downloadBookingFile(
a.click();
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 { 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";
type ReviewStatus = "PENDING" | "APPROVED" | "QUERIED" | null;
@@ -97,11 +97,8 @@ export function ClearanceDocumentUploadCard({
const queried = reviewStatus === "QUERIED";
const approved = reviewStatus === "APPROVED";
const showUpload = canUpload && !approved && onStageFile;
const viewUrl = uploadedFile ? fileViewUrl(uploadedFile.id) : null;
const canPreviewUploaded =
uploadedFile &&
viewUrl &&
isViewable({ name: uploadedFile.name, url: viewUrl });
uploadedFile && isViewable({ name: uploadedFile.name, url: "" });
return (
<Paper
@@ -175,7 +172,9 @@ export function ClearanceDocumentUploadCard({
color="edr-green"
leftSection={<Eye size={14} />}
onClick={() =>
onPreview({ name: uploadedFile.name, url: viewUrl! })
void fetchViewableFile(uploadedFile.id, uploadedFile.name).then(
onPreview,
)
}
>
View
@@ -184,9 +183,11 @@ export function ClearanceDocumentUploadCard({
<Button
size="compact-sm"
variant="default"
component="a"
href={fileViewUrl(uploadedFile.id, true)}
download={uploadedFile.name}
component="button"
type="button"
onClick={() =>
void downloadStoredFile(uploadedFile.id, uploadedFile.name)
}
leftSection={<Download size={14} />}
>
Download

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
StepHeader,
StepLabel,
ToggleRow,
UnitCountToggles,
} from "./shared";
type BookingForm = UseFormReturn<
@@ -35,70 +36,6 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
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({
form,
referenceData,

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ import type { Freight } from "@edr/types";
import { clearanceWorkflowFileLabel } from "@edr/types";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import toast from "react-hot-toast";
import { labelForDocCode } from "@/pages/bookings/resubmit";
@@ -450,11 +450,9 @@ export default function ContractDetailPage() {
size="md"
leftSection={<FileText size={16} />}
onClick={() =>
view({
name: contractPdf.name,
url: fileViewUrl(contractPdf.id),
mimeType: contractPdf.mimeType,
})
void fetchViewableFile(contractPdf.id, contractPdf.name).then(
view,
)
}
>
View contract
@@ -1184,10 +1182,7 @@ export default function ContractDetailPage() {
onView={view}
onDownload={async (f) => {
try {
const a = document.createElement("a");
a.href = fileViewUrl(f.id, true);
a.download = f.name;
a.click();
await downloadStoredFile(f.id, f.name);
} catch {
toast.error("Could not download file.");
}
@@ -1749,7 +1744,7 @@ function DocFileRow({
const { ext, color } = fileTypeChip(file.name, file.mimeType);
const viewable = isViewable({
name: file.name,
url: fileViewUrl(file.id),
url: "",
mimeType: file.mimeType,
});
return (
@@ -1804,19 +1799,16 @@ function DocFileRow({
radius="md"
leftSection={<Eye size={14} />}
onClick={() =>
onView({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
void fetchViewableFile(file.id, file.name).then(onView)
}
>
View
</Button>
)}
<Button
component="a"
href={fileViewUrl(file.id, true)}
component="button"
type="button"
onClick={() => void downloadStoredFile(file.id, file.name)}
variant="default"
size="xs"
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 { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -31,10 +38,12 @@ import {
ChevronLeft,
FileDown,
FileUp,
Flame,
MapPin,
Package,
Receipt,
Repeat,
Snowflake,
X,
} from "lucide-react";
@@ -50,6 +59,8 @@ import {
StepCard,
StepHeader,
StepLabel,
ToggleRow,
UnitCountToggles,
fieldStyles,
} from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
@@ -1140,6 +1151,7 @@ function CargoStep({
allowedSizes: sizes,
includeHazardous: contract.isHazardous ?? false,
includeReefer: contract.isReefer ?? false,
includeReturn: contract.equipmentReturn === "WITH_RETURN",
};
const handleImportFile = async (file: File | null) => {
@@ -1174,8 +1186,7 @@ function CargoStep({
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity:
current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
returnQuantity: String(imported.filter((r) => r.withReturn).length),
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
@@ -1530,6 +1541,72 @@ function ContainerLineEditor({
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 (
<Box
className="rounded-xl"
@@ -1538,7 +1615,7 @@ function ContainerLineEditor({
<Text fz={14} fw={700} c="#10202F" mb={10}>
{size} containers
</Text>
<Group gap={12} grow mb={12} align="flex-start">
<Box maw={220} mb={14}>
<Controller
name={`containers.${index}.quantity`}
control={form.control}
@@ -1554,67 +1631,61 @@ function ContainerLineEditor({
styles={fieldStyles}
onChange={(e) => {
field.onChange(e.currentTarget.value);
syncUnits(Number(e.currentTarget.value || 0));
const qty = Number(e.currentTarget.value || 0);
syncUnits(qty);
clampHandlingCounts(qty);
}}
/>
)}
/>
{isHazardous && (
<Controller
name={`containers.${index}.hazardousQuantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
{isReefer && (
<Controller
name={`containers.${index}.reeferQuantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
{withReturnService && (
<Controller
name={`containers.${index}.returnQuantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</Group>
</Box>
{/* Cargo handling — only the services this contract was created with are
offered, since the server rejects quantities for the others. Each
switch reveals a bounded picker: tap the containers it applies to. */}
{(isHazardous || isReefer || withReturnService) && quantity > 0 && (
<>
<StepLabel>Cargo handling</StepLabel>
<div className="grid gap-3 sm:grid-cols-2" style={{ marginBottom: 14 }}>
{isHazardous &&
handlingToggle("hazardousQuantity", {
icon: <Flame size={18} />,
iconBg: "#FBEAE7",
iconColor: "#C0392B",
title: "Hazardous",
description: "Some of these containers carry hazardous cargo.",
pickLabel: "Tap the hazardous containers",
activeBg: "#FBEAE7",
activeBorder: "#E4A69B",
activeColor: "#C0392B",
})}
{isReefer &&
handlingToggle("reeferQuantity", {
icon: <Snowflake size={18} />,
iconBg: "#E9F0F8",
iconColor: "#2E5B96",
title: "Refrigerated",
description: "Some of these containers need reefer transport.",
pickLabel: "Tap the refrigerated containers",
activeBg: "#E9F0F8",
activeBorder: "#A9C2E0",
activeColor: "#2E5B96",
})}
{withReturnService &&
handlingToggle("returnQuantity", {
icon: <Repeat size={18} />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
title: "With return",
description: "Some of these containers come back to EDR empty.",
pickLabel: "Tap the containers EDR returns",
activeBg: "#ECF6F1",
activeBorder: "#A9D6C2",
activeColor: "#0A6F4D",
})}
</div>
</>
)}
<StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}>

View File

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

View File

@@ -8,7 +8,7 @@ import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
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 { BORDER, GREEN, INK } from "../contract-ui";
@@ -181,8 +181,9 @@ export function ContractDocsEditor({
</Group>
)}
<Button
component="a"
href={fileViewUrl(file.id, true)}
component="button"
type="button"
onClick={() => void downloadStoredFile(file.id, file.name)}
variant="default"
size="compact-xs"
radius="md"

View File

@@ -16,7 +16,8 @@ import type {
} from "react-hook-form";
// 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 {
AlertBox,
fieldStyles,
@@ -25,6 +26,8 @@ export {
StepCard,
StepHeader,
StepLabel,
ToggleRow,
UnitCountToggles,
} from "@/pages/bookings/new-booking-form/shared";
import {

View File

@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical
// 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
// partial import can never silently drop containers.
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
allowedSizes: string[];
includeHazardous: boolean;
includeReefer: boolean;
/** Contract was created WITH_RETURN — offer the empty-return column. */
includeReturn?: boolean;
}
export interface ImportedContainerRow {
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
vgmTons: string;
hazardous: boolean;
reefer: boolean;
withReturn: boolean;
}
export interface ContainerExcelResult {
@@ -36,7 +39,8 @@ type ColumnKey =
| "sealNumber"
| "vgmTons"
| "hazardous"
| "reefer";
| "reefer"
| "withReturn";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
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("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
if (h.includes("return")) return "withReturn";
// After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
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)"];
if (opts.includeHazardous) headers.push("Hazardous (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 sampleRows = sizes.map((size, i) => {
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
];
if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO");
if (opts.includeReturn) row.push("NO");
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) {
let hazardTotalQty = 0;
let reeferTotalQty = 0;
let returnTotalQty = 0;
for (const line of values.containers) {
const qty = Number(line.quantity || 0);
@@ -60,6 +61,7 @@ export function computeShipmentTotal(
}
hazardTotalQty += Number(line.hazardousQuantity || 0);
reeferTotalQty += Number(line.reeferQuantity || 0);
returnTotalQty += Number(line.returnQuantity || 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 {
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
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 {
companiesService,
@@ -113,9 +113,11 @@ export default function TabDocuments({
{ name: string; url: string; size?: number; mimeType?: string | null }[]
> = {};
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({
name: doc.name,
url: fileViewUrl(doc.id),
url: doc.id,
size: doc.size,
mimeType: doc.mimeType,
});
@@ -199,7 +201,7 @@ export default function TabDocuments({
errors={fieldErrors}
uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey}
onViewFile={view}
onViewFile={(f) => void fetchViewableFile(f.url, f.name).then(view)}
/>
)}
@@ -439,11 +441,7 @@ function ProfileLicenseRow({
size="sm"
fw={600}
onClick={() =>
onViewFile({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
void fetchViewableFile(f.id, f.name).then(onViewFile)
}
style={{
textAlign: "left",

View File

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