diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx index d4db86ce0..dbe03c025 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx @@ -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(GL_DOC_SLOTS[0].value); const [file, setFile] = useState(null); + const { view, viewer } = useFileViewer(); const submit = () => { if (!slot || !file) return; @@ -69,12 +72,108 @@ export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) { Upload - {!file ? ( + {file ? ( + + ) : ( PDF or image. The matching milestone completes on upload. - ) : null} + )} + {viewer} ); } + +/** + * 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 ( + + {isImage ? ( + 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)", + }} + > + + + ) : ( + + + + )} + + + Ready to upload + + + {file.name} + + + {canPreview && ( + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 83e711451..e1d1d4977 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -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 = {}; + 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), diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx index e07b2a49f..5c997af9f 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx @@ -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 ( {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); }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx index d4b530f5f..b78b155ba 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx @@ -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({ )} {pending[doc.fileKey] && ( - - Ready to upload: {pending[doc.fileKey].name} - + )} ))} @@ -383,35 +381,40 @@ export function ContractClearancePanel({ {adHoc.map((row, i) => ( - - - setAdHoc((rows) => - rows.map((r, j) => - j === i ? { ...r, name: e.currentTarget.value } : r, - ), - ) - } - style={{ flex: 1 }} - radius="md" - /> - - setAdHoc((rows) => - rows.map((r, j) => (j === i ? { ...r, file: f } : r)), - ) - } - accept="application/pdf,image/*" - > - {(props) => ( - - )} - - + + + + setAdHoc((rows) => + rows.map((r, j) => + j === i ? { ...r, name: e.currentTarget.value } : r, + ), + ) + } + style={{ flex: 1 }} + radius="md" + /> + + setAdHoc((rows) => + rows.map((r, j) => (j === i ? { ...r, file: f } : r)), + ) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + {row.file && ( + + )} + ))} @@ -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 ( + + {isImage ? ( + 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", + }} + > + + + ) : ( + + + + )} + + + Ready to upload + + + {file.name} + + + {canPreview && ( + + )} + + ); +} + export default ContractClearancePanel; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index 8b38c1beb..430d1239a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -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 ( + + + + {label} + + + ); +} + +/** + * 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 ( + + + + Service Type * + + {error ? ( + + {error} + + ) : null} + + + {services.length === 0 ? ( + + + No standalone services are available right now. + + + ) : ( +
+ {services.map((s) => { + const selected = s.id === value; + const hasBonus = (s.priorityBonusPoints ?? 0) > 0; + return ( + + ); + })} +
+ )} +
+ ); +} + 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 ( - -
- ( -
- -
- ))} +
+ {canPreview && ( + + )} + +
+ + {/* Hidden inputs to represent file details in traditional form submissions */} + +
+ ); + })} )} @@ -419,6 +502,12 @@ export function SmartFileInput({ ); })} + + setPreviewFile(null)} + /> ); }