mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
fix issue
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `[]`).
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user