diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index acbd857cd..3dddb3bc1 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -28,6 +28,10 @@ import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { clearanceCodesForBooking } from './clearance.util'; +import { + buildClearanceDocHistory, + type ClearanceDocEvent, +} from './clearance-doc-history.util'; import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -630,6 +634,7 @@ export class BookingTransitionService { uploadedAt: string | null; reviewedAt: string | null; reviewedByName: string | null; + history: ClearanceDocEvent[]; }>; allApproved: boolean; phase?: string | null; @@ -655,9 +660,18 @@ export class BookingTransitionService { const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); - const reviewerNames = await this.bookingsRepository.resolveStaffNames( - reviews.map((r) => r.reviewedByStaffId), + const allVersions = await this.filesService.findAllVersionsByResource( + bookingId, + "bookings", ); + const queryNotes = await this.bookingsRepository.findReviewNotes( + bookingId, + "CHANGES_REQUESTED", + ); + const reviewerNames = await this.bookingsRepository.resolveStaffNames([ + ...reviews.map((r) => r.reviewedByStaffId), + ...queryNotes.map((n) => n.authorId), + ]); const documents: Awaited< ReturnType @@ -691,6 +705,13 @@ export class BookingTransitionService { reviewedByName: review?.reviewedByStaffId ? (reviewerNames.get(review.reviewedByStaffId) ?? null) : null, + history: buildClearanceDocHistory({ + fileKey: field.fileKey, + allVersions, + queryNotes, + review, + names: reviewerNames, + }), }); } }; @@ -716,6 +737,13 @@ export class BookingTransitionService { reviewedByName: review?.reviewedByStaffId ? (reviewerNames.get(review.reviewedByStaffId) ?? null) : null, + history: buildClearanceDocHistory({ + fileKey: f.code, + allVersions, + queryNotes, + review, + names: reviewerNames, + }), }); } diff --git a/apps/edr-freight-api/src/modules/bookings/clearance-doc-history.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance-doc-history.util.ts new file mode 100644 index 000000000..e86c8f644 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance-doc-history.util.ts @@ -0,0 +1,74 @@ +import type { BookingDocumentReview } from './entities/booking-document-review.entity'; +import type { BookingReviewNote } from './entities/booking-review-note.entity'; +import type { FileRecord } from '../files/entities/file.entity'; + +/** One entry of a clearance document's per-card audit trail, oldest first. */ +export interface ClearanceDocEvent { + type: 'UPLOADED' | 'RESUBMITTED' | 'QUERIED' | 'APPROVED'; + at: string; + byName: string | null; + note: string | null; +} + +/** + * Query review notes are written as `Document "" queried: ` + * (see BookingTransitionService.reviewDocument) — the only place a past query + * decision survives after the customer re-uploads and the review row resets. + */ +const QUERY_NOTE_RE = /^Document "(.+?)" queried: ([\s\S]*)$/; + +/** + * Per-document audit trail assembled from data the flow already persists: + * every stored file version (first = customer upload, later ones = the + * customer's amendment responses), every query note (who opened it, when, + * why), and the review row's current approval. Approvals that were later + * reset by a re-upload are the one thing not kept anywhere — the trail shows + * the decision that currently stands. + */ +export function buildClearanceDocHistory(input: { + fileKey: string; + /** All versions of all files on the booking, createdAt ASC, deleted included. */ + allVersions: FileRecord[]; + /** CHANGES_REQUESTED review notes for the booking. */ + queryNotes: BookingReviewNote[]; + review: BookingDocumentReview | null; + /** staff id → display name. */ + names: Map; +}): ClearanceDocEvent[] { + const { fileKey, allVersions, queryNotes, review, names } = input; + const events: ClearanceDocEvent[] = []; + + const versions = allVersions.filter((v) => v.code === fileKey); + versions.forEach((v, i) => { + events.push({ + type: i === 0 ? 'UPLOADED' : 'RESUBMITTED', + at: v.createdAt.toISOString(), + byName: v.uploadedByName ?? null, + note: null, + }); + }); + + for (const n of queryNotes) { + const m = QUERY_NOTE_RE.exec(n.note); + if (!m || m[1] !== fileKey) continue; + events.push({ + type: 'QUERIED', + at: n.createdAt.toISOString(), + byName: n.authorId ? (names.get(n.authorId) ?? null) : null, + note: m[2] || null, + }); + } + + if (review?.status === 'APPROVED' && review.reviewedAt) { + events.push({ + type: 'APPROVED', + at: review.reviewedAt.toISOString(), + byName: review.reviewedByStaffId + ? (names.get(review.reviewedByStaffId) ?? null) + : null, + note: null, + }); + } + + return events.sort((a, b) => a.at.localeCompare(b.at)); +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 5ce4183ab..b63aad054 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -35,6 +35,11 @@ import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { + buildClearanceDocHistory, + type ClearanceDocEvent, +} from '../bookings/clearance-doc-history.util'; + const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface BookingClearanceView { @@ -55,6 +60,7 @@ export interface BookingClearanceView { uploadedAt: string | null; reviewedAt: string | null; reviewedByName: string | null; + history: ClearanceDocEvent[]; }>; allApproved: boolean; phase?: string | null; @@ -188,9 +194,18 @@ export class BookingClearanceService { const fileByCode = new Map(files.map((f) => [f.code, f])); const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); - const reviewerNames = await this.bookingsRepository.resolveStaffNames( - reviews.map((r) => r.reviewedByStaffId), + const allVersions = await this.filesService.findAllVersionsByResource( + bookingId, + 'bookings', ); + const queryNotes = await this.bookingsRepository.findReviewNotes( + bookingId, + 'CHANGES_REQUESTED', + ); + const reviewerNames = await this.bookingsRepository.resolveStaffNames([ + ...reviews.map((r) => r.reviewedByStaffId), + ...queryNotes.map((n) => n.authorId), + ]); const documents: BookingClearanceView['documents'] = []; @@ -219,6 +234,13 @@ export class BookingClearanceService { reviewedByName: review?.reviewedByStaffId ? (reviewerNames.get(review.reviewedByStaffId) ?? null) : null, + history: buildClearanceDocHistory({ + fileKey: field.fileKey, + allVersions, + queryNotes, + review, + names: reviewerNames, + }), }); } }; @@ -243,6 +265,13 @@ export class BookingClearanceService { reviewedByName: review?.reviewedByStaffId ? (reviewerNames.get(review.reviewedByStaffId) ?? null) : null, + history: buildClearanceDocHistory({ + fileKey: f.code, + allVersions, + queryNotes, + review, + names: reviewerNames, + }), }); } diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index 9a2552c7a..e4164c3b2 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository { }); } + /** + * Every version of every document on a resource, oldest first — superseded + * versions included. One query for a whole document grid's upload history. + */ + findAllVersionsByResource( + resourceId: string, + resource: string, + ): Promise { + return this.repository.find({ + where: { resourceId, resource }, + withDeleted: true, + order: { createdAt: "ASC" }, + }); + } + /** * Documents belonging to any of the given resources that a reviewer has asked * the customer to correct. Used by the approval gate, so it takes a list of diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index e76240704..e2481848a 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -327,6 +327,14 @@ export class FilesService { return this.filesRepository.findByResource(resourceId, resource); } + /** All versions of every document on a resource (superseded included), oldest first. */ + findAllVersionsByResource( + resourceId: string, + resource: string, + ): Promise { + return this.filesRepository.findAllVersionsByResource(resourceId, resource); + } + /** * Files for many resources of one kind, grouped by resource id. Resources with * no files are absent from the map (callers should default to `[]`). 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 e4e78f462..291bc0d05 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 @@ -14,6 +14,7 @@ import { Text, Textarea, ThemeIcon, + Timeline, Tooltip, } from "@mantine/core"; import { @@ -24,6 +25,7 @@ import { FileCheck2, FileText, MessageSquareWarning, + RefreshCw, Upload, } from "lucide-react"; import toast from "react-hot-toast"; @@ -458,6 +460,76 @@ export function ClearanceReviewSection({ ); } +const EVENT_META: Record< + Freight.ClearanceDocumentEvent["type"], + { color: string; icon: typeof Upload; label: (byName: string | null) => string } +> = { + UPLOADED: { + color: "blue", + icon: Upload, + label: (n) => `Uploaded by ${n ?? "customer"}`, + }, + RESUBMITTED: { + color: "blue", + icon: RefreshCw, + label: (n) => `Re-submitted by ${n ?? "customer"}`, + }, + QUERIED: { + color: "red", + icon: MessageSquareWarning, + label: (n) => `Query opened by ${n ?? "staff"}`, + }, + APPROVED: { + color: "edr-green", + icon: CheckCircle2, + label: (n) => `Approved by ${n ?? "staff"}`, + }, +}; + +/** Per-document audit trail: uploads, amendment responses, queries, approval. */ +function DocHistoryTimeline({ + history, +}: { + history: Freight.ClearanceDocumentEvent[]; +}) { + return ( + + {history.map((ev, i) => { + const meta = EVENT_META[ev.type]; + const Icon = meta.icon; + return ( + } + title={ + + {meta.label(ev.byName)} + + } + > + + {formatDateTime(ev.at)} + + {ev.note ? ( + + {ev.note} + + ) : null} + + ); + })} + + ); +} + function StatPill({ color, label, @@ -551,11 +623,6 @@ function DocReviewCard({ {hasFile ? doc.file!.name : "Not uploaded by customer"} - {hasFile && doc.uploadedAt ? ( - - Uploaded {formatDateTime(doc.uploadedAt)} - - ) : null} @@ -607,11 +674,8 @@ function DocReviewCard({ - {doc.reviewedAt && (status === "APPROVED" || status === "QUERIED") && ( - - {status === "APPROVED" ? "Approved" : "Queried"} by{" "} - {doc.reviewedByName ?? "staff"} · {formatDateTime(doc.reviewedAt)} - + {(doc.history?.length ?? 0) > 0 && ( + )} {status === "QUERIED" && doc.note && ( diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index d236eb03b..02f4f3f0e 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -802,6 +802,16 @@ export interface PricingBreakdown { export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED"; +/** One entry of a clearance document's audit trail, oldest first. */ +export interface ClearanceDocumentEvent { + type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED"; + at: string; + /** Actor display name (staff for reviews; null = the customer/unknown). */ + byName: string | null; + /** Query reason, for QUERIED events. */ + note: string | null; +} + /** One row of the clearance document grid (a required doc + its file + review). */ export interface ClearanceDocument { fileKey: string; @@ -819,6 +829,8 @@ export interface ClearanceDocument { reviewedAt?: string | null; /** Display name of the staff member who recorded the decision. */ reviewedByName?: string | null; + /** Full audit trail: uploads, re-submissions, queries, approval. */ + history?: ClearanceDocumentEvent[]; } /**