From e22b336e8c78dd4c9f6f52e79272062dd6f68549 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 20 Jul 2026 07:46:38 +0000 Subject: [PATCH 01/15] Refactor clearance terminology to document approval across services and UI components --- .../booking-lifecycle-notifier.service.ts | 8 +++---- .../contracts/contract-clearance.service.ts | 2 +- .../detail/ClearanceReviewSection.tsx | 10 +++++---- .../ContractClearanceReviewSection.tsx | 6 +++--- .../trainBuilder/BuildTrainModal.tsx | 3 ++- .../trainBuilder/EditTrainDetailsModal.tsx | 3 ++- .../src/hooks/contracts/useContracts.ts | 8 ++++--- .../src/hooks/useImportTrainNumberOptions.ts | 21 ++++++++++++------- .../bookings/DocumentClearanceListPage.tsx | 2 +- .../contracts/ContractClearanceListPage.tsx | 4 ++-- 10 files changed, 40 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 1cef9528c..3809dc086 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -157,13 +157,13 @@ export class BookingLifecycleNotifierService { }); } - /** Clearance finalized → customer can proceed to request operation. */ + /** Document approval finalized → customer can proceed to request operation. */ clearanceReady(b: Booking): void { const msg = - `Clearance for booking ${b.reference} is complete. ` + + `Document approval for booking ${b.reference} is finalized. ` + `You can now proceed to request operation from the portal.`; - void this.notifyContact(b, msg, 'CLEARANCE READY'); - this.inApp(b, 'Clearance complete', msg, { + void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED'); + this.inApp(b, 'Document approval finalized', msg, { type: NotificationType.CLEARANCE_DECISION, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d3bbaf098..4b179814c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -446,7 +446,7 @@ export class ContractClearanceService { const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; if (!allowed.includes(contract.status)) { throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, + `Cannot finalize document approval on status "${contract.status}".`, ); } } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 3dcf1ea9e..501e074e2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -135,12 +135,14 @@ export function ClearanceReviewSection({ const finalizeMutation = useMutation({ mutationFn: () => bookingsService.finalizeClearance(bookingId), onSuccess: () => { - toast.success("Clearance finalized"); + toast.success("Document approval finalized"); refresh(); }, onError: (e) => toast.error( - e instanceof Error ? e.message : "Could not finalize clearance", + e instanceof Error + ? e.message + : "Could not finalize document approval", ), }); @@ -366,7 +368,7 @@ export function ClearanceReviewSection({ }> {finalizeMutation.error instanceof Error ? finalizeMutation.error.message - : "Could not finalize clearance."} + : "Could not finalize document approval."} )} @@ -445,7 +447,7 @@ export function ClearanceReviewSection({ loading={finalizeMutation.isPending} onClick={() => finalizeMutation.mutate()} > - Finalize clearance + Finalize document approval diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx index c85dc0b07..c7d668ade 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -68,7 +68,7 @@ export interface ContractClearanceReviewSectionProps { queriesLocked?: boolean; /** * ONE_TIME customs contracts use the phased milestone workflow. Hides the - * legacy "Finalize clearance" shortcut; booking readiness follows delivery + * legacy "Finalize document approval" shortcut; booking readiness follows delivery * order (import) or export release. */ phasedCustoms?: boolean; @@ -393,7 +393,7 @@ export function ContractClearanceReviewSection({ }> {finalizeClearance.error instanceof Error ? finalizeClearance.error.message - : "Could not finalize clearance."} + : "Could not finalize document approval."} )} @@ -482,7 +482,7 @@ export function ContractClearanceReviewSection({ }) } > - Finalize clearance + Finalize document approval diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 05b4bd546..925ff528d 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -160,7 +160,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain onChange={(value) => setImportTrainNumber(value ?? "")} searchable clearable - nothingFoundMessage="No free run numbers — add more in Dropdown Settings" + nothingFoundMessage={importNumbers.emptyMessage} + error={importNumbers.settingMissing ? importNumbers.emptyMessage : undefined} /> (null); + const fileInput = useRef(null); + const { view, viewer } = useFileViewer(); + const attach = useAttachmentDraft((reason) => toast.error(reason)); + + /** + * Scroll height captured just before an older page was requested, tagged with + * the page count at that moment. + * + * The page count is what makes this safe. Keyed on presence alone, a message + * arriving over the socket while history was still in flight would consume the + * snapshot on a one-bubble append, and the real 30-message prepend would then + * land with nothing to correct against — throwing the reader exactly as far as + * this exists to prevent. Comparing counts means only an actual new page can + * claim it. + */ + const pendingRestore = useRef<{ height: number; atPageCount: number } | null>( + null, + ); + /** Whether the agent is parked at the bottom and wants to follow new messages. */ + const stick = useRef(true); + /** Which thread the refs above describe; a switch resets them. */ + const anchoredThread = useRef(conversation.id); + + const messageCount = messages.length; useEffect(() => { markRead.mutate(conversation.id); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [conversation.id, messages?.length]); + }, [conversation.id, messageCount]); - useEffect(() => { - viewport.current?.scrollTo({ top: viewport.current.scrollHeight }); - }, [messages?.length, conversation.id]); + /** + * Keep the viewport sensible as the list changes underneath it. + * + * Two different things change `messages`, and they want opposite behaviour: a + * new message at the bottom should follow (if the agent is already there), + * while an older page prepended at the top must NOT move what they're reading. + * Layout effect, not effect — this must run before paint or the prepend + * visibly jumps. + */ + useLayoutEffect(() => { + const el = viewport.current; + if (!el) return; + + // Thread switch: start a fresh read at the bottom and drop the previous + // thread's anchoring state. + if (anchoredThread.current !== conversation.id) { + anchoredThread.current = conversation.id; + pendingRestore.current = null; + stick.current = true; + el.scrollTo({ top: el.scrollHeight }); + return; + } + + const restore = pendingRestore.current; + if (restore && pageCount > restore.atPageCount) { + // An older page went in above: push the scroll down by exactly the height + // that was added, so the same message stays under the cursor. + el.scrollTop += el.scrollHeight - restore.height; + pendingRestore.current = null; + return; + } + if (stick.current) el.scrollTo({ top: el.scrollHeight }); + }, [messages, pageCount, conversation.id]); + + const onScroll = ({ y }: { y: number }) => { + const el = viewport.current; + if (!el) return; + stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP; + if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) { + // A failed fetch leaves this set, which is harmless: the list didn't + // change, so the height is still accurate for the retry, and the count + // tag stops it being mistaken for a landed page in the meantime. + pendingRestore.current = { + height: el.scrollHeight, + atPageCount: pageCount, + }; + fetchNextPage(); + } + }; const submit = async () => { const body = draft.trim(); - if (!body) return; + if (!body && attach.attachments.length === 0) return; + const files = attach.files; + // Clear optimistically so the composer feels instant; on failure the text is + // restored below rather than silently lost. setDraft(""); - await send.mutateAsync(body); + attach.clear(); + stick.current = true; + try { + await send.mutateAsync({ body: body || undefined, attachments: files }); + } catch (error) { + setDraft(body); + toast.error( + error instanceof Error ? error.message : "Couldn't send that message.", + ); + } + }; + + const loadAttachment = useLazyAttachmentObjectUrl(); + + // Images already hold their bytes as an object URL from rendering the + // thumbnail, so reuse it rather than fetching the same file twice. + const openAttachment = (a: SupportAttachmentDto, src: string) => + view({ name: a.name, url: src, mimeType: a.mimeType }); + + // Documents aren't fetched until opened. + const openFile = async (a: SupportAttachmentDto) => { + try { + const src = await loadAttachment(a.url); + view({ name: a.name, url: src, mimeType: a.mimeType }); + } catch { + toast.error(`Couldn't open ${a.name}.`); + } }; return ( @@ -361,36 +541,108 @@ function ConversationThread({ {/* Messages */} - + {isLoading ? ( - ) : (messages ?? []).length === 0 ? ( + ) : messages.length === 0 ? ( No messages yet — say hello. ) : ( - {(messages ?? []).map((m) => ( - + {isFetchingNextPage && ( + + + + )} + {!hasNextPage && ( + + Start of conversation + + )} + {messages.map((m) => ( + ))} )} {/* Composer */} - + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + attach.add(Array.from(e.dataTransfer.files)); + }} + > + + { + attach.add(Array.from(e.currentTarget.files ?? [])); + // Reset so picking the same file twice in a row still fires change. + e.currentTarget.value = ""; + }} + /> + fileInput.current?.click()} + > + +