Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat

This commit is contained in:
marshal
2026-06-29 02:40:02 +03:00
6 changed files with 647 additions and 134 deletions

View File

@@ -1,8 +1,10 @@
import { useState } from "react";
import { Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
import { FileUp, Upload } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Box, Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
import { Eye, FileText, FileUp, Upload } from "lucide-react";
import { isViewable } from "@edr/ui-common";
import { useUploadGlDocuments } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer";
import { ActionShell } from "./ActionShell";
/**
@@ -23,6 +25,7 @@ export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
const upload = useUploadGlDocuments(bookingId);
const [slot, setSlot] = useState<string | null>(GL_DOC_SLOTS[0].value);
const [file, setFile] = useState<File | null>(null);
const { view, viewer } = useFileViewer();
const submit = () => {
if (!slot || !file) return;
@@ -69,12 +72,108 @@ export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
Upload
</Button>
</Group>
{!file ? (
{file ? (
<StagedFilePreview file={file} onPreview={view} />
) : (
<Text size="xs" c="dimmed">
PDF or image. The matching milestone completes on upload.
</Text>
) : null}
)}
</Stack>
{viewer}
</ActionShell>
);
}
/**
* A compact preview chip for the GL file staged for upload: an image thumbnail
* (or a glyph) and a Preview button that opens the file in the shared viewer via
* a local object URL (minted once, revoked on unmount).
*/
function StagedFilePreview({
file,
onPreview,
}: {
file: File;
onPreview: (f: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const url = useMemo(() => URL.createObjectURL(file), [file]);
useEffect(() => () => URL.revokeObjectURL(url), [url]);
const isImage =
file.type.startsWith("image/") ||
["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(
file.name.split(".").pop()?.toLowerCase() ?? "",
);
const canPreview = isViewable({ name: file.name, url, mimeType: file.type });
return (
<Group
gap={10}
wrap="nowrap"
p={8}
style={{
borderRadius: 10,
border: "1px dashed var(--mantine-color-edr-green-5)",
background: "var(--mantine-color-edr-green-0)",
minWidth: 0,
}}
>
{isImage ? (
<Box
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 8,
overflow: "hidden",
cursor: "pointer",
border: "1px solid var(--mantine-color-gray-3)",
}}
>
<img
src={url}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</Box>
) : (
<Box
c="edr-green"
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-edr-green-1)",
}}
>
<FileText size={17} />
</Box>
)}
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" fw={700} c="edr-green">
Ready to upload
</Text>
<Text size="xs" truncate>
{file.name}
</Text>
</Box>
{canPreview && (
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
leftSection={<Eye size={13} />}
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
>
Preview
</Button>
)}
</Group>
);
}

View File

