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

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

View File

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

View File

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

View File

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