mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useRef } from "react";
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
IFileUploadSetting,
|
||||
IFileUploadField,
|
||||
@@ -11,9 +11,15 @@ import {
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../button";
|
||||
import {
|
||||
FileViewerModal,
|
||||
isViewable,
|
||||
type ViewableFile,
|
||||
} from "../FileViewer";
|
||||
|
||||
export interface SmartFileInputProps {
|
||||
/** The settings object containing features and their upload fields config. */
|
||||
@@ -86,6 +92,43 @@ export function SmartFileInput({
|
||||
// File input refs for programmatic clicks in minimal variant
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
// The staged file currently open in the preview modal.
|
||||
const [previewFile, setPreviewFile] = useState<ViewableFile | null>(null);
|
||||
|
||||
// Object URLs minted for staged File objects (keyed by File identity) so the
|
||||
// preview modal + image thumbnails can render local files. Revoked on unmount.
|
||||
const objectUrls = useRef(new Map<File, string>());
|
||||
const urlForFile = useCallback((fileObj: File): string => {
|
||||
const cache = objectUrls.current;
|
||||
let url = cache.get(fileObj);
|
||||
if (!url) {
|
||||
url = URL.createObjectURL(fileObj);
|
||||
cache.set(fileObj, url);
|
||||
}
|
||||
return url;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const cache = objectUrls.current;
|
||||
return () => {
|
||||
cache.forEach((url) => URL.revokeObjectURL(url));
|
||||
cache.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPreview = (fileObj: File) => {
|
||||
setPreviewFile({
|
||||
name: fileObj.name,
|
||||
url: urlForFile(fileObj),
|
||||
mimeType: fileObj.type || null,
|
||||
});
|
||||
};
|
||||
|
||||
const isImage = (fileObj: File) =>
|
||||
fileObj.type.startsWith("image/") ||
|
||||
["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(
|
||||
fileObj.name.split(".").pop()?.toLowerCase() ?? "",
|
||||
);
|
||||
|
||||
// Memoize fields sorted by the order property (ascending)
|
||||
const sortedFields = useMemo(() => {
|
||||
return [...file.fields].sort((a, b) => a.order - b.order);
|
||||
@@ -286,55 +329,95 @@ export function SmartFileInput({
|
||||
{/* Selected Files List */}
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{currentFiles.map((fileObj, idx) => (
|
||||
<div
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
|
||||
fieldError ? "border-destructive/30" : "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
|
||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||
</span>
|
||||
{currentFiles.map((fileObj, idx) => {
|
||||
const showThumb = isImage(fileObj);
|
||||
const canPreview =
|
||||
isViewable({ name: fileObj.name, mimeType: fileObj.type, url: "" });
|
||||
return (
|
||||
<div
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
className={cn(
|
||||
"group/file flex items-center justify-between gap-3 p-2.5 pr-3 rounded-xl border bg-card transition-all duration-200 shadow-2xs hover:shadow-md hover:border-primary/40",
|
||||
fieldError ? "border-destructive/30" : "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Thumbnail (images) or a tinted type tile */}
|
||||
{showThumb ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPreview(fileObj)}
|
||||
className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-border bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={`Preview ${fileObj.name}`}
|
||||
>
|
||||
<img
|
||||
src={urlForFile(fileObj)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-200 group-hover/file:scale-105"
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all duration-200 group-hover/file:bg-black/35 group-hover/file:opacity-100">
|
||||
<Eye className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-sm font-medium text-foreground truncate max-w-[180px] md:max-w-md"
|
||||
title={fileObj.name}
|
||||
>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => removeFile(field.fileKey, idx)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
||||
value={fileObj.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPreview(fileObj)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
|
||||
aria-label={`Preview ${fileObj.name}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Preview</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => removeFile(field.fileKey, idx)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
||||
value={fileObj.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -419,6 +502,12 @@ export function SmartFileInput({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<FileViewerModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user