mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
43
apps/edr-freight-web/portal/src/services/files.service.ts
Normal file
43
apps/edr-freight-web/portal/src/services/files.service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user