mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +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 { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
|
import { Box, Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
|
||||||
import { FileUp, Upload } from "lucide-react";
|
import { Eye, FileText, FileUp, Upload } from "lucide-react";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { useUploadGlDocuments } from "@/hooks/contracts/useContracts";
|
import { useUploadGlDocuments } from "@/hooks/contracts/useContracts";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { ActionShell } from "./ActionShell";
|
import { ActionShell } from "./ActionShell";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +25,7 @@ export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
|
|||||||
const upload = useUploadGlDocuments(bookingId);
|
const upload = useUploadGlDocuments(bookingId);
|
||||||
const [slot, setSlot] = useState<string | null>(GL_DOC_SLOTS[0].value);
|
const [slot, setSlot] = useState<string | null>(GL_DOC_SLOTS[0].value);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!slot || !file) return;
|
if (!slot || !file) return;
|
||||||
@@ -69,12 +72,108 @@ export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
|
|||||||
Upload
|
Upload
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
{!file ? (
|
{file ? (
|
||||||
|
<StagedFilePreview file={file} onPreview={view} />
|
||||||
|
) : (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
PDF or image. The matching milestone completes on upload.
|
PDF or image. The matching milestone completes on upload.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{viewer}
|
||||||
</ActionShell>
|
</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 : "";
|
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
|
||||||
return;
|
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;
|
values[field.name] = raw;
|
||||||
});
|
});
|
||||||
return values;
|
return values;
|
||||||
@@ -127,6 +137,15 @@ const FleetFormDialog = ({
|
|||||||
return Object.keys(next).length === 0;
|
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 = () => {
|
const handleSubmit = () => {
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
const payload = Object.fromEntries(
|
const payload = Object.fromEntries(
|
||||||
@@ -134,6 +153,10 @@ const FleetFormDialog = ({
|
|||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
if (value === FLEET_SELECT_NONE || value === "")
|
if (value === FLEET_SELECT_NONE || value === "")
|
||||||
return [key, undefined];
|
return [key, undefined];
|
||||||
|
if (fieldTypeByName[key] === "number") {
|
||||||
|
const num = Number(value);
|
||||||
|
return [key, Number.isNaN(num) ? undefined : num];
|
||||||
|
}
|
||||||
return [key, value];
|
return [key, value];
|
||||||
})
|
})
|
||||||
.filter(([, value]) => value !== undefined),
|
.filter(([, value]) => value !== undefined),
|
||||||
|
|||||||
@@ -20,14 +20,11 @@ export const formatFleetCell = (
|
|||||||
format?: FleetColumnFormat,
|
format?: FleetColumnFormat,
|
||||||
accessorKey?: string,
|
accessorKey?: string,
|
||||||
): ReactNode => {
|
): ReactNode => {
|
||||||
console.log('formatFleetCell:', { value, format, accessorKey, type: typeof value });
|
|
||||||
|
|
||||||
if (format === "statusBadge") {
|
if (format === "statusBadge") {
|
||||||
const status = value == null || value === "" ? "—" : String(value);
|
const status = value == null || value === "" ? "—" : String(value);
|
||||||
const getStatusColor = (st: string): string => {
|
const getStatusColor = (st: string): string => {
|
||||||
const s = st.toUpperCase();
|
const s = st.toUpperCase();
|
||||||
console.log('Status for color mapping:', s);
|
if (s === "ACTIVE" || s === "AVAILABLE") return "green";
|
||||||
if (s === "ACTIVE") return "green";
|
|
||||||
if (s === "INACTIVE") return "gray";
|
if (s === "INACTIVE") return "gray";
|
||||||
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
|
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
|
||||||
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
|
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
|
||||||
@@ -35,7 +32,6 @@ export const formatFleetCell = (
|
|||||||
return "gray";
|
return "gray";
|
||||||
};
|
};
|
||||||
const color = getStatusColor(status);
|
const color = getStatusColor(status);
|
||||||
console.log('Assigned color:', color, 'for status:', status);
|
|
||||||
return (
|
return (
|
||||||
<Badge variant="light" color={color} size="sm" radius="md">
|
<Badge variant="light" color={color} size="sm" radius="md">
|
||||||
{status}
|
{status}
|
||||||
@@ -50,7 +46,5 @@ export const formatFleetCell = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = formatRuleEngineCell(value, format as ColumnFormat | undefined);
|
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
|
||||||
console.log('formatRuleEngineCell result for', accessorKey, ':', result);
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -301,9 +301,7 @@ export function ContractClearancePanel({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{pending[doc.fileKey] && (
|
{pending[doc.fileKey] && (
|
||||||
<Text fz="12px" c={GREEN} mt={6}>
|
<StagedFilePreview file={pending[doc.fileKey]} onPreview={view} />
|
||||||
Ready to upload: {pending[doc.fileKey].name}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -383,35 +381,40 @@ export function ContractClearancePanel({
|
|||||||
</Group>
|
</Group>
|
||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
{adHoc.map((row, i) => (
|
{adHoc.map((row, i) => (
|
||||||
<Group key={i} gap={8} wrap="nowrap">
|
<Box key={i}>
|
||||||
<TextInput
|
<Group gap={8} wrap="nowrap">
|
||||||
placeholder="Document name"
|
<TextInput
|
||||||
value={row.name}
|
placeholder="Document name"
|
||||||
onChange={(e) =>
|
value={row.name}
|
||||||
setAdHoc((rows) =>
|
onChange={(e) =>
|
||||||
rows.map((r, j) =>
|
setAdHoc((rows) =>
|
||||||
j === i ? { ...r, name: e.currentTarget.value } : r,
|
rows.map((r, j) =>
|
||||||
),
|
j === i ? { ...r, name: e.currentTarget.value } : r,
|
||||||
)
|
),
|
||||||
}
|
)
|
||||||
style={{ flex: 1 }}
|
}
|
||||||
radius="md"
|
style={{ flex: 1 }}
|
||||||
/>
|
radius="md"
|
||||||
<FileButton
|
/>
|
||||||
onChange={(f) =>
|
<FileButton
|
||||||
setAdHoc((rows) =>
|
onChange={(f) =>
|
||||||
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
setAdHoc((rows) =>
|
||||||
)
|
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
||||||
}
|
)
|
||||||
accept="application/pdf,image/*"
|
}
|
||||||
>
|
accept="application/pdf,image/*"
|
||||||
{(props) => (
|
>
|
||||||
<Button {...props} variant="default" radius="md">
|
{(props) => (
|
||||||
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
<Button {...props} variant="default" radius="md">
|
||||||
</Button>
|
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
||||||
)}
|
</Button>
|
||||||
</FileButton>
|
)}
|
||||||
</Group>
|
</FileButton>
|
||||||
|
</Group>
|
||||||
|
{row.file && (
|
||||||
|
<StagedFilePreview file={row.file} onPreview={view} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</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;
|
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 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 { useEffect, useMemo, useRef } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
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";
|
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<
|
type ContractForm = UseFormReturn<
|
||||||
ContractFormInputValues,
|
ContractFormInputValues,
|
||||||
any,
|
any,
|
||||||
@@ -69,47 +296,30 @@ export function Step2ServiceType({
|
|||||||
const showServiceSections =
|
const showServiceSections =
|
||||||
serviceType != null || includesFirstMile || includesLastMile;
|
serviceType != null || includesFirstMile || includesLastMile;
|
||||||
|
|
||||||
const serviceOptions = useMemo(
|
const standaloneServices = useMemo(
|
||||||
() =>
|
() => (referenceData?.service ?? []).filter((s) => s.canBeBookedAlone),
|
||||||
(referenceData?.service ?? [])
|
|
||||||
.filter((s) => s.canBeBookedAlone)
|
|
||||||
.map((s) => ({ value: s.id, label: s.serviceName })),
|
|
||||||
[referenceData],
|
[referenceData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={16}>
|
<Stack gap={18}>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Controller
|
||||||
<Controller
|
name="serviceTypeId"
|
||||||
name="serviceTypeId"
|
control={form.control}
|
||||||
control={form.control}
|
render={({ field, fieldState }) => (
|
||||||
render={({ field, fieldState }) => (
|
<ServiceTypeSelector
|
||||||
<div>
|
services={standaloneServices}
|
||||||
<Select
|
value={field.value || null}
|
||||||
label="Service Type *"
|
onChange={(id) => field.onChange(id)}
|
||||||
placeholder="Select a service…"
|
onBlur={field.onBlur}
|
||||||
data={serviceOptions}
|
error={fieldState.error?.message}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<Box maw={420}>
|
||||||
<PaymentCurrencyField control={form.control} />
|
<PaymentCurrencyField control={form.control} />
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{showServiceSections && (
|
{showServiceSections && (
|
||||||
<Stack gap={12}>
|
<Stack gap={12}>
|
||||||
@@ -304,16 +514,16 @@ export function Step2ServiceType({
|
|||||||
color: "#0A6F4D",
|
color: "#0A6F4D",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileText size={18} />
|
<FileCheck2 size={18} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={14} fw={700} c="#10202F">
|
<Text fz={14} fw={700} c="#10202F">
|
||||||
Customs Clearing Service
|
Customs Clearing Service
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||||
Included with this service. After signing you will upload
|
Included with this service. After signing, upload your
|
||||||
clearance documents and Global Logistics will handle the
|
clearance documents — Global Logistics reviews them, then you
|
||||||
booking.
|
create the booking.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{ flexShrink: 0, marginLeft: "auto" }}>
|
<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 {
|
import {
|
||||||
IFileUploadSetting,
|
IFileUploadSetting,
|
||||||
IFileUploadField,
|
IFileUploadField,
|
||||||
@@ -11,9 +11,15 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
|
import {
|
||||||
|
FileViewerModal,
|
||||||
|
isViewable,
|
||||||
|
type ViewableFile,
|
||||||
|
} from "../FileViewer";
|
||||||
|
|
||||||
export interface SmartFileInputProps {
|
export interface SmartFileInputProps {
|
||||||
/** The settings object containing features and their upload fields config. */
|
/** 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
|
// File input refs for programmatic clicks in minimal variant
|
||||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
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)
|
// Memoize fields sorted by the order property (ascending)
|
||||||
const sortedFields = useMemo(() => {
|
const sortedFields = useMemo(() => {
|
||||||
return [...file.fields].sort((a, b) => a.order - b.order);
|
return [...file.fields].sort((a, b) => a.order - b.order);
|
||||||
@@ -286,55 +329,95 @@ export function SmartFileInput({
|
|||||||
{/* Selected Files List */}
|
{/* Selected Files List */}
|
||||||
{currentFiles.length > 0 && (
|
{currentFiles.length > 0 && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{currentFiles.map((fileObj, idx) => (
|
{currentFiles.map((fileObj, idx) => {
|
||||||
<div
|
const showThumb = isImage(fileObj);
|
||||||
key={`${fileObj.name}-${idx}`}
|
const canPreview =
|
||||||
className={cn(
|
isViewable({ name: fileObj.name, mimeType: fileObj.type, url: "" });
|
||||||
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
|
return (
|
||||||
fieldError ? "border-destructive/30" : "border-border"
|
<div
|
||||||
)}
|
key={`${fileObj.name}-${idx}`}
|
||||||
>
|
className={cn(
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
"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",
|
||||||
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
|
fieldError ? "border-destructive/30" : "border-border"
|
||||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
)}
|
||||||
</div>
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="min-w-0">
|
{/* Thumbnail (images) or a tinted type tile */}
|
||||||
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
|
{showThumb ? (
|
||||||
{fileObj.name}
|
<button
|
||||||
</p>
|
type="button"
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
onClick={() => openPreview(fileObj)}
|
||||||
<span className="text-xs text-muted-foreground">
|
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"
|
||||||
{formatBytes(fileObj.size)}
|
aria-label={`Preview ${fileObj.name}`}
|
||||||
</span>
|
>
|
||||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
<img
|
||||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
src={urlForFile(fileObj)}
|
||||||
</span>
|
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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
type="button"
|
{canPreview && (
|
||||||
disabled={disabled}
|
<button
|
||||||
onClick={() => removeFile(field.fileKey, idx)}
|
type="button"
|
||||||
className={cn(
|
onClick={() => openPreview(fileObj)}
|
||||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
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"
|
||||||
disabled && "opacity-50 pointer-events-none"
|
aria-label={`Preview ${fileObj.name}`}
|
||||||
)}
|
>
|
||||||
aria-label={`Remove file ${fileObj.name}`}
|
<Eye className="h-3.5 w-3.5" />
|
||||||
>
|
<span className="hidden sm:inline">Preview</span>
|
||||||
<Trash2 className="h-4 w-4" />
|
</button>
|
||||||
</button>
|
)}
|
||||||
|
<button
|
||||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
type="button"
|
||||||
<input
|
disabled={disabled}
|
||||||
type="hidden"
|
onClick={() => removeFile(field.fileKey, idx)}
|
||||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
className={cn(
|
||||||
value={fileObj.name}
|
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||||
/>
|
disabled && "opacity-50 pointer-events-none"
|
||||||
</div>
|
)}
|
||||||
))}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -419,6 +502,12 @@ export function SmartFileInput({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<FileViewerModal
|
||||||
|
open={previewFile !== null}
|
||||||
|
file={previewFile}
|
||||||
|
onClose={() => setPreviewFile(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user