fix issue

This commit is contained in:
Marshal
2026-08-20 04:27:16 +00:00
parent a58157a7ee
commit 4adb53b486
7 changed files with 244 additions and 14 deletions

View File

@@ -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<BookingTransitionService["getClearanceView"]>
@@ -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,
}),
});
}

View File

@@ -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 "<fileKey>" queried: <note>`
* (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<string, string>;
}): 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));
}

View File

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

View File

@@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
});
}
/**
* 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<FileRecord[]> {
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

View File

@@ -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<FileRecord[]> {
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 `[]`).

View File

@@ -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 (
<Timeline
mt="sm"
ml={4}
bulletSize={20}
lineWidth={2}
active={history.length - 1}
color="gray"
>
{history.map((ev, i) => {
const meta = EVENT_META[ev.type];
const Icon = meta.icon;
return (
<Timeline.Item
key={`${ev.type}:${ev.at}:${i}`}
color={meta.color}
bullet={<Icon size={11} />}
title={
<Text fz="12.5px" fw={600} c="edr-text" lh={1.3}>
{meta.label(ev.byName)}
</Text>
}
>
<Text fz="11px" c="dimmed">
{formatDateTime(ev.at)}
</Text>
{ev.note ? (
<Text fz="11.5px" c="red.8" mt={2}>
{ev.note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
);
}
function StatPill({
color,
label,
@@ -551,11 +623,6 @@ function DocReviewCard({
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
{hasFile && doc.uploadedAt ? (
<Text fz="11px" c="dimmed">
Uploaded {formatDateTime(doc.uploadedAt)}
</Text>
) : null}
</Box>
</Group>
@@ -607,11 +674,8 @@ function DocReviewCard({
</Group>
</Group>
{doc.reviewedAt && (status === "APPROVED" || status === "QUERIED") && (
<Text fz="11.5px" c="dimmed" mt={6}>
{status === "APPROVED" ? "Approved" : "Queried"} by{" "}
{doc.reviewedByName ?? "staff"} · {formatDateTime(doc.reviewedAt)}
</Text>
{(doc.history?.length ?? 0) > 0 && (
<DocHistoryTimeline history={doc.history!} />
)}
{status === "QUERIED" && doc.note && (

View File

@@ -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[];
}
/**