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 { 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>
);
}

View File

@@ -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),

View File

@@ -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;
}; };

View File

@@ -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;

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 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" }}>

View File

@@ -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>
); );
} }