mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1364 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { MessageSquarePlus, Send } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
export interface AdditionalDocsRequestCardProps {
|
||||
bookingId: string;
|
||||
/** Past requests, newest first. */
|
||||
requests: Freight.ClearanceDocRequest[];
|
||||
/** False once the shipment is paid — documents (and requests) are closed. */
|
||||
canRequest: boolean;
|
||||
onSent?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GL asks the customer for additional clearance document(s) in plain words.
|
||||
* The message, its author and its time show on the customer's portal beside
|
||||
* the upload box, so the customer knows exactly what to send and who asked.
|
||||
*/
|
||||
export function AdditionalDocsRequestCard({
|
||||
bookingId,
|
||||
requests,
|
||||
canRequest,
|
||||
onSent,
|
||||
}: AdditionalDocsRequestCardProps) {
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: () => bookingsService.requestAdditionalDocuments(bookingId, note),
|
||||
onSuccess: () => {
|
||||
toast.success("Request sent to the customer");
|
||||
setNote("");
|
||||
onSent?.();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(extractErrorMessage(e, "Could not send the request")),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={MessageSquarePlus}
|
||||
title="Ask for a document"
|
||||
subtitle="The customer sees your message, who wrote it, and uploads the file from their portal."
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{canRequest ? (
|
||||
<Box>
|
||||
<Textarea
|
||||
placeholder="e.g. Please send the amended commercial invoice showing the revised unit price."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
radius="md"
|
||||
size="sm"
|
||||
/>
|
||||
<Group justify="flex-end" mt={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={14} />}
|
||||
loading={send.isPending}
|
||||
disabled={!note.trim()}
|
||||
onClick={() => send.mutate()}
|
||||
>
|
||||
Send request
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
) : (
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
This shipment is settled — document requests are closed.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{requests.length > 0 && (
|
||||
<Stack gap={8}>
|
||||
<Text fz="11px" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
|
||||
Sent requests
|
||||
</Text>
|
||||
{requests.map((r) => (
|
||||
<Paper key={r.id} withBorder radius="md" p="xs">
|
||||
<Text fz="12.5px" c="edr-text">
|
||||
{r.note}
|
||||
</Text>
|
||||
<Text fz="11px" c="dimmed" mt={4}>
|
||||
{r.byName ?? "Staff"} · {formatDateTime(r.at)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export function ClearanceChargesTab({
|
||||
onViewFile,
|
||||
}: ClearanceChargesTabProps) {
|
||||
const qc = useQueryClient();
|
||||
// Bumped after each create so the form remounts empty for the next charge.
|
||||
const [miscCreated, setMiscCreated] = useState(0);
|
||||
const { data: charges, isLoading } = useQuery({
|
||||
queryKey: ["clearance-charges", bookingId],
|
||||
queryFn: () => bookingsService.getClearanceCharges(bookingId),
|
||||
@@ -109,6 +111,8 @@ export function ClearanceChargesTab({
|
||||
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Miscellaneous charge created");
|
||||
// Remount the form so the next charge starts from an empty one.
|
||||
setMiscCreated((n) => n + 1);
|
||||
refresh(next);
|
||||
},
|
||||
onError,
|
||||
@@ -124,7 +128,9 @@ export function ClearanceChargesTab({
|
||||
}
|
||||
|
||||
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
|
||||
const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null;
|
||||
const miscCharges = (charges ?? []).filter(
|
||||
(c) => c.type === "MISCELLANEOUS",
|
||||
);
|
||||
const busy =
|
||||
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
|
||||
|
||||
@@ -137,7 +143,7 @@ export function ClearanceChargesTab({
|
||||
return (
|
||||
<Stack gap="md" maw={860}>
|
||||
<ChargeCard
|
||||
title="1 · Port charges"
|
||||
title="Port charges"
|
||||
charge={port}
|
||||
roleMode={roleMode}
|
||||
busy={busy}
|
||||
@@ -175,34 +181,48 @@ export function ClearanceChargesTab({
|
||||
}
|
||||
/>
|
||||
|
||||
<ChargeCard
|
||||
title="2 · Miscellaneous charges"
|
||||
charge={misc}
|
||||
roleMode={roleMode}
|
||||
busy={busy}
|
||||
emptyHint={
|
||||
port?.status !== "PAID"
|
||||
? "Unlocks once the port charge is paid."
|
||||
: roleMode === "ET"
|
||||
? "Create the miscellaneous charge with its document, amount and currency."
|
||||
: "GL Ethiopia creates this charge once the port charge is paid."
|
||||
}
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
misc && bill.mutate({ chargeId: misc.id, amount, currency })
|
||||
}
|
||||
onSend={() => misc && send.mutate(misc.id)}
|
||||
etCreate={
|
||||
roleMode === "ET" && !misc && port?.status === "PAID" ? (
|
||||
<MiscCreateForm
|
||||
busy={createMisc.isPending}
|
||||
onCreate={(file, amount, currency) =>
|
||||
createMisc.mutate({ file, amount, currency })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/* Any number of miscellaneous charges, in any order relative to the
|
||||
port charge — each is billed and paid on its own. */}
|
||||
{miscCharges.map((c, i) => (
|
||||
<ChargeCard
|
||||
key={c.id}
|
||||
title={
|
||||
miscCharges.length > 1
|
||||
? `Miscellaneous charge ${i + 1}`
|
||||
: "Miscellaneous charge"
|
||||
}
|
||||
charge={c}
|
||||
roleMode={roleMode}
|
||||
busy={busy}
|
||||
emptyHint=""
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
bill.mutate({ chargeId: c.id, amount, currency })
|
||||
}
|
||||
onSend={() => send.mutate(c.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{roleMode === "ET" && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="14px" fw={700} c="edr-text" mb={4}>
|
||||
{miscCharges.length > 0
|
||||
? "Add another miscellaneous charge"
|
||||
: "Add a miscellaneous charge"}
|
||||
</Text>
|
||||
<Text fz="12px" c="dimmed" mb="sm">
|
||||
Upload the supporting document and set the amount. You can raise as
|
||||
many as the shipment needs, before or after the port charge.
|
||||
</Text>
|
||||
<MiscCreateForm
|
||||
key={miscCreated}
|
||||
busy={createMisc.isPending}
|
||||
onCreate={(file, amount, currency) =>
|
||||
createMisc.mutate({ file, amount, currency })
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{totals.size > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
BookingContractCard,
|
||||
} from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
@@ -363,6 +364,14 @@ export default function DocumentClearanceDetailPage() {
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
{/* Documents stay open until payment, so GL can ask for a
|
||||
missing file at any point in that window. */}
|
||||
<AdditionalDocsRequestCard
|
||||
bookingId={id!}
|
||||
requests={clearance.docRequests ?? []}
|
||||
canRequest={!documentsClosed}
|
||||
onSent={() => void refetch()}
|
||||
/>
|
||||
{booking ? <BookingCompanyCard booking={booking} /> : null}
|
||||
{booking ? <BookingContractCard booking={booking} /> : null}
|
||||
{isPhasedGeneral ? (
|
||||
|
||||
@@ -432,6 +432,11 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceView;
|
||||
},
|
||||
|
||||
/** GL asks the customer for additional clearance document(s). */
|
||||
requestAdditionalDocuments: async (id: string, note: string): Promise<void> => {
|
||||
await client.post(`/bookings/${id}/clearance/doc-requests`, { note });
|
||||
},
|
||||
|
||||
/** Clearance action history — reviews, workflow steps, charges (newest first). */
|
||||
getClearanceHistory: async (
|
||||
id: string,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -55,6 +56,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
clearance,
|
||||
customerDocs,
|
||||
glDocs,
|
||||
workflowFiles,
|
||||
isReady,
|
||||
needsCompletion,
|
||||
awaitingGlCompletion,
|
||||
@@ -152,6 +154,56 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Customs paperwork GL produced for this shipment — declaration, T1
|
||||
transit permit, Djibouti documents. These live in `workflowFiles`
|
||||
rather than the seeded output set, so they need their own section:
|
||||
without it the customer never sees their own declaration or T1. */}
|
||||
{workflowFiles.length > 0 && (
|
||||
<>
|
||||
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
||||
Clearance documents from Global Logistics
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{workflowFiles.map((doc) => (
|
||||
<Group
|
||||
key={doc.code}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box c="#2E5B96">
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Text fz="13px" c="#10202F" truncate>
|
||||
{doc.label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({ name: doc.file.name, url: "" }) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.file.id, doc.file.name).then(
|
||||
view,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
icon={<Download size={15} />}
|
||||
onClick={() =>
|
||||
void downloadStoredFile(doc.file.id, doc.file.name)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* GL output documents (read-only to the customer). */}
|
||||
{glDocs.length > 0 && (
|
||||
<>
|
||||
@@ -209,6 +261,37 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* What Global Logistics asked for, in their own words — placed directly
|
||||
above the upload box so the customer reads the ask and answers it in
|
||||
one place. Newest first. */}
|
||||
{(clearance.docRequests?.length ?? 0) > 0 && (
|
||||
<Box mt="lg">
|
||||
<Text fz="12.5px" fw={700} c="#10202F" mb={8}>
|
||||
Requested by Global Logistics
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{clearance.docRequests!.map((r) => (
|
||||
<Alert
|
||||
key={r.id}
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<MessageSquare size={16} />}
|
||||
p="xs"
|
||||
>
|
||||
<Text fz="12.5px" c="#10202F">
|
||||
{r.note}
|
||||
</Text>
|
||||
<Text fz="11px" c="dimmed" mt={4}>
|
||||
{r.byName ?? "Global Logistics"} ·{" "}
|
||||
{new Date(r.at).toLocaleString()}
|
||||
</Text>
|
||||
</Alert>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{canUpload ? (
|
||||
<ClearanceAdHocUploadSection
|
||||
rows={adHoc}
|
||||
|
||||
@@ -97,6 +97,18 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
||||
[clearance],
|
||||
);
|
||||
// Phased customs paperwork (declaration, T1 transit permit, Djibouti docs).
|
||||
// Separate from `glDocs`, which only covers the seeded output-document set —
|
||||
// these carry the shipment's own declaration and permit.
|
||||
const workflowFiles = useMemo(
|
||||
() =>
|
||||
(clearance?.workflowFiles ?? []).filter(
|
||||
(f): f is Freight.ClearanceWorkflowFile & {
|
||||
file: NonNullable<Freight.ClearanceWorkflowFile["file"]>;
|
||||
} => Boolean(f.file),
|
||||
),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the
|
||||
// customer resubmits via the same clearance/proceed endpoint.
|
||||
@@ -205,6 +217,7 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
isLoading: clearanceQuery.isLoading,
|
||||
customerDocs,
|
||||
glDocs,
|
||||
workflowFiles,
|
||||
isReady,
|
||||
needsCompletion,
|
||||
completeTo,
|
||||
|
||||
Reference in New Issue
Block a user