mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
Merge pull request #767 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -32,7 +32,10 @@ import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import {
|
||||
downloadBookingFile,
|
||||
fetchViewableFile,
|
||||
} from "@/services/files.service";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
|
||||
export interface ClearanceReviewSectionProps {
|
||||
@@ -272,17 +275,17 @@ export function ClearanceReviewSection({
|
||||
<>
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
url: "",
|
||||
}) && (
|
||||
<Tooltip label="View">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
void fetchViewableFile(
|
||||
doc.file!.id,
|
||||
doc.file!.name,
|
||||
).then(view)
|
||||
}
|
||||
c="edr-green"
|
||||
style={{
|
||||
@@ -298,10 +301,21 @@ export function ClearanceReviewSection({
|
||||
)}
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void downloadBookingFile(
|
||||
doc.file!.id,
|
||||
doc.file!.name,
|
||||
)
|
||||
}
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
@@ -544,7 +558,7 @@ function DocReviewCard({
|
||||
{hasFile &&
|
||||
isViewable({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
url: "",
|
||||
}) && (
|
||||
<Tooltip label="Preview document">
|
||||
<Button
|
||||
@@ -553,10 +567,9 @@ function DocReviewCard({
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
|
||||
onView,
|
||||
)
|
||||
}
|
||||
>
|
||||
View
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
|
||||
const CATEGORY_LABELS: Record<
|
||||
Freight.ClearanceWorkflowFileCategory,
|
||||
@@ -97,8 +97,7 @@ function WorkflowFileRow({
|
||||
const file = item.file;
|
||||
if (!file) return null;
|
||||
|
||||
const viewUrl = fileViewUrl(file.id);
|
||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
||||
const canPreview = isViewable({ name: file.name, url: "" });
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
@@ -129,7 +128,9 @@ function WorkflowFileRow({
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(file.id, file.name).then(onView)
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
@@ -34,7 +34,10 @@ import { isViewable } from "@edr/ui-common";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import {
|
||||
downloadBookingFile,
|
||||
fetchViewableFile,
|
||||
} from "@/services/files.service";
|
||||
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
|
||||
@@ -293,17 +296,17 @@ export function ContractClearanceReviewSection({
|
||||
<>
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
url: "",
|
||||
}) && (
|
||||
<Tooltip label="View">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
void fetchViewableFile(
|
||||
doc.file!.id,
|
||||
doc.file!.name,
|
||||
).then(view)
|
||||
}
|
||||
c="edr-green"
|
||||
style={{
|
||||
@@ -319,10 +322,21 @@ export function ContractClearanceReviewSection({
|
||||
)}
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void downloadBookingFile(
|
||||
doc.file!.id,
|
||||
doc.file!.name,
|
||||
)
|
||||
}
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
@@ -611,7 +625,7 @@ function DocReviewCard({
|
||||
{hasFile &&
|
||||
isViewable({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
url: "",
|
||||
}) && (
|
||||
<Tooltip label="Preview document">
|
||||
<Button
|
||||
@@ -620,10 +634,9 @@ function DocReviewCard({
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
|
||||
onView,
|
||||
)
|
||||
}
|
||||
>
|
||||
View
|
||||
@@ -633,8 +646,11 @@ function DocReviewCard({
|
||||
{hasFile && (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file!.id, true)}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void downloadBookingFile(doc.file!.id, doc.file!.name)
|
||||
}
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
|
||||
@@ -429,6 +429,7 @@ export default function GlCreateBookingForm() {
|
||||
quantity: Number(l.quantity || 0),
|
||||
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
||||
reeferQuantity: Number(l.reeferQuantity || 0),
|
||||
returnQuantity: Number(l.returnQuantity || 0),
|
||||
})),
|
||||
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
|
||||
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
|
||||
@@ -532,6 +533,7 @@ export default function GlCreateBookingForm() {
|
||||
allowedSizes: containerSizes,
|
||||
includeHazardous: contract?.isHazardous ?? false,
|
||||
includeReefer: contract?.isReefer ?? false,
|
||||
includeReturn: contractWithReturn,
|
||||
};
|
||||
|
||||
const handleImportFile = async (file: File | null) => {
|
||||
@@ -557,8 +559,7 @@ export default function GlCreateBookingForm() {
|
||||
quantity: String(imported.length),
|
||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||
returnQuantity:
|
||||
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
||||
returnQuantity: String(imported.filter((r) => r.withReturn).length),
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@man
|
||||
import { Download, Eye, FileText } from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
|
||||
export interface PhasedUploadedFileRowProps {
|
||||
label: string;
|
||||
@@ -20,8 +20,7 @@ export function PhasedUploadedFileRow({
|
||||
onDownload,
|
||||
compact = false,
|
||||
}: PhasedUploadedFileRowProps) {
|
||||
const viewUrl = fileViewUrl(file.id);
|
||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
||||
const canPreview = isViewable({ name: file.name, url: "" });
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -61,7 +60,9 @@ export function PhasedUploadedFileRow({
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(file.id, file.name).then(onView)
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for container shipments: one spreadsheet row per physical
|
||||
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
||||
// hazardous/reefer flags when the contract allows them. The parser is
|
||||
// hazardous/reefer/return flags when the contract allows them. The parser is
|
||||
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
||||
// partial import can never silently drop containers.
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
|
||||
allowedSizes: string[];
|
||||
includeHazardous: boolean;
|
||||
includeReefer: boolean;
|
||||
/** Contract was created WITH_RETURN — offer the empty-return column. */
|
||||
includeReturn?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportedContainerRow {
|
||||
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
|
||||
vgmTons: string;
|
||||
hazardous: boolean;
|
||||
reefer: boolean;
|
||||
withReturn: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerExcelResult {
|
||||
@@ -36,7 +39,8 @@ type ColumnKey =
|
||||
| "sealNumber"
|
||||
| "vgmTons"
|
||||
| "hazardous"
|
||||
| "reefer";
|
||||
| "reefer"
|
||||
| "withReturn";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
@@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null {
|
||||
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
||||
if (h.includes("hazard")) return "hazardous";
|
||||
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
||||
if (h.includes("return")) return "withReturn";
|
||||
// After the more specific matches: "Container Number", "Container No", …
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
|
||||
vgmTons: vgmRaw,
|
||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||
withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
||||
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
||||
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
||||
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
||||
if (opts.includeReturn) headers.push("With Return (YES/NO)");
|
||||
|
||||
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
||||
const sampleRows = sizes.map((size, i) => {
|
||||
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
||||
];
|
||||
if (opts.includeHazardous) row.push("NO");
|
||||
if (opts.includeReefer) row.push("NO");
|
||||
if (opts.includeReturn) row.push("NO");
|
||||
return row;
|
||||
});
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ export interface GlShipmentTotal {
|
||||
/** A normalized view of the form quantities, freight-shape agnostic. */
|
||||
export interface GlShipmentQuantities {
|
||||
isContainer: boolean;
|
||||
/** Container lines: size + total qty + hazardous/reefer qty. */
|
||||
/** Container lines: size + total qty + hazardous/reefer/return qty. */
|
||||
containers: Array<{
|
||||
containerSize: string;
|
||||
quantity: number;
|
||||
hazardousQuantity: number;
|
||||
reeferQuantity: number;
|
||||
/** Containers EDR takes back empty — only on WITH_RETURN contracts. */
|
||||
returnQuantity: number;
|
||||
}>;
|
||||
/** Bulk: tons (or item count) + hazardous/reefer qty. */
|
||||
bulkQuantity: number;
|
||||
@@ -53,6 +55,7 @@ export function computeGlShipmentTotal(
|
||||
if (q.isContainer) {
|
||||
let hazardTotalQty = 0;
|
||||
let reeferTotalQty = 0;
|
||||
let returnTotalQty = 0;
|
||||
|
||||
for (const line of q.containers) {
|
||||
const qty = line.quantity;
|
||||
@@ -75,6 +78,7 @@ export function computeGlShipmentTotal(
|
||||
}
|
||||
hazardTotalQty += line.hazardousQuantity;
|
||||
reeferTotalQty += line.reeferQuantity;
|
||||
returnTotalQty += line.returnQuantity;
|
||||
}
|
||||
|
||||
if (contract.isHazardous && hazardTotalQty > 0) {
|
||||
@@ -101,6 +105,21 @@ export function computeGlShipmentTotal(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Empty-container return is a container-only surcharge, priced per returning
|
||||
// container rather than per line (contract-pricing.service emits the
|
||||
// `with_return` rate only for WITH_RETURN contracts).
|
||||
if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) {
|
||||
const wr = rateFor((i) => i.conditionalOn === "with_return");
|
||||
if (wr) {
|
||||
lines.push({
|
||||
label: wr.label,
|
||||
unitPrice: wr.unitPrice,
|
||||
unit: wr.unit,
|
||||
quantity: returnTotalQty,
|
||||
amount: wr.unitPrice * returnTotalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const qty = q.bulkQuantity;
|
||||
const rate =
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
@@ -236,10 +236,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: c.fileName ?? humanize(c.code),
|
||||
url: fileViewUrl(c.fileId),
|
||||
})
|
||||
void fetchViewableFile(
|
||||
c.fileId,
|
||||
c.fileName ?? humanize(c.code),
|
||||
).then(view)
|
||||
}
|
||||
style={{
|
||||
textDecoration:
|
||||
@@ -269,10 +269,9 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: `Document ${i + 1}`,
|
||||
url: fileViewUrl(fileId),
|
||||
})
|
||||
void fetchViewableFile(fileId, `Document ${i + 1}`).then(
|
||||
view,
|
||||
)
|
||||
}
|
||||
>
|
||||
Document {i + 1}
|
||||
@@ -307,10 +306,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: c.fileName ?? "License document",
|
||||
url: fileViewUrl(c.fileId),
|
||||
})
|
||||
void fetchViewableFile(
|
||||
c.fileId,
|
||||
c.fileName ?? "License document",
|
||||
).then(view)
|
||||
}
|
||||
style={{
|
||||
textDecoration:
|
||||
|
||||
Reference in New Issue
Block a user