@@ -47,6 +47,16 @@ const buildInitialValues = (
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
return;
}
// For selects, snap the record value onto a real option even if its casing
// drifted (e.g. an API/seed value of "Available" vs the "AVAILABLE" option).
// Otherwise the Select renders blank and a required field fails on submit.
if (field.type === "select" && field.options?.length) {
const match = field.options.find(
(o) => String(o.value).toLowerCase() === String(raw).toLowerCase(),
);
values[field.name] = match ? match.value : raw;
return;
}
values[field.name] = raw;
});
return values;
@@ -127,6 +137,15 @@ const FleetFormDialog = ({
return Object.keys(next).length === 0;
};
// Field types keyed by name, so the submit payload can coerce each value to the
// type the API expects (number columns come back from the API as strings like
// "24.00", which the DTO's @IsNumber rejects on an otherwise-unchanged save).
const fieldTypeByName = useMemo(() => {
const map: Record<string, FleetFormFieldDef["type"]> = {};
fields.forEach((f) => (map[f.name] = f.type));
return map;
}, [fields]);
const handleSubmit = () => {
if (!validate()) return;
const payload = Object.fromEntries(
@@ -134,6 +153,10 @@ const FleetFormDialog = ({
.map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "")
return [key, undefined];
if (fieldTypeByName[key] === "number") {
const num = Number(value);
return [key, Number.isNaN(num) ? undefined : num];
}
return [key, value];
})
.filter(([, value]) => value !== undefined),

View File

@@ -20,14 +20,11 @@ export const formatFleetCell = (
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
console.log('formatFleetCell:', { value, format, accessorKey, type: typeof value });
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
const getStatusColor = (st: string): string => {
const s = st.toUpperCase();
console.log('Status for color mapping:', s);
if (s === "ACTIVE") return "green";
if (s === "ACTIVE" || s === "AVAILABLE") return "green";
if (s === "INACTIVE") return "gray";
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
@@ -35,7 +32,6 @@ export const formatFleetCell = (
return "gray";
};
const color = getStatusColor(status);
console.log('Assigned color:', color, 'for status:', status);
return (
<Badge variant="light" color={color} size="sm" radius="md">
{status}
@@ -50,7 +46,5 @@ export const formatFleetCell = (
}
}
const result = formatRuleEngineCell(value, format as ColumnFormat | undefined);
console.log('formatRuleEngineCell result for', accessorKey, ':', result);
return result;
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
};

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -301,9 +301,7 @@ export function ContractClearancePanel({
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
<StagedFilePreview file={pending[doc.fileKey]} onPreview={view} />
)}
</Box>
))}
@@ -383,35 +381,40 @@ export function ContractClearancePanel({
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i ? { ...r, name: e.currentTarget.value } : r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
<Box key={i}>
<Group gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i ? { ...r, name: e.currentTarget.value } : r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
{row.file && (
<StagedFilePreview file={row.file} onPreview={view} />
)}
</Box>
))}
</Stack>
</Box>
@@ -466,4 +469,99 @@ export function ContractClearancePanel({
);
}
/**
* A compact preview chip for a locally-staged (not-yet-uploaded) clearance file.
* Shows an image thumbnail (or a file glyph) plus a Preview button that opens the
* file in the shared viewer via a local object URL. The URL is minted once per
* File and revoked on unmount.
*/
function StagedFilePreview({
file,
onPreview,
}: {
file: File;
onPreview: (f: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const url = useMemo(() => URL.createObjectURL(file), [file]);
useEffect(() => () => URL.revokeObjectURL(url), [url]);
const isImage =
file.type.startsWith("image/") ||
["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(
file.name.split(".").pop()?.toLowerCase() ?? "",
);
const canPreview = isViewable({ name: file.name, url, mimeType: file.type });
return (
<Group
gap={10}
wrap="nowrap"
mt={8}
p={8}
style={{
borderRadius: 12,
border: `1px dashed ${GREEN}`,
background: "#F2FBF6",
minWidth: 0,
}}
>
{isImage ? (
<Box
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
style={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 8,
overflow: "hidden",
border: `1px solid ${BORDER}`,
cursor: "pointer",
}}
>
<img
src={url}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</Box>
) : (
<Box
style={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#E7F6EE",
color: GREEN,
}}
>
<FileText size={18} />
</Box>
)}
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="12px" fw={700} c={GREEN}>
Ready to upload
</Text>
<Text fz="12px" c="#10202F" truncate>
{file.name}
</Text>
</Box>
{canPreview && (
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
leftSection={<Eye size={13} />}
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
>
Preview
</Button>
)}
</Group>
);
}
export default ContractClearancePanel;

View File

@@ -1,6 +1,17 @@
import { Box, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Info, Truck } from "lucide-react";
import {
Check,
Container,
FileCheck2,
FileText,
Info,
PackageCheck,
ShieldCheck,
Sparkles,
TrainFront,
Truck,
} from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { ContractFormInputValues, type ContractFormValues } from "./schema";
@@ -10,6 +21,222 @@ import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker
import type { Freight } from "@edr/types";
const GREEN = "#0A6F4D";
const GREEN_SOFT = "#F6FBF8";
const GREEN_RING = "#CDEBDD";
const BORDER = "#E6ECF2";
const INK = "#10202F";
const MUTED = "#6B7C8E";
type ServiceItem = Freight.BookingReferenceData["service"][number];
/** Feature chips describing what a service bundles, derived from its flags. */
function serviceFeatures(s: ServiceItem) {
return [
{ key: "rail", icon: TrainFront, label: "Rail haulage", on: true },
{
key: "first",
icon: Truck,
label: "First mile pick-up",
on: s.includesFirstMile,
},
{
key: "last",
icon: PackageCheck,
label: "Last mile delivery",
on: s.includesLastMile,
},
{
key: "customs",
icon: ShieldCheck,
label: "Customs clearance",
on: s.includesCustoms,
},
];
}
function FeatureChip({
icon: Icon,
label,
on,
}: {
icon: typeof TrainFront;
label: string;
on: boolean;
}) {
return (
<Group
gap={5}
wrap="nowrap"
px={9}
py={4}
style={{
borderRadius: 999,
background: on ? "#ECF6F1" : "#F4F6F8",
border: `1px solid ${on ? GREEN_RING : "#EDF1F5"}`,
opacity: on ? 1 : 0.55,
}}
>
<Icon size={12} color={on ? GREEN : "#9AA8B5"} strokeWidth={2.2} />
<Text fz={11} fw={600} c={on ? GREEN : "#9AA8B5"} style={{ lineHeight: 1 }}>
{label}
</Text>
</Group>
);
}
/**
* Premium card picker for the contract service type — replaces the plain
* dropdown. Each selectable card shows the service name, description, and the
* bundle it includes (rail / first mile / last mile / customs) as chips, with a
* green ring + check on the active choice.
*/
function ServiceTypeSelector({
services,
value,
onChange,
onBlur,
error,
}: {
services: ServiceItem[];
value: string | null;
onChange: (id: string) => void;
onBlur: () => void;
error?: string;
}) {
return (
<Stack gap={8}>
<Group justify="space-between" align="baseline">
<Text fz={13} fw={600} c={INK}>
Service Type <span style={{ color: "#E11D48" }}>*</span>
</Text>
{error ? (
<Text fz={12} c="#E11D48" fw={600}>
{error}
</Text>
) : null}
</Group>
{services.length === 0 ? (
<Box
px={16}
py={20}
style={{
borderRadius: 14,
border: `1.5px dashed ${BORDER}`,
background: "#FAFBFC",
textAlign: "center",
}}
>
<Text fz={13} c={MUTED}>
No standalone services are available right now.
</Text>
</Box>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{services.map((s) => {
const selected = s.id === value;
const hasBonus = (s.priorityBonusPoints ?? 0) > 0;
return (
<button
key={s.id}
type="button"
onClick={() => {
onChange(s.id);
onBlur();
}}
style={{
position: "relative",
textAlign: "left",
cursor: "pointer",
padding: 16,
borderRadius: 16,
border: `1.5px solid ${selected ? GREEN : error ? "#F0B4B4" : BORDER}`,
background: selected ? GREEN_SOFT : "#fff",
boxShadow: selected
? "0 8px 24px -12px rgba(10,111,77,0.45)"
: "0 1px 2px rgba(16,32,47,0.04)",
transition: "all 160ms cubic-bezier(0.4,0,0.2,1)",
outline: "none",
}}
>
{/* Selected check */}
<Box
style={{
position: "absolute",
top: 12,
right: 12,
width: 22,
height: 22,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: selected ? GREEN : "transparent",
border: selected ? "none" : `1.5px solid ${BORDER}`,
transition: "all 160ms ease",
}}
>
{selected ? <Check size={13} color="#fff" strokeWidth={3} /> : null}
</Box>
<Group gap={11} align="flex-start" wrap="nowrap" mb={10}>
<Box
style={{
width: 42,
height: 42,
flexShrink: 0,
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: selected ? "#ECF6F1" : "#F1F4F7",
color: selected ? GREEN : "#64748B",
transition: "all 160ms ease",
}}
>
<Container size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0, paddingRight: 24 }}>
<Text fz={14.5} fw={750} c={INK} style={{ lineHeight: 1.25 }}>
{s.serviceName}
</Text>
{hasBonus ? (
<Group gap={4} mt={3} wrap="nowrap">
<Sparkles size={11} color="#B26C09" />
<Text fz={11} fw={700} c="#B26C09">
Priority service
</Text>
</Group>
) : null}
</Box>
</Group>
{s.description ? (
<Text fz={12.5} c={MUTED} mb={11} style={{ lineHeight: 1.45 }}>
{s.description}
</Text>
) : null}
<Group gap={6} wrap="wrap">
{serviceFeatures(s).map((f) => (
<FeatureChip
key={f.key}
icon={f.icon}
label={f.label}
on={f.on}
/>
))}
</Group>
</button>
);
})}
</div>
)}
</Stack>
);
}
type ContractForm = UseFormReturn<
ContractFormInputValues,
any,
@@ -69,47 +296,30 @@ export function Step2ServiceType({
const showServiceSections =
serviceType != null || includesFirstMile || includesLastMile;
const serviceOptions = useMemo(
() =>
(referenceData?.service ?? [])
.filter((s) => s.canBeBookedAlone)
.map((s) => ({ value: s.id, label: s.serviceName })),
const standaloneServices = useMemo(
() => (referenceData?.service ?? []).filter((s) => s.canBeBookedAlone),
[referenceData],
);
return (
<Stack gap={16}>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="serviceTypeId"
control={form.control}
render={({ field, fieldState }) => (
<div>
<Select
label="Service Type *"
placeholder="Select a service…"
data={serviceOptions}
value={field.value || null}
onChange={(v) => v && field.onChange(v)}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
{serviceType?.description && (
<Text fz={12} c="#6B7C8E" mt={6}>
{serviceType.description}
</Text>
)}
</div>
)}
/>
<Stack gap={18}>
<Controller
name="serviceTypeId"
control={form.control}
render={({ field, fieldState }) => (
<ServiceTypeSelector
services={standaloneServices}
value={field.value || null}
onChange={(id) => field.onChange(id)}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
<Box maw={420}>
<PaymentCurrencyField control={form.control} />
</div>
</Box>
{showServiceSections && (
<Stack gap={12}>
@@ -304,16 +514,16 @@ export function Step2ServiceType({
color: "#0A6F4D",
}}
>
<FileText size={18} />
<FileCheck2 size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Service
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Included with this service. After signing you will upload
clearance documents and Global Logistics will handle the
booking.
Included with this service. After signing, upload your
clearance documents Global Logistics reviews them, then you
create the booking.
</Text>
</Box>
<Box style={{ flexShrink: 0, marginLeft: "auto" }}>