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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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