Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-13 11:34:57 +00:00
47 changed files with 1441 additions and 72 deletions

View File

@@ -52,6 +52,7 @@ import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
import LogoSettingsPage from "./pages/settings/LogoSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -1044,6 +1045,15 @@ const App = () => {
path="invoice-stamp-settings"
element={<Navigate to="/dashboard/stamp-settings" replace />}
/>
{/* The ONE company logo, shown in the header of every generated document. */}
<Route
path="logo-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.logo.view}>
<LogoSettingsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

View File

@@ -1,7 +1,10 @@
import type { ReactNode } from "react";
import { Truck } from "lucide-react";
import { SimpleGrid, Stack } from "@mantine/core";
import { Download, Truck } from "lucide-react";
import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
@@ -16,18 +19,44 @@ export interface BookingMileServicesCardProps {
handoverSection?: ReactNode;
}
/** First / last mile addresses, plus the export handover control. Renders
* nothing when none of the three are present. */
/**
* First / last mile addresses, plus the export handover control and the
* stored last-mile contract reference (signed status + PDF download) for
* Truck & Machinery once a request on this booking is approved. Renders
* nothing when none of the three are present.
*/
export function BookingMileServicesCard({
booking,
handoverSection,
}: BookingMileServicesCardProps) {
const hasAddresses =
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
const { data: requestsResponse } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }),
queryFn: async () =>
(await lastMileRequestsService.list({ bookingId: booking.id })).data,
enabled: Boolean(booking.lastMileDeliveryAddress),
});
const approvedRequest = (requestsResponse?.data ?? []).find(
(r) => r.status === "APPROVED",
);
if (!hasAddresses && !handoverSection) {
return null;
}
const downloadContract = async () => {
if (!approvedRequest) return;
const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
};
return (
<SectionCard icon={Truck} title="Mile services" accent="grape">
<Stack gap="md">
@@ -41,6 +70,32 @@ export function BookingMileServicesCard({
)}
</SimpleGrid>
)}
{approvedRequest && (
<Group justify="space-between" align="center" wrap="wrap">
<Stack gap={0}>
<Text size="sm" fw={600}>
Last-mile contract
</Text>
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
{approvedRequest.customerSignedAt
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
approvedRequest.signerDisplayName
? ` by ${approvedRequest.signerDisplayName}`
: ""
}`
: "Awaiting customer signature"}
</Text>
</Stack>
<Button
size="xs"
variant="light"
leftSection={<Download size={14} />}
onClick={() => void downloadContract()}
>
Download PDF
</Button>
</Group>
)}
{handoverSection}
</Stack>
</SectionCard>

View File

@@ -0,0 +1,172 @@
import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { ImageIcon, RefreshCw, X } from "lucide-react";
const MAX_LOGO_MB = 10;
export interface LogoUploadProps {
/** Logo image as a data URL, or null when none is attached yet. */
value: string | null;
onChange: (dataUrl: string | null) => void;
label?: string;
description?: string;
}
/**
* Company logo picker — reads the picked image straight into a data URL,
* same transport as {@link StampUpload}. Kept as its own component (not a
* generalized image-upload) matching how stamp/teeter are already separate
* files here despite the near-identical shape.
*/
export function LogoUpload({
value,
onChange,
label = "Company logo",
description = "Attach the official company logo.",
}: LogoUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const readFile = (file: File | undefined | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("The logo must be an image file (PNG or JPG).");
return;
}
if (file.size > MAX_LOGO_MB * 1024 * 1024) {
setError(`The logo image must be under ${MAX_LOGO_MB} MB.`);
return;
}
const reader = new FileReader();
reader.onload = () => {
setError(null);
setFileName(file.name);
onChange(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => setError("Could not read that file. Try another.");
reader.readAsDataURL(file);
};
const openPicker = () => inputRef.current?.click();
const clear = () => {
setFileName(null);
setError(null);
onChange(null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(e) => readFile(e.currentTarget.files?.[0])}
/>
{value ? (
<Paper withBorder radius="md" p="sm">
<Group gap="md" wrap="nowrap" align="center">
<Box
style={{
background:
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
borderRadius: 8,
flexShrink: 0,
padding: 6,
}}
>
<Image
src={value}
alt="Company logo"
fit="contain"
h={92}
w={92}
/>
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{fileName ?? "Logo attached"}
</Text>
<Text size="xs" c="dimmed">
Shown in the header of every generated document.
</Text>
<Group gap="xs" mt={2}>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<RefreshCw size={13} />}
onClick={openPicker}
>
Replace
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<X size={13} />}
onClick={clear}
>
Remove
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<Paper
withBorder
radius="md"
p="lg"
onClick={openPicker}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
readFile(e.dataTransfer.files?.[0]);
}}
style={{
borderColor: dragging
? "var(--mantine-color-edr-green-6)"
: undefined,
borderStyle: "dashed",
backgroundColor: dragging
? "var(--mantine-color-edr-green-0)"
: undefined,
cursor: "pointer",
}}
>
<Stack gap={6} align="center">
<ImageIcon size={26} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
Upload company logo
</Text>
<Text size="xs" c="dimmed" ta="center">
{description} Drop an image here or click to browse PNG or JPG,
up to {MAX_LOGO_MB} MB.
</Text>
</Stack>
</Paper>
)}
{error && (
<Text size="xs" c="red.7">
{error}
</Text>
)}
</Stack>
);
}

View File

@@ -9,6 +9,7 @@ import {
FileText,
Hammer,
History,
Image as ImageIcon,
LayoutDashboard,
LayoutGrid,
MapPin,
@@ -492,6 +493,12 @@ export const buildSidebarSections = (
icon: <Stamp />,
permission: FREIGHT_PERMS.settings.stamp.view,
},
{
label: "Company logo",
href: "/dashboard/logo-settings",
icon: <ImageIcon />,
permission: FREIGHT_PERMS.settings.logo.view,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",

View File

@@ -29,6 +29,7 @@ import {
// Repeat, // used by the hidden Move (reassign) button
Train,
TrainFront,
Truck,
Weight,
X,
} from "lucide-react";
@@ -38,6 +39,7 @@ import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { EntityLink } from "@/components/detail";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { useToast } from "@/hooks/use-toast";
import type {
EligibleContainerBooking,
@@ -412,6 +414,31 @@ export function ScheduleWorkspacePanel({
);
};
// Export cargo that skipped the warehouse (customer truck straight onto the
// wagon) has no GRN and never will — loadBooking's GRN gate would keep
// rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage
// acceptance sheet is the handover document instead, then loads in one click.
const [truckToTrainPending, setTruckToTrainPending] = useState<string | null>(null);
const doTruckToTrain = (bookingId: string, ref: string) => {
setTruckToTrainPending(bookingId);
bookingsService
.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN")
.then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId }))
.then(() => {
toast({ title: `${ref} loaded — direct truck-to-train handover` });
onChanged();
void yardWorkQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not load as direct truck-to-train",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
)
.finally(() => setTruckToTrainPending(null));
};
const doUnload = (bookingId: string, ref: string) => {
unloadJourney
.mutateAsync({ scheduleId: schedule.id, bookingId })
@@ -710,6 +737,12 @@ export function ScheduleWorkspacePanel({
const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId;
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
const showUnload = canWork && riding && (journey?.canUnload ?? false);
const showTruckToTrain =
canWork &&
!riding &&
!done &&
boardHere &&
b.tradeDirection === "EXPORT";
return (
<BookingCard
key={b.id}
@@ -767,6 +800,24 @@ export function ScheduleWorkspacePanel({
</Button>
</Tooltip>
) : null}
{showTruckToTrain ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
withArrow
>
<Button
size="compact-sm"
variant="light"
color="blue"
radius="md"
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() => doTruckToTrain(b.id, ref)}
>
Truck to Train
</Button>
</Tooltip>
) : null}
{showUnload ? (
<Tooltip
label={

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { logoSettingsService } from "@/services/logoSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["logoSettings"];
export const useLogoSettingsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => logoSettingsService.get(),
});
export const useSetLogo = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (logoImageBase64: string) =>
logoSettingsService.set(logoImageBase64),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("logoSettings.updated", "Company logo updated"));
},
onError: handleError,
});
};
export const useClearLogo = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: () => logoSettingsService.clear(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("logoSettings.cleared", "Company logo removed"));
},
onError: handleError,
});
};

View File

@@ -334,6 +334,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// The ONE company logo, applied to every generated document (invoices,
// receipts, contracts, warehouse papers, train-scheduling manifests).
logo: {
view: "edr_freight_app:settings:logo:view",
manage: "edr_freight_app:settings:logo:manage",
},
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
// gate the company stamp before the two were untangled.

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Save, Trash2 } from "lucide-react";
import { LogoUpload } from "@/components/contracts/LogoUpload";
import {
useClearLogo,
useSetLogo,
useLogoSettingsQuery,
} from "@/hooks/useLogoSettings";
/**
* The ONE company logo, read by every document path server-side via
* LogoSettingsService: invoices and receipts, contract cover pages, warehouse
* GRN/release/handover papers, train-scheduling manifests, and the payment
* receipt. Single global image — no per-document choice.
*/
export default function LogoSettingsPage() {
const { data, isLoading } = useLogoSettingsQuery();
const setLogo = useSetLogo();
const clearLogo = useClearLogo();
const [draft, setDraft] = useState<string | null>(null);
useEffect(() => {
setDraft(null);
}, [data?.logoImageUrl]);
const value = draft !== null ? draft : (data?.logoImageUrl ?? null);
const dirty = draft !== null && draft !== data?.logoImageUrl;
const handleSave = async () => {
if (!draft) return;
await setLogo.mutateAsync(draft);
};
const handleClear = async () => {
if (!data?.logoImageUrl) return;
await clearLogo.mutateAsync();
};
return (
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Company logo</CardTitle>
<CardDescription>
The single EDR logo, applied to every generated document
invoices and receipts, contracts, warehouse papers, train-scheduling
manifests, and payment receipts. Replacing it here changes it
everywhere at once.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<LogoUpload
value={isLoading ? null : value}
onChange={setDraft}
label="Company logo"
description="Shown in the header of every generated document."
/>
<div className="flex items-center gap-2">
<Button onClick={handleSave} disabled={!dirty || setLogo.isPending}>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{data?.logoImageUrl && !dirty && (
<Button
variant="outline"
onClick={handleClear}
disabled={clearLogo.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = "/logo-settings";
/** Company logo used on every generated document. */
export interface LogoSettings {
logoImageUrl: string | null;
updatedById: string | null;
updatedAt: string | null;
}
export const logoSettingsService = {
get: async (): Promise<LogoSettings> => {
const response = await client.get<ApiResponse<LogoSettings>>(BASE);
return unwrap(response.data);
},
set: async (logoImageBase64: string): Promise<LogoSettings> => {
const response = await client.put<ApiResponse<LogoSettings>>(BASE, {
logoImageBase64,
});
return unwrap(response.data);
},
clear: async (): Promise<LogoSettings> => {
const response = await client.delete<ApiResponse<LogoSettings>>(BASE);
return unwrap(response.data);
},
};

View File

@@ -224,6 +224,7 @@ export const URL_CONSTANTS = {
},
LAST_MILE_REQUESTS: {
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,

View File

@@ -1,5 +1,6 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
@@ -8,6 +9,7 @@ import type {
MileLegSummary,
MileVehicleSummary,
} from "@/services/bookings.service";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import { CardTitle, SectionCard } from "./layout";
@@ -171,12 +173,85 @@ function LegBlock({
);
}
/**
* Reference row for the stored last-mile contract: signed status, open the
* contract page (view / sign), download the PDF.
*/
function LastMileContractRow({
bookingId,
requestId,
signedAt,
signerDisplayName,
}: {
bookingId: string;
requestId: string;
signedAt?: string | null;
signerDisplayName?: string | null;
}) {
const navigate = useNavigate();
const download = async () => {
const blob = await lastMileRequestsService.downloadContractDocument(requestId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "last-mile-contract.pdf";
a.click();
URL.revokeObjectURL(url);
};
return (
<Group
justify="space-between"
align="center"
wrap="wrap"
pt={8}
mt={4}
style={{ borderTop: "1px solid #F2F5F8" }}
>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F">
Last-mile contract
</Text>
<Text fz="12px" c={signedAt ? "#0A6F4D" : "#B45309"}>
{signedAt
? `Signed ${new Date(signedAt).toLocaleDateString()}${
signerDisplayName ? ` by ${signerDisplayName}` : ""
}`
: "Awaiting your signature"}
</Text>
</Stack>
<Group gap={8}>
<Button
size="xs"
variant="light"
onClick={() =>
navigate(`/bookings/${bookingId}/last-mile-contract?requestId=${requestId}`)
}
>
{signedAt ? "View contract" : "View & sign"}
</Button>
<Button size="xs" variant="default" onClick={() => void download()}>
Download PDF
</Button>
</Group>
</Group>
);
}
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
const { data } = useQuery({
queryKey: ["booking-mile-summary", booking.id],
queryFn: () => bookingsService.mileSummary(booking.id),
});
// The stored LM contract lives on the booking's approved last-mile request.
const { data: lmRequests } = useQuery({
queryKey: ["booking-last-mile-requests", booking.id],
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
enabled: !!booking.lastMileDeliveryAddress,
});
const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED");
const firstLeg = data?.firstMile ?? null;
const lastLeg = data?.lastMile ?? null;
@@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
/>
)}
{showLast && (
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
<Box>
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
{approvedRequest && (
<LastMileContractRow
bookingId={booking.id}
requestId={approvedRequest.id}
signedAt={approvedRequest.customerSignedAt}
signerDisplayName={approvedRequest.signerDisplayName}
/>
)}
</Box>
)}
</Stack>
</SectionCard>

View File

@@ -12,6 +12,7 @@ export interface LastMileRequest {
requestedContainerNumbers?: string[] | null;
requestedDeliveryDate?: string | null;
customerSignedAt?: string | null;
signerDisplayName?: string | null;
rejectionReason?: string | null;
createdAt: string;
updatedAt: string;
@@ -46,6 +47,12 @@ export const lastMileRequestsService = {
return data.data ?? data;
},
/** The booking's requests, newest first — links the stored LM contract. */
listForBooking: async (bookingId: string): Promise<LastMileRequest[]> => {
const { data } = await client.get(L.BY_BOOKING(bookingId));
return data.data ?? data;
},
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
submit: async (
id: string,