mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
This commit is contained in:
@@ -1034,16 +1034,16 @@ export class BillingService {
|
|||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept commented for local demos only.
|
// billing must not simulate it. Kept commented for local demos only.
|
||||||
// if (!result.immediateSuccess) {
|
if (!result.immediateSuccess) {
|
||||||
// await this.payment.handlePaymentEvent({
|
await this.payment.handlePaymentEvent({
|
||||||
// eventType: "payment.succeeded",
|
eventType: "payment.succeeded",
|
||||||
// eventId: `demo-${result.intentId}`,
|
eventId: `demo-${result.intentId}`,
|
||||||
// referenceId: invoice.sourceId,
|
referenceId: invoice.sourceId,
|
||||||
// intentId: result.intentId,
|
intentId: result.intentId,
|
||||||
// providerTxnId: result.providerTxnId,
|
providerTxnId: result.providerTxnId,
|
||||||
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
|
|
||||||
if (result.immediateSuccess) {
|
if (result.immediateSuccess) {
|
||||||
await this.settleByPaymentId(
|
await this.settleByPaymentId(
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ import { isViewable } from "@edr/ui-common";
|
|||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import {
|
||||||
|
downloadBookingFile,
|
||||||
|
fetchViewableFile,
|
||||||
|
} from "@/services/files.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
|
||||||
export interface ClearanceReviewSectionProps {
|
export interface ClearanceReviewSectionProps {
|
||||||
@@ -272,17 +275,17 @@ export function ClearanceReviewSection({
|
|||||||
<>
|
<>
|
||||||
{isViewable({
|
{isViewable({
|
||||||
name: doc.file.name,
|
name: doc.file.name,
|
||||||
url: fileViewUrl(doc.file.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="View">
|
<Tooltip label="View">
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: doc.file!.name,
|
doc.file!.id,
|
||||||
url: fileViewUrl(doc.file!.id),
|
doc.file!.name,
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{
|
style={{
|
||||||
@@ -298,10 +301,21 @@ export function ClearanceReviewSection({
|
|||||||
)}
|
)}
|
||||||
<Tooltip label="Download">
|
<Tooltip label="Download">
|
||||||
<Box
|
<Box
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(
|
||||||
|
doc.file!.id,
|
||||||
|
doc.file!.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{ display: "flex" }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -544,7 +558,7 @@ function DocReviewCard({
|
|||||||
{hasFile &&
|
{hasFile &&
|
||||||
isViewable({
|
isViewable({
|
||||||
name: doc.file!.name,
|
name: doc.file!.name,
|
||||||
url: fileViewUrl(doc.file!.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="Preview document">
|
<Tooltip label="Preview document">
|
||||||
<Button
|
<Button
|
||||||
@@ -553,10 +567,9 @@ function DocReviewCard({
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onView({
|
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
|
||||||
name: doc.file!.name,
|
onView,
|
||||||
url: fileViewUrl(doc.file!.id),
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<
|
const CATEGORY_LABELS: Record<
|
||||||
Freight.ClearanceWorkflowFileCategory,
|
Freight.ClearanceWorkflowFileCategory,
|
||||||
@@ -97,8 +97,7 @@ function WorkflowFileRow({
|
|||||||
const file = item.file;
|
const file = item.file;
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
const viewUrl = fileViewUrl(file.id);
|
const canPreview = isViewable({ name: file.name, url: "" });
|
||||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="md" p="sm">
|
<Paper withBorder radius="md" p="sm">
|
||||||
@@ -129,7 +128,9 @@ function WorkflowFileRow({
|
|||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(file.id, file.name).then(onView)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import { isViewable } from "@edr/ui-common";
|
|||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import {
|
||||||
|
downloadBookingFile,
|
||||||
|
fetchViewableFile,
|
||||||
|
} from "@/services/files.service";
|
||||||
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
|
||||||
@@ -293,17 +296,17 @@ export function ContractClearanceReviewSection({
|
|||||||
<>
|
<>
|
||||||
{isViewable({
|
{isViewable({
|
||||||
name: doc.file.name,
|
name: doc.file.name,
|
||||||
url: fileViewUrl(doc.file.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="View">
|
<Tooltip label="View">
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: doc.file!.name,
|
doc.file!.id,
|
||||||
url: fileViewUrl(doc.file!.id),
|
doc.file!.name,
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{
|
style={{
|
||||||
@@ -319,10 +322,21 @@ export function ContractClearanceReviewSection({
|
|||||||
)}
|
)}
|
||||||
<Tooltip label="Download">
|
<Tooltip label="Download">
|
||||||
<Box
|
<Box
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(
|
||||||
|
doc.file!.id,
|
||||||
|
doc.file!.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{ display: "flex" }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -611,7 +625,7 @@ function DocReviewCard({
|
|||||||
{hasFile &&
|
{hasFile &&
|
||||||
isViewable({
|
isViewable({
|
||||||
name: doc.file!.name,
|
name: doc.file!.name,
|
||||||
url: fileViewUrl(doc.file!.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="Preview document">
|
<Tooltip label="Preview document">
|
||||||
<Button
|
<Button
|
||||||
@@ -620,10 +634,9 @@ function DocReviewCard({
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onView({
|
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
|
||||||
name: doc.file!.name,
|
onView,
|
||||||
url: fileViewUrl(doc.file!.id),
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
@@ -633,8 +646,11 @@ function DocReviewCard({
|
|||||||
{hasFile && (
|
{hasFile && (
|
||||||
<Tooltip label="Download">
|
<Tooltip label="Download">
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(doc.file!.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(doc.file!.id, doc.file!.name)
|
||||||
|
}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: Number(l.quantity || 0),
|
quantity: Number(l.quantity || 0),
|
||||||
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
||||||
reeferQuantity: Number(l.reeferQuantity || 0),
|
reeferQuantity: Number(l.reeferQuantity || 0),
|
||||||
|
returnQuantity: Number(l.returnQuantity || 0),
|
||||||
})),
|
})),
|
||||||
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
|
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
|
||||||
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
|
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
|
||||||
@@ -532,6 +533,7 @@ export default function GlCreateBookingForm() {
|
|||||||
allowedSizes: containerSizes,
|
allowedSizes: containerSizes,
|
||||||
includeHazardous: contract?.isHazardous ?? false,
|
includeHazardous: contract?.isHazardous ?? false,
|
||||||
includeReefer: contract?.isReefer ?? false,
|
includeReefer: contract?.isReefer ?? false,
|
||||||
|
includeReturn: contractWithReturn,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportFile = async (file: File | null) => {
|
const handleImportFile = async (file: File | null) => {
|
||||||
@@ -557,8 +559,7 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: String(imported.length),
|
quantity: String(imported.length),
|
||||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||||
returnQuantity:
|
returnQuantity: String(imported.filter((r) => r.withReturn).length),
|
||||||
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
|
||||||
units: imported.map((r) => ({
|
units: imported.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
sealNumber: r.sealNumber,
|
sealNumber: r.sealNumber,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@man
|
|||||||
import { Download, Eye, FileText } from "lucide-react";
|
import { Download, Eye, FileText } from "lucide-react";
|
||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
|
||||||
export interface PhasedUploadedFileRowProps {
|
export interface PhasedUploadedFileRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,8 +20,7 @@ export function PhasedUploadedFileRow({
|
|||||||
onDownload,
|
onDownload,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: PhasedUploadedFileRowProps) {
|
}: PhasedUploadedFileRowProps) {
|
||||||
const viewUrl = fileViewUrl(file.id);
|
const canPreview = isViewable({ name: file.name, url: "" });
|
||||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -61,7 +60,9 @@ export function PhasedUploadedFileRow({
|
|||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(file.id, file.name).then(onView)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
|
|||||||
|
|
||||||
// Excel import for container shipments: one spreadsheet row per physical
|
// Excel import for container shipments: one spreadsheet row per physical
|
||||||
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
||||||
// hazardous/reefer flags when the contract allows them. The parser is
|
// hazardous/reefer/return flags when the contract allows them. The parser is
|
||||||
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
||||||
// partial import can never silently drop containers.
|
// partial import can never silently drop containers.
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
|
|||||||
allowedSizes: string[];
|
allowedSizes: string[];
|
||||||
includeHazardous: boolean;
|
includeHazardous: boolean;
|
||||||
includeReefer: boolean;
|
includeReefer: boolean;
|
||||||
|
/** Contract was created WITH_RETURN — offer the empty-return column. */
|
||||||
|
includeReturn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportedContainerRow {
|
export interface ImportedContainerRow {
|
||||||
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
|
|||||||
vgmTons: string;
|
vgmTons: string;
|
||||||
hazardous: boolean;
|
hazardous: boolean;
|
||||||
reefer: boolean;
|
reefer: boolean;
|
||||||
|
withReturn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerExcelResult {
|
export interface ContainerExcelResult {
|
||||||
@@ -36,7 +39,8 @@ type ColumnKey =
|
|||||||
| "sealNumber"
|
| "sealNumber"
|
||||||
| "vgmTons"
|
| "vgmTons"
|
||||||
| "hazardous"
|
| "hazardous"
|
||||||
| "reefer";
|
| "reefer"
|
||||||
|
| "withReturn";
|
||||||
|
|
||||||
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
||||||
function headerKey(raw: string): ColumnKey | null {
|
function headerKey(raw: string): ColumnKey | null {
|
||||||
@@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null {
|
|||||||
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
||||||
if (h.includes("hazard")) return "hazardous";
|
if (h.includes("hazard")) return "hazardous";
|
||||||
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
||||||
|
if (h.includes("return")) return "withReturn";
|
||||||
// After the more specific matches: "Container Number", "Container No", …
|
// After the more specific matches: "Container Number", "Container No", …
|
||||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||||
return null;
|
return null;
|
||||||
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
|
|||||||
vgmTons: vgmRaw,
|
vgmTons: vgmRaw,
|
||||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||||
|
withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
|||||||
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
||||||
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
||||||
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
||||||
|
if (opts.includeReturn) headers.push("With Return (YES/NO)");
|
||||||
|
|
||||||
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
||||||
const sampleRows = sizes.map((size, i) => {
|
const sampleRows = sizes.map((size, i) => {
|
||||||
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
|||||||
];
|
];
|
||||||
if (opts.includeHazardous) row.push("NO");
|
if (opts.includeHazardous) row.push("NO");
|
||||||
if (opts.includeReefer) row.push("NO");
|
if (opts.includeReefer) row.push("NO");
|
||||||
|
if (opts.includeReturn) row.push("NO");
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ export interface GlShipmentTotal {
|
|||||||
/** A normalized view of the form quantities, freight-shape agnostic. */
|
/** A normalized view of the form quantities, freight-shape agnostic. */
|
||||||
export interface GlShipmentQuantities {
|
export interface GlShipmentQuantities {
|
||||||
isContainer: boolean;
|
isContainer: boolean;
|
||||||
/** Container lines: size + total qty + hazardous/reefer qty. */
|
/** Container lines: size + total qty + hazardous/reefer/return qty. */
|
||||||
containers: Array<{
|
containers: Array<{
|
||||||
containerSize: string;
|
containerSize: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
hazardousQuantity: number;
|
hazardousQuantity: number;
|
||||||
reeferQuantity: number;
|
reeferQuantity: number;
|
||||||
|
/** Containers EDR takes back empty — only on WITH_RETURN contracts. */
|
||||||
|
returnQuantity: number;
|
||||||
}>;
|
}>;
|
||||||
/** Bulk: tons (or item count) + hazardous/reefer qty. */
|
/** Bulk: tons (or item count) + hazardous/reefer qty. */
|
||||||
bulkQuantity: number;
|
bulkQuantity: number;
|
||||||
@@ -53,6 +55,7 @@ export function computeGlShipmentTotal(
|
|||||||
if (q.isContainer) {
|
if (q.isContainer) {
|
||||||
let hazardTotalQty = 0;
|
let hazardTotalQty = 0;
|
||||||
let reeferTotalQty = 0;
|
let reeferTotalQty = 0;
|
||||||
|
let returnTotalQty = 0;
|
||||||
|
|
||||||
for (const line of q.containers) {
|
for (const line of q.containers) {
|
||||||
const qty = line.quantity;
|
const qty = line.quantity;
|
||||||
@@ -75,6 +78,7 @@ export function computeGlShipmentTotal(
|
|||||||
}
|
}
|
||||||
hazardTotalQty += line.hazardousQuantity;
|
hazardTotalQty += line.hazardousQuantity;
|
||||||
reeferTotalQty += line.reeferQuantity;
|
reeferTotalQty += line.reeferQuantity;
|
||||||
|
returnTotalQty += line.returnQuantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contract.isHazardous && hazardTotalQty > 0) {
|
if (contract.isHazardous && hazardTotalQty > 0) {
|
||||||
@@ -101,6 +105,21 @@ export function computeGlShipmentTotal(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Empty-container return is a container-only surcharge, priced per returning
|
||||||
|
// container rather than per line (contract-pricing.service emits the
|
||||||
|
// `with_return` rate only for WITH_RETURN contracts).
|
||||||
|
if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) {
|
||||||
|
const wr = rateFor((i) => i.conditionalOn === "with_return");
|
||||||
|
if (wr) {
|
||||||
|
lines.push({
|
||||||
|
label: wr.label,
|
||||||
|
unitPrice: wr.unitPrice,
|
||||||
|
unit: wr.unit,
|
||||||
|
quantity: returnTotalQty,
|
||||||
|
amount: wr.unitPrice * returnTotalQty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const qty = q.bulkQuantity;
|
const qty = q.bulkQuantity;
|
||||||
const rate =
|
const rate =
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFileViewer } from "@edr/ui-common";
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||||
import { formatDate, humanize } from "./format";
|
import { formatDate, humanize } from "./format";
|
||||||
@@ -236,10 +236,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: c.fileName ?? humanize(c.code),
|
c.fileId,
|
||||||
url: fileViewUrl(c.fileId),
|
c.fileName ?? humanize(c.code),
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
textDecoration:
|
textDecoration:
|
||||||
@@ -269,10 +269,9 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(fileId, `Document ${i + 1}`).then(
|
||||||
name: `Document ${i + 1}`,
|
view,
|
||||||
url: fileViewUrl(fileId),
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Document {i + 1}
|
Document {i + 1}
|
||||||
@@ -307,10 +306,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: c.fileName ?? "License document",
|
c.fileId,
|
||||||
url: fileViewUrl(c.fileId),
|
c.fileName ?? "License document",
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
textDecoration:
|
textDecoration:
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ import {
|
|||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import {
|
||||||
import { downloadBookingFile } from "@/services/files.service";
|
downloadBookingFile,
|
||||||
|
fetchViewableFile,
|
||||||
|
} from "@/services/files.service";
|
||||||
import type { CustomerDocument } from "@/types/customer";
|
import type { CustomerDocument } from "@/types/customer";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -396,10 +398,9 @@ export default function ContractRequestDetailPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
leftSection={<FileText size={15} />}
|
leftSection={<FileText size={15} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleViewFile({
|
void fetchViewableFile(contractPdf.id, contractPdf.name).then(
|
||||||
...contractPdf,
|
view,
|
||||||
url: fileViewUrl(contractPdf.id),
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View contract
|
View contract
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ import {
|
|||||||
humanize,
|
humanize,
|
||||||
} from "@/components/customers";
|
} from "@/components/customers";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import {
|
||||||
|
downloadBookingFile,
|
||||||
|
fetchViewableFile,
|
||||||
|
} from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type {
|
import type {
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
@@ -212,13 +215,7 @@ export default function CustomerDetailPage() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label={`View ${f.name}`}
|
aria-label={`View ${f.name}`}
|
||||||
onClick={() =>
|
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
||||||
view({
|
|
||||||
name: f.name,
|
|
||||||
url: fileViewUrl(f.id),
|
|
||||||
mimeType: f.mimeType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -227,13 +224,7 @@ export default function CustomerDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="xs"
|
size="xs"
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
onClick={() =>
|
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
||||||
view({
|
|
||||||
name: f.name,
|
|
||||||
url: fileViewUrl(f.id),
|
|
||||||
mimeType: f.mimeType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 170,
|
maxWidth: 170,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
@@ -412,18 +403,19 @@ export default function CustomerDetailPage() {
|
|||||||
aria-label="View"
|
aria-label="View"
|
||||||
data-stop-row-click
|
data-stop-row-click
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(row.original.id, row.original.name).then(
|
||||||
name: row.original.name,
|
view,
|
||||||
url: fileViewUrl(row.original.id),
|
)
|
||||||
mimeType: row.original.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(row.original.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(row.original.id, row.original.name)
|
||||||
|
}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
@@ -839,11 +831,7 @@ export default function CustomerDetailPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(doc.id, doc.name).then(view)
|
||||||
name: doc.name,
|
|
||||||
url: fileViewUrl(doc.id),
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{doc.name}
|
{doc.name}
|
||||||
@@ -869,18 +857,17 @@ export default function CustomerDetailPage() {
|
|||||||
color="gray"
|
color="gray"
|
||||||
aria-label={`Preview ${doc.name}`}
|
aria-label={`Preview ${doc.name}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(doc.id, doc.name).then(view)
|
||||||
name: doc.name,
|
|
||||||
url: fileViewUrl(doc.id),
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(doc.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(doc.id, doc.name)
|
||||||
|
}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label={`Download ${doc.name}`}
|
aria-label={`Download ${doc.name}`}
|
||||||
@@ -980,11 +967,7 @@ export default function CustomerDetailPage() {
|
|||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
name: f.name,
|
|
||||||
url: fileViewUrl(f.id),
|
|
||||||
mimeType: f.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
size="xs"
|
size="xs"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import { driversService } from "@/services/drivers.service";
|
|||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
|
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
|
||||||
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
|
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
|
||||||
|
import {
|
||||||
|
downloadBookingFile,
|
||||||
|
fetchViewableFile,
|
||||||
|
} from "@/services/files.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const fmtDate = (iso?: string | null) => {
|
const fmtDate = (iso?: string | null) => {
|
||||||
@@ -155,10 +159,6 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
|
|||||||
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
|
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// /files/:id is a public inline-serving route; open directly for preview/download.
|
|
||||||
const fileUrl = (fileId: string, download = false) =>
|
|
||||||
`${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Stack gap="md" mb="lg">
|
<Stack gap="md" mb="lg">
|
||||||
@@ -211,10 +211,22 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
|
|||||||
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
|
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
<ActionIcon variant="subtle" aria-label="View" onClick={() => window.open(fileUrl(doc.id), "_blank")}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
aria-label="View"
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(doc.id, doc.name).then((f) =>
|
||||||
|
window.open(f.url, "_blank"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="subtle" aria-label="Download" onClick={() => window.open(fileUrl(doc.id, true), "_blank")}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
aria-label="Download"
|
||||||
|
onClick={() => void downloadBookingFile(doc.id, doc.name)}
|
||||||
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
subtitle="Global limits applied when previewing and assigning bookings to trains."
|
subtitle="Global limits applied when previewing and assigning bookings to trains."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card maw={720}>
|
{/* <Card maw={720}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Max wagons per train"
|
label="Max wagons per train"
|
||||||
@@ -107,7 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card> */}
|
||||||
|
|
||||||
<Card maw={720} mt="md">
|
<Card maw={720} mt="md">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|||||||
@@ -24,3 +24,20 @@ export async function downloadBookingFile(
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /files/:id is authenticated (global JwtGuard) — raw browser loads
|
||||||
|
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Fetch the bytes
|
||||||
|
* through the axios client and hand the viewer a blob object URL instead.
|
||||||
|
*/
|
||||||
|
export async function fetchViewableFile(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<{ name: string; url: string; mimeType?: string }> {
|
||||||
|
const blob = await filesService.download(id);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
mimeType: blob.type || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||||
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
||||||
|
|
||||||
type ReviewStatus = "PENDING" | "APPROVED" | "QUERIED" | null;
|
type ReviewStatus = "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||||
@@ -97,11 +97,8 @@ export function ClearanceDocumentUploadCard({
|
|||||||
const queried = reviewStatus === "QUERIED";
|
const queried = reviewStatus === "QUERIED";
|
||||||
const approved = reviewStatus === "APPROVED";
|
const approved = reviewStatus === "APPROVED";
|
||||||
const showUpload = canUpload && !approved && onStageFile;
|
const showUpload = canUpload && !approved && onStageFile;
|
||||||
const viewUrl = uploadedFile ? fileViewUrl(uploadedFile.id) : null;
|
|
||||||
const canPreviewUploaded =
|
const canPreviewUploaded =
|
||||||
uploadedFile &&
|
uploadedFile && isViewable({ name: uploadedFile.name, url: "" });
|
||||||
viewUrl &&
|
|
||||||
isViewable({ name: uploadedFile.name, url: viewUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -175,7 +172,9 @@ export function ClearanceDocumentUploadCard({
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<Eye size={14} />}
|
leftSection={<Eye size={14} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onPreview({ name: uploadedFile.name, url: viewUrl! })
|
void fetchViewableFile(uploadedFile.id, uploadedFile.name).then(
|
||||||
|
onPreview,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
@@ -184,9 +183,11 @@ export function ClearanceDocumentUploadCard({
|
|||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(uploadedFile.id, true)}
|
type="button"
|
||||||
download={uploadedFile.name}
|
onClick={() =>
|
||||||
|
void downloadStoredFile(uploadedFile.id, uploadedFile.name)
|
||||||
|
}
|
||||||
leftSection={<Download size={14} />}
|
leftSection={<Download size={14} />}
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Paperclip } from "lucide-react";
|
|||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import type { IFileUploadSetting } from "@edr/types/freight";
|
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||||
|
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import type { LicenseFile } from "@/services/companies.service";
|
import type { LicenseFile } from "@/services/companies.service";
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -111,9 +111,13 @@ export default function RoleLicenseStep({
|
|||||||
<Group key={f.id} gap={6} wrap="nowrap">
|
<Group key={f.id} gap={6} wrap="nowrap">
|
||||||
<Paperclip size={13} className="text-edr-muted" />
|
<Paperclip size={13} className="text-edr-muted" />
|
||||||
<Anchor
|
<Anchor
|
||||||
href={fileViewUrl(f.id)}
|
component="button"
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then((v) =>
|
||||||
|
window.open(v.url, "_blank"),
|
||||||
|
)
|
||||||
|
}
|
||||||
size="xs"
|
size="xs"
|
||||||
>
|
>
|
||||||
{f.name}
|
{f.name}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { downloadStoredFile } from "@/services/files.service";
|
||||||
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
@@ -86,12 +86,7 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
files={clearance.workflowFiles ?? []}
|
files={clearance.workflowFiles ?? []}
|
||||||
title="Customs documents"
|
title="Customs documents"
|
||||||
onView={(f) => view(f)}
|
onView={(f) => view(f)}
|
||||||
onDownload={({ id, name }) => {
|
onDownload={({ id, name }) => void downloadStoredFile(id, name)}
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = fileViewUrl(id, true);
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
{viewer}
|
{viewer}
|
||||||
@@ -110,6 +105,7 @@ function DutyAdvicePanel({
|
|||||||
}) {
|
}) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const noticeFile = dutyAdvice.noticeFile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||||
@@ -124,16 +120,16 @@ function DutyAdvicePanel({
|
|||||||
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
||||||
: null}
|
: null}
|
||||||
</Text>
|
</Text>
|
||||||
{dutyAdvice.noticeFile ? (
|
{noticeFile ? (
|
||||||
<Anchor
|
<Anchor
|
||||||
href={fileViewUrl(dutyAdvice.noticeFile.id, true)}
|
component="button"
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
Download duty notice ({dutyAdvice.noticeFile.name})
|
Download duty notice ({noticeFile.name})
|
||||||
</Group>
|
</Group>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { useMemo, useRef, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { downloadStoredFile } from "@/services/files.service";
|
||||||
import type { SubmitBookingResponse } from "@/services/bookings.service";
|
import type { SubmitBookingResponse } from "@/services/bookings.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -289,15 +289,25 @@ export function DraftBookingView({
|
|||||||
action={
|
action={
|
||||||
isUploaded && !allowReplace ? (
|
isUploaded && !allowReplace ? (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={file ? fileViewUrl(file.id, true) : undefined}
|
onClick={
|
||||||
|
file
|
||||||
|
? () => void downloadStoredFile(file.id, file.name)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isUploaded && (
|
{isUploaded && (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={
|
onClick={
|
||||||
file ? fileViewUrl(file.id, true) : undefined
|
file
|
||||||
|
? () =>
|
||||||
|
void downloadStoredFile(
|
||||||
|
file.id,
|
||||||
|
file.name,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
@@ -102,7 +102,6 @@ function FileRow({
|
|||||||
last?: boolean;
|
last?: boolean;
|
||||||
onView: (f: { name: string; url: string }) => void;
|
onView: (f: { name: string; url: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const viewUrl = file ? fileViewUrl(file.id) : null;
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
gap={13}
|
gap={13}
|
||||||
@@ -135,13 +134,18 @@ function FileRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{pill}
|
{pill}
|
||||||
{file && viewUrl && isViewable({ name: file.name, url: viewUrl }) && (
|
{file && isViewable({ name: file.name, url: "" }) && (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
icon={<Eye size={15} />}
|
icon={<Eye size={15} />}
|
||||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
onClick={() => void fetchViewableFile(file.id, file.name).then(onView)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{file && (
|
||||||
|
<IconSquare
|
||||||
|
icon={<Download size={15} />}
|
||||||
|
onClick={() => void downloadStoredFile(file.id, file.name)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{file && <IconSquare href={fileViewUrl(file.id, true)} icon={<Download size={15} />} />}
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -475,12 +479,7 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
files={clearance!.workflowFiles ?? []}
|
files={clearance!.workflowFiles ?? []}
|
||||||
tradeDirection={booking.tradeDirection}
|
tradeDirection={booking.tradeDirection}
|
||||||
onView={(f) => view(f)}
|
onView={(f) => view(f)}
|
||||||
onDownload={({ id, name }) => {
|
onDownload={({ id, name }) => void downloadStoredFile(id, name)}
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = fileViewUrl(id, true);
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { downloadStoredFile } from "@/services/files.service";
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -966,9 +966,13 @@ export default function EditBookingPage() {
|
|||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
{isUploaded && !selected && (
|
{isUploaded && !selected && (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={
|
onClick={
|
||||||
uploadedFile
|
uploadedFile
|
||||||
? fileViewUrl(uploadedFile.id, true)
|
? () =>
|
||||||
|
void downloadStoredFile(
|
||||||
|
uploadedFile.id,
|
||||||
|
uploadedFile.name,
|
||||||
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
ClearanceAdHocUploadSection,
|
ClearanceAdHocUploadSection,
|
||||||
} from "@/components/contracts/ClearanceAdHocUploadSection";
|
} from "@/components/contracts/ClearanceAdHocUploadSection";
|
||||||
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { OperationDatePicker } from "./OperationDatePicker";
|
import { OperationDatePicker } from "./OperationDatePicker";
|
||||||
import type { ClearanceFlowController } from "./useClearanceFlow";
|
import type { ClearanceFlowController } from "./useClearanceFlow";
|
||||||
@@ -167,21 +167,23 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={8} wrap="nowrap">
|
||||||
{isViewable({
|
{isViewable({
|
||||||
name: doc.file.name,
|
name: doc.file.name,
|
||||||
url: fileViewUrl(doc.file.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
icon={<Eye size={15} />}
|
icon={<Eye size={15} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: doc.file!.name,
|
doc.file!.id,
|
||||||
url: fileViewUrl(doc.file!.id),
|
doc.file!.name,
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
|
||||||
icon={<Download size={15} />}
|
icon={<Download size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
void downloadStoredFile(doc.file!.id, doc.file!.name)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -434,6 +434,70 @@ export function ToggleRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One numbered toggle per container unit in the line — tap units to mark how
|
||||||
|
* many are hazardous/refrigerated/returning (2 hazardous → toggle 2 units on).
|
||||||
|
* Selection fills from unit 1: tapping unit N selects 1..N, tapping a selected
|
||||||
|
* unit N keeps 1..N-1 — the count is always derived, never free-typed, so it
|
||||||
|
* can't exceed the line quantity.
|
||||||
|
*/
|
||||||
|
export function UnitCountToggles({
|
||||||
|
total,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
activeBg,
|
||||||
|
activeBorder,
|
||||||
|
activeColor,
|
||||||
|
}: {
|
||||||
|
total: number;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
label: string;
|
||||||
|
activeBg: string;
|
||||||
|
activeBorder: string;
|
||||||
|
activeColor: string;
|
||||||
|
}) {
|
||||||
|
const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz={12} fw={600} c="#4A5A68" mb={6}>
|
||||||
|
{label} · {count}/{total} selected
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Array.from({ length: total }, (_, i) => {
|
||||||
|
const selected = i < count;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={`Container ${i + 1}`}
|
||||||
|
onClick={() => onChange(String(selected ? i : i + 1))}
|
||||||
|
className="rounded-lg"
|
||||||
|
style={{
|
||||||
|
minWidth: 40,
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: `1.5px solid ${selected ? activeBorder : BORDER}`,
|
||||||
|
background: selected ? activeBg : "#fff",
|
||||||
|
color: selected ? activeColor : MUTED,
|
||||||
|
transition:
|
||||||
|
"background 120ms ease, border-color 120ms ease, color 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{i + 1}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface AsyncComboboxOption {
|
interface AsyncComboboxOption {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
StepHeader,
|
StepHeader,
|
||||||
StepLabel,
|
StepLabel,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
|
UnitCountToggles,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<
|
type BookingForm = UseFormReturn<
|
||||||
@@ -35,70 +36,6 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
|||||||
if (event.key === "-") event.preventDefault();
|
if (event.key === "-") event.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* One numbered toggle per container unit in the line — tap units to mark how
|
|
||||||
* many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
|
|
||||||
* fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N
|
|
||||||
* keeps 1..N-1 — the count is always derived, never free-typed, so it can't
|
|
||||||
* exceed the line quantity.
|
|
||||||
*/
|
|
||||||
function UnitCountToggles({
|
|
||||||
total,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
label,
|
|
||||||
activeBg,
|
|
||||||
activeBorder,
|
|
||||||
activeColor,
|
|
||||||
}: {
|
|
||||||
total: number;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
label: string;
|
|
||||||
activeBg: string;
|
|
||||||
activeBorder: string;
|
|
||||||
activeColor: string;
|
|
||||||
}) {
|
|
||||||
const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Text fz={12} fw={600} c="#4A5A68" mb={6}>
|
|
||||||
{label} · {count}/{total} selected
|
|
||||||
</Text>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Array.from({ length: total }, (_, i) => {
|
|
||||||
const selected = i < count;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={selected}
|
|
||||||
aria-label={`Container ${i + 1}`}
|
|
||||||
onClick={() => onChange(String(selected ? i : i + 1))}
|
|
||||||
className="rounded-lg"
|
|
||||||
style={{
|
|
||||||
minWidth: 40,
|
|
||||||
padding: "6px 10px",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
cursor: "pointer",
|
|
||||||
border: `1.5px solid ${selected ? activeBorder : "#E6ECF2"}`,
|
|
||||||
background: selected ? activeBg : "#fff",
|
|
||||||
color: selected ? activeColor : "#6B7C8E",
|
|
||||||
transition:
|
|
||||||
"background 120ms ease, border-color 120ms ease, color 120ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
#{i + 1}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Step5CargoDetails({
|
export function Step5CargoDetails({
|
||||||
form,
|
form,
|
||||||
referenceData,
|
referenceData,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SmartFileInput } from "@edr/ui-common";
|
|||||||
import { CheckCircle2, Download, FileText } from "lucide-react";
|
import { CheckCircle2, Download, FileText } from "lucide-react";
|
||||||
|
|
||||||
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { downloadStoredFile } from "@/services/files.service";
|
||||||
import { labelForDocCode } from "./resubmitDocs";
|
import { labelForDocCode } from "./resubmitDocs";
|
||||||
import type { ResubmitFlowController } from "./useResubmitFlow";
|
import type { ResubmitFlowController } from "./useResubmitFlow";
|
||||||
|
|
||||||
@@ -70,8 +70,8 @@ export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={fileViewUrl(file.id, true)}
|
|
||||||
icon={<Download size={15} />}
|
icon={<Download size={15} />}
|
||||||
|
onClick={() => void downloadStoredFile(file.id, file.name)}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
|
|
||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||||
import {
|
import {
|
||||||
ClearanceAdHocUploadSection,
|
ClearanceAdHocUploadSection,
|
||||||
type AdHocDoc,
|
type AdHocDoc,
|
||||||
@@ -259,21 +259,23 @@ export function ContractClearancePanel({
|
|||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={8} wrap="nowrap">
|
||||||
{isViewable({
|
{isViewable({
|
||||||
name: doc.file.name,
|
name: doc.file.name,
|
||||||
url: fileViewUrl(doc.file.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
icon={<Eye size={15} />}
|
icon={<Eye size={15} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: doc.file!.name,
|
doc.file!.id,
|
||||||
url: fileViewUrl(doc.file!.id),
|
doc.file!.name,
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
|
||||||
icon={<Download size={15} />}
|
icon={<Download size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
void downloadStoredFile(doc.file!.id, doc.file!.name)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
@@ -294,12 +296,7 @@ export function ContractClearancePanel({
|
|||||||
files={clearance?.workflowFiles ?? []}
|
files={clearance?.workflowFiles ?? []}
|
||||||
title="Customs workflow documents"
|
title="Customs workflow documents"
|
||||||
onView={(f) => view(f)}
|
onView={(f) => view(f)}
|
||||||
onDownload={({ id, name }) => {
|
onDownload={({ id, name }) => void downloadStoredFile(id, name)}
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = fileViewUrl(id, true);
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,13 @@ import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
|||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { BORDER, INK } from "./contract-ui";
|
import { BORDER, INK } from "./contract-ui";
|
||||||
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
||||||
|
|
||||||
function downloadWorkflowFile({ id, name }: { id: string; name: string }) {
|
function downloadWorkflowFile({ id, name }: { id: string; name: string }) {
|
||||||
const a = document.createElement("a");
|
void downloadStoredFile(id, name);
|
||||||
a.href = fileViewUrl(id, true);
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContractClearanceWorkflowBanner({
|
export function ContractClearanceWorkflowBanner({
|
||||||
@@ -191,9 +188,14 @@ function DutyAdvicePanel({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="orange"
|
color="orange"
|
||||||
leftSection={<Download size={14} />}
|
leftSection={<Download size={14} />}
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(dutyAdvice.noticeFile.id, true)}
|
type="button"
|
||||||
download={dutyAdvice.noticeFile.name}
|
onClick={() =>
|
||||||
|
void downloadStoredFile(
|
||||||
|
dutyAdvice.noticeFile!.id,
|
||||||
|
dutyAdvice.noticeFile!.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Download duty notice
|
Download duty notice
|
||||||
</Button>
|
</Button>
|
||||||
@@ -202,10 +204,10 @@ function DutyAdvicePanel({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onPreview({
|
void fetchViewableFile(
|
||||||
name: dutyAdvice.noticeFile!.name,
|
dutyAdvice.noticeFile!.id,
|
||||||
url: fileViewUrl(dutyAdvice.noticeFile!.id),
|
dutyAdvice.noticeFile!.name,
|
||||||
})
|
).then(onPreview)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Preview notice
|
Preview notice
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { clearanceWorkflowFileLabel } from "@edr/types";
|
import { clearanceWorkflowFileLabel } from "@edr/types";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
@@ -450,11 +450,9 @@ export default function ContractDetailPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
leftSection={<FileText size={16} />}
|
leftSection={<FileText size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(contractPdf.id, contractPdf.name).then(
|
||||||
name: contractPdf.name,
|
view,
|
||||||
url: fileViewUrl(contractPdf.id),
|
)
|
||||||
mimeType: contractPdf.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View contract
|
View contract
|
||||||
@@ -1184,10 +1182,7 @@ export default function ContractDetailPage() {
|
|||||||
onView={view}
|
onView={view}
|
||||||
onDownload={async (f) => {
|
onDownload={async (f) => {
|
||||||
try {
|
try {
|
||||||
const a = document.createElement("a");
|
await downloadStoredFile(f.id, f.name);
|
||||||
a.href = fileViewUrl(f.id, true);
|
|
||||||
a.download = f.name;
|
|
||||||
a.click();
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Could not download file.");
|
toast.error("Could not download file.");
|
||||||
}
|
}
|
||||||
@@ -1749,7 +1744,7 @@ function DocFileRow({
|
|||||||
const { ext, color } = fileTypeChip(file.name, file.mimeType);
|
const { ext, color } = fileTypeChip(file.name, file.mimeType);
|
||||||
const viewable = isViewable({
|
const viewable = isViewable({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
url: fileViewUrl(file.id),
|
url: "",
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
@@ -1804,19 +1799,16 @@ function DocFileRow({
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={14} />}
|
leftSection={<Eye size={14} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onView({
|
void fetchViewableFile(file.id, file.name).then(onView)
|
||||||
name: file.name,
|
|
||||||
url: fileViewUrl(file.id),
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(file.id, true)}
|
type="button"
|
||||||
|
onClick={() => void downloadStoredFile(file.id, file.name)}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -31,10 +38,12 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
FileDown,
|
FileDown,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
Flame,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Snowflake,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -50,6 +59,8 @@ import {
|
|||||||
StepCard,
|
StepCard,
|
||||||
StepHeader,
|
StepHeader,
|
||||||
StepLabel,
|
StepLabel,
|
||||||
|
ToggleRow,
|
||||||
|
UnitCountToggles,
|
||||||
fieldStyles,
|
fieldStyles,
|
||||||
} from "./new-contract-form/shared";
|
} from "./new-contract-form/shared";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
@@ -1140,6 +1151,7 @@ function CargoStep({
|
|||||||
allowedSizes: sizes,
|
allowedSizes: sizes,
|
||||||
includeHazardous: contract.isHazardous ?? false,
|
includeHazardous: contract.isHazardous ?? false,
|
||||||
includeReefer: contract.isReefer ?? false,
|
includeReefer: contract.isReefer ?? false,
|
||||||
|
includeReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportFile = async (file: File | null) => {
|
const handleImportFile = async (file: File | null) => {
|
||||||
@@ -1174,8 +1186,7 @@ function CargoStep({
|
|||||||
quantity: String(imported.length),
|
quantity: String(imported.length),
|
||||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||||
returnQuantity:
|
returnQuantity: String(imported.filter((r) => r.withReturn).length),
|
||||||
current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
|
||||||
units: imported.map((r) => ({
|
units: imported.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
sealNumber: r.sealNumber,
|
sealNumber: r.sealNumber,
|
||||||
@@ -1530,6 +1541,72 @@ function ContainerLineEditor({
|
|||||||
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
|
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lowering the line quantity must pull every cargo-handling count back within
|
||||||
|
// it, or a stale count silently exceeds the line and fails validation on a
|
||||||
|
// field the customer can no longer see a cause for.
|
||||||
|
const clampHandlingCounts = (qty: number) => {
|
||||||
|
(["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach(
|
||||||
|
(key) => {
|
||||||
|
const path = `containers.${index}.${key}` as const;
|
||||||
|
const current = Number(form.getValues(path) || 0);
|
||||||
|
if (current > qty)
|
||||||
|
form.setValue(path, String(Math.max(0, qty)), {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Switch state is derived from the count — a line is hazardous iff qty > 0. */
|
||||||
|
const handlingToggle = (
|
||||||
|
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
|
||||||
|
opts: {
|
||||||
|
icon: ReactNode;
|
||||||
|
iconBg: string;
|
||||||
|
iconColor: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pickLabel: string;
|
||||||
|
activeBg: string;
|
||||||
|
activeBorder: string;
|
||||||
|
activeColor: string;
|
||||||
|
},
|
||||||
|
) => (
|
||||||
|
<Controller
|
||||||
|
name={`containers.${index}.${key}`}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<ToggleRow
|
||||||
|
icon={opts.icon}
|
||||||
|
iconBg={opts.iconBg}
|
||||||
|
iconColor={opts.iconColor}
|
||||||
|
title={opts.title}
|
||||||
|
description={opts.description}
|
||||||
|
checked={Number(field.value || 0) > 0}
|
||||||
|
onChange={(on) => field.onChange(on ? "1" : "0")}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<UnitCountToggles
|
||||||
|
total={quantity}
|
||||||
|
value={field.value ?? "0"}
|
||||||
|
onChange={field.onChange}
|
||||||
|
label={opts.pickLabel}
|
||||||
|
activeBg={opts.activeBg}
|
||||||
|
activeBorder={opts.activeBorder}
|
||||||
|
activeColor={opts.activeColor}
|
||||||
|
/>
|
||||||
|
{fieldState.error?.message ? (
|
||||||
|
<Text fz={11} c="red.7" mt={4}>
|
||||||
|
{fieldState.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ToggleRow>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="rounded-xl"
|
className="rounded-xl"
|
||||||
@@ -1538,7 +1615,7 @@ function ContainerLineEditor({
|
|||||||
<Text fz={14} fw={700} c="#10202F" mb={10}>
|
<Text fz={14} fw={700} c="#10202F" mb={10}>
|
||||||
{size} containers
|
{size} containers
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={12} grow mb={12} align="flex-start">
|
<Box maw={220} mb={14}>
|
||||||
<Controller
|
<Controller
|
||||||
name={`containers.${index}.quantity`}
|
name={`containers.${index}.quantity`}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -1554,67 +1631,61 @@ function ContainerLineEditor({
|
|||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e.currentTarget.value);
|
field.onChange(e.currentTarget.value);
|
||||||
syncUnits(Number(e.currentTarget.value || 0));
|
const qty = Number(e.currentTarget.value || 0);
|
||||||
|
syncUnits(qty);
|
||||||
|
clampHandlingCounts(qty);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{isHazardous && (
|
</Box>
|
||||||
<Controller
|
|
||||||
name={`containers.${index}.hazardousQuantity`}
|
{/* Cargo handling — only the services this contract was created with are
|
||||||
control={form.control}
|
offered, since the server rejects quantities for the others. Each
|
||||||
render={({ field, fieldState }) => (
|
switch reveals a bounded picker: tap the containers it applies to. */}
|
||||||
<TextInput
|
{(isHazardous || isReefer || withReturnService) && quantity > 0 && (
|
||||||
{...field}
|
<>
|
||||||
type="number"
|
<StepLabel>Cargo handling</StepLabel>
|
||||||
onKeyDown={blockNegative}
|
<div className="grid gap-3 sm:grid-cols-2" style={{ marginBottom: 14 }}>
|
||||||
label="Hazardous qty"
|
{isHazardous &&
|
||||||
min={0}
|
handlingToggle("hazardousQuantity", {
|
||||||
error={fieldState.error?.message}
|
icon: <Flame size={18} />,
|
||||||
radius={10}
|
iconBg: "#FBEAE7",
|
||||||
styles={fieldStyles}
|
iconColor: "#C0392B",
|
||||||
/>
|
title: "Hazardous",
|
||||||
)}
|
description: "Some of these containers carry hazardous cargo.",
|
||||||
/>
|
pickLabel: "Tap the hazardous containers",
|
||||||
)}
|
activeBg: "#FBEAE7",
|
||||||
{isReefer && (
|
activeBorder: "#E4A69B",
|
||||||
<Controller
|
activeColor: "#C0392B",
|
||||||
name={`containers.${index}.reeferQuantity`}
|
})}
|
||||||
control={form.control}
|
{isReefer &&
|
||||||
render={({ field, fieldState }) => (
|
handlingToggle("reeferQuantity", {
|
||||||
<TextInput
|
icon: <Snowflake size={18} />,
|
||||||
{...field}
|
iconBg: "#E9F0F8",
|
||||||
type="number"
|
iconColor: "#2E5B96",
|
||||||
onKeyDown={blockNegative}
|
title: "Refrigerated",
|
||||||
label="Reefer qty"
|
description: "Some of these containers need reefer transport.",
|
||||||
min={0}
|
pickLabel: "Tap the refrigerated containers",
|
||||||
error={fieldState.error?.message}
|
activeBg: "#E9F0F8",
|
||||||
radius={10}
|
activeBorder: "#A9C2E0",
|
||||||
styles={fieldStyles}
|
activeColor: "#2E5B96",
|
||||||
/>
|
})}
|
||||||
)}
|
{withReturnService &&
|
||||||
/>
|
handlingToggle("returnQuantity", {
|
||||||
)}
|
icon: <Repeat size={18} />,
|
||||||
{withReturnService && (
|
iconBg: "#ECF6F1",
|
||||||
<Controller
|
iconColor: "#0A6F4D",
|
||||||
name={`containers.${index}.returnQuantity`}
|
title: "With return",
|
||||||
control={form.control}
|
description: "Some of these containers come back to EDR empty.",
|
||||||
render={({ field, fieldState }) => (
|
pickLabel: "Tap the containers EDR returns",
|
||||||
<TextInput
|
activeBg: "#ECF6F1",
|
||||||
{...field}
|
activeBorder: "#A9D6C2",
|
||||||
type="number"
|
activeColor: "#0A6F4D",
|
||||||
onKeyDown={blockNegative}
|
})}
|
||||||
label="With return qty"
|
</div>
|
||||||
description="Containers EDR returns empty"
|
</>
|
||||||
min={0}
|
)}
|
||||||
error={fieldState.error?.message}
|
|
||||||
radius={10}
|
|
||||||
styles={fieldStyles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<StepLabel>Per-container details</StepLabel>
|
<StepLabel>Per-container details</StepLabel>
|
||||||
<Stack gap={10} mt={8}>
|
<Stack gap={10} mt={8}>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
|
||||||
// Brand palette (mirrors the booking form's shared constants).
|
// Brand palette (mirrors the booking form's shared constants).
|
||||||
export const INK = "#10202F";
|
export const INK = "#10202F";
|
||||||
@@ -275,11 +275,14 @@ export function ContractDocButton({
|
|||||||
return (
|
return (
|
||||||
<Tooltip label="Contract document" withArrow>
|
<Tooltip label="Contract document" withArrow>
|
||||||
<Box
|
<Box
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(file.id)}
|
type="button"
|
||||||
target="_blank"
|
onClick={(e: React.MouseEvent) => {
|
||||||
rel="noreferrer"
|
onClick?.(e);
|
||||||
onClick={onClick}
|
void fetchViewableFile(file.id, file.name).then((f) =>
|
||||||
|
window.open(f.url, "_blank"),
|
||||||
|
);
|
||||||
|
}}
|
||||||
aria-label="Open contract document"
|
aria-label="Open contract document"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -287,6 +290,8 @@ export function ContractDocButton({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: `1px solid ${BORDER}`,
|
border: `1px solid ${BORDER}`,
|
||||||
background: "#FFFFFF",
|
background: "#FFFFFF",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { CompanyDocument } from "@/services/companies.service";
|
import type { CompanyDocument } from "@/services/companies.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { downloadStoredFile } from "@/services/files.service";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
|
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
|
||||||
import { BORDER, GREEN, INK } from "../contract-ui";
|
import { BORDER, GREEN, INK } from "../contract-ui";
|
||||||
|
|
||||||
@@ -181,8 +181,9 @@ export function ContractDocsEditor({
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(file.id, true)}
|
type="button"
|
||||||
|
onClick={() => void downloadStoredFile(file.id, file.name)}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import type {
|
|||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
|
||||||
// The contract wizard reuses the booking wizard's premium card chrome verbatim
|
// The contract wizard reuses the booking wizard's premium card chrome verbatim
|
||||||
// (OptionCard / StepCard / StepHeader / AlertBox / StepLabel / fieldStyles).
|
// (OptionCard / StepCard / StepHeader / AlertBox / StepLabel / fieldStyles),
|
||||||
|
// plus the cargo-handling switch + tap-to-count pair used per container line.
|
||||||
export {
|
export {
|
||||||
AlertBox,
|
AlertBox,
|
||||||
fieldStyles,
|
fieldStyles,
|
||||||
@@ -25,6 +26,8 @@ export {
|
|||||||
StepCard,
|
StepCard,
|
||||||
StepHeader,
|
StepHeader,
|
||||||
StepLabel,
|
StepLabel,
|
||||||
|
ToggleRow,
|
||||||
|
UnitCountToggles,
|
||||||
} from "@/pages/bookings/new-booking-form/shared";
|
} from "@/pages/bookings/new-booking-form/shared";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
|
|||||||
|
|
||||||
// Excel import for container shipments: one spreadsheet row per physical
|
// Excel import for container shipments: one spreadsheet row per physical
|
||||||
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
||||||
// hazardous/reefer flags when the contract allows them. The parser is
|
// hazardous/reefer/return flags when the contract allows them. The parser is
|
||||||
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
||||||
// partial import can never silently drop containers.
|
// partial import can never silently drop containers.
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
|
|||||||
allowedSizes: string[];
|
allowedSizes: string[];
|
||||||
includeHazardous: boolean;
|
includeHazardous: boolean;
|
||||||
includeReefer: boolean;
|
includeReefer: boolean;
|
||||||
|
/** Contract was created WITH_RETURN — offer the empty-return column. */
|
||||||
|
includeReturn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportedContainerRow {
|
export interface ImportedContainerRow {
|
||||||
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
|
|||||||
vgmTons: string;
|
vgmTons: string;
|
||||||
hazardous: boolean;
|
hazardous: boolean;
|
||||||
reefer: boolean;
|
reefer: boolean;
|
||||||
|
withReturn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerExcelResult {
|
export interface ContainerExcelResult {
|
||||||
@@ -36,7 +39,8 @@ type ColumnKey =
|
|||||||
| "sealNumber"
|
| "sealNumber"
|
||||||
| "vgmTons"
|
| "vgmTons"
|
||||||
| "hazardous"
|
| "hazardous"
|
||||||
| "reefer";
|
| "reefer"
|
||||||
|
| "withReturn";
|
||||||
|
|
||||||
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
||||||
function headerKey(raw: string): ColumnKey | null {
|
function headerKey(raw: string): ColumnKey | null {
|
||||||
@@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null {
|
|||||||
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
||||||
if (h.includes("hazard")) return "hazardous";
|
if (h.includes("hazard")) return "hazardous";
|
||||||
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
||||||
|
if (h.includes("return")) return "withReturn";
|
||||||
// After the more specific matches: "Container Number", "Container No", …
|
// After the more specific matches: "Container Number", "Container No", …
|
||||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||||
return null;
|
return null;
|
||||||
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
|
|||||||
vgmTons: vgmRaw,
|
vgmTons: vgmRaw,
|
||||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||||
|
withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
|||||||
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
||||||
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
||||||
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
||||||
|
if (opts.includeReturn) headers.push("With Return (YES/NO)");
|
||||||
|
|
||||||
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
||||||
const sampleRows = sizes.map((size, i) => {
|
const sampleRows = sizes.map((size, i) => {
|
||||||
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
|||||||
];
|
];
|
||||||
if (opts.includeHazardous) row.push("NO");
|
if (opts.includeHazardous) row.push("NO");
|
||||||
if (opts.includeReefer) row.push("NO");
|
if (opts.includeReefer) row.push("NO");
|
||||||
|
if (opts.includeReturn) row.push("NO");
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
if (isContainer) {
|
||||||
let hazardTotalQty = 0;
|
let hazardTotalQty = 0;
|
||||||
let reeferTotalQty = 0;
|
let reeferTotalQty = 0;
|
||||||
|
let returnTotalQty = 0;
|
||||||
|
|
||||||
for (const line of values.containers) {
|
for (const line of values.containers) {
|
||||||
const qty = Number(line.quantity || 0);
|
const qty = Number(line.quantity || 0);
|
||||||
@@ -60,6 +61,7 @@ export function computeShipmentTotal(
|
|||||||
}
|
}
|
||||||
hazardTotalQty += Number(line.hazardousQuantity || 0);
|
hazardTotalQty += Number(line.hazardousQuantity || 0);
|
||||||
reeferTotalQty += Number(line.reeferQuantity || 0);
|
reeferTotalQty += Number(line.reeferQuantity || 0);
|
||||||
|
returnTotalQty += Number(line.returnQuantity || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contract.isHazardous && hazardTotalQty > 0) {
|
if (contract.isHazardous && hazardTotalQty > 0) {
|
||||||
@@ -86,6 +88,21 @@ export function computeShipmentTotal(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Empty-container return is a container-only surcharge, priced per returning
|
||||||
|
// container rather than per line (contract-pricing.service emits the
|
||||||
|
// `with_return` rate only for WITH_RETURN contracts).
|
||||||
|
if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) {
|
||||||
|
const wr = rateFor((i) => i.conditionalOn === "with_return");
|
||||||
|
if (wr) {
|
||||||
|
lines.push({
|
||||||
|
label: wr.label,
|
||||||
|
unitPrice: wr.unitPrice,
|
||||||
|
unit: wr.unit,
|
||||||
|
quantity: returnTotalQty,
|
||||||
|
amount: wr.unitPrice * returnTotalQty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||||
const rate =
|
const rate =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
companiesService,
|
companiesService,
|
||||||
@@ -113,9 +113,11 @@ export default function TabDocuments({
|
|||||||
{ name: string; url: string; size?: number; mimeType?: string | null }[]
|
{ name: string; url: string; size?: number; mimeType?: string | null }[]
|
||||||
> = {};
|
> = {};
|
||||||
for (const doc of docsQuery.data ?? []) {
|
for (const doc of docsQuery.data ?? []) {
|
||||||
|
// GET /api/files/:id is JWT-guarded, so no raw URL is stored here — the
|
||||||
|
// file id rides in `url` and onViewFile resolves it to a blob URL.
|
||||||
(map[doc.code] ??= []).push({
|
(map[doc.code] ??= []).push({
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
url: fileViewUrl(doc.id),
|
url: doc.id,
|
||||||
size: doc.size,
|
size: doc.size,
|
||||||
mimeType: doc.mimeType,
|
mimeType: doc.mimeType,
|
||||||
});
|
});
|
||||||
@@ -199,7 +201,7 @@ export default function TabDocuments({
|
|||||||
errors={fieldErrors}
|
errors={fieldErrors}
|
||||||
uploadedKeys={uploadedKeys}
|
uploadedKeys={uploadedKeys}
|
||||||
existingFiles={existingFilesByKey}
|
existingFiles={existingFilesByKey}
|
||||||
onViewFile={view}
|
onViewFile={(f) => void fetchViewableFile(f.url, f.name).then(view)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -439,11 +441,7 @@ function ProfileLicenseRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onViewFile({
|
void fetchViewableFile(f.id, f.name).then(onViewFile)
|
||||||
name: f.name,
|
|
||||||
url: fileViewUrl(f.id),
|
|
||||||
mimeType: f.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useFileViewer, type ViewableFile } from "@edr/ui-common";
|
import { useFileViewer, type ViewableFile } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import {
|
import {
|
||||||
companiesService,
|
companiesService,
|
||||||
type LicenseFile,
|
type LicenseFile,
|
||||||
@@ -528,11 +528,7 @@ function LetterRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onViewFile({
|
void fetchViewableFile(file.id, file.name).then(onViewFile)
|
||||||
name: file.name,
|
|
||||||
url: fileViewUrl(file.id),
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
|
|||||||
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