Merge pull request #1364 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-20 14:49:27 +03:00
committed by GitHub
16 changed files with 453 additions and 64 deletions

View File

@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia
* may raise several MISCELLANEOUS charges, and either level may be created
* first. Port charges stay unique per booking (one port bill per shipment),
* enforced by a partial index instead of the old blanket (booking_id, type)
* uniqueness that also capped miscellaneous at one.
*/
export class MultipleMiscClearanceCharges3610000000000
implements MigrationInterface
{
name = 'MultipleMiscClearanceCharges3610000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type"
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port"
ON "freight"."booking_clearance_charge" ("booking_id")
WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking"
ON "freight"."booking_clearance_charge" ("booking_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// No-op on the uniqueness: restoring the blanket (booking_id, type) index
// would fail on any booking that has since raised a second miscellaneous
// charge, which is exactly what this migration set out to allow.
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port"
`);
}
}

View File

@@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],

View File

@@ -303,22 +303,8 @@ export class BookingClearanceChargeService {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking); this.assertClearanceFinalized(booking);
const port = await this.repo().findOne({ // No ordering and no cap: a miscellaneous charge may be raised before,
where: { bookingId, type: 'PORT_CHARGES' }, // after or alongside the port charge, and a booking may carry several.
});
if (port?.status !== 'PAID') {
throw new ConflictException(
'Miscellaneous charges open after the port charge is paid.',
);
}
const existing = await this.repo().findOne({
where: { bookingId, type: 'MISCELLANEOUS' },
});
if (existing) {
throw new ConflictException(
'This booking already has a miscellaneous charge — revise it instead.',
);
}
if (!(input.amount > 0)) { if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.'); throw new BadRequestException('Amount must be greater than zero.');
} }
@@ -326,21 +312,15 @@ export class BookingClearanceChargeService {
throw new BadRequestException('Currency is required.'); throw new BadRequestException('Currency is required.');
} }
const record = await this.filesService.upsertByCode( // Save the row first so its id can key the document. A booking may carry
{ // several miscellaneous charges, and `upsertByCode` retires whatever sits
resourceId: bookingId, // under the same code — a shared code would silently delete the previous
resource: 'bookings', // charge's document.
code: CHARGE_FILE_CODE.MISCELLANEOUS, const charge = await this.repo().save(
file,
},
{ userId: staffId },
);
await this.repo().save(
this.repo().create({ this.repo().create({
bookingId, bookingId,
type: 'MISCELLANEOUS', type: 'MISCELLANEOUS',
status: 'BILLED', status: 'BILLED',
fileRecordId: record.id,
amount: input.amount.toFixed(2), amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(), currency: input.currency.trim().toUpperCase(),
uploadedByStaffId: staffId, uploadedByStaffId: staffId,
@@ -349,6 +329,16 @@ export class BookingClearanceChargeService {
billedAt: new Date(), billedAt: new Date(),
}), }),
); );
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`,
file,
},
{ userId: staffId },
);
await this.repo().update(charge.id, { fileRecordId: record.id });
await this.clearanceEvents.record({ await this.clearanceEvents.record({
bookingId, bookingId,
action: 'CHARGE_MISC_CREATED', action: 'CHARGE_MISC_CREATED',

View File

@@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService {
} }
/** A clearance document was queried and needs the customer to re-upload. */ /** A clearance document was queried and needs the customer to re-upload. */
/** GL asked the customer for additional clearance document(s). */
additionalDocsRequested(b: Booking, note: string): void {
const msg =
`Additional document(s) requested on booking ${b.reference}: ` +
`${note} Please upload them from the portal.`;
void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED');
this.inApp(b, 'Additional documents requested', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
documentQueried(b: Booking, fileKey: string, note: string): void { documentQueried(b: Booking, fileKey: string, note: string): void {
const msg = const msg =
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +

View File

@@ -643,6 +643,12 @@ export class BookingTransitionService {
}>; }>;
allApproved: boolean; allApproved: boolean;
documentsOpen: boolean; documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null; phase?: string | null;
milestones?: unknown[]; milestones?: unknown[];
nextAction?: unknown; nextAction?: unknown;
@@ -674,9 +680,14 @@ export class BookingTransitionService {
bookingId, bookingId,
"CHANGES_REQUESTED", "CHANGES_REQUESTED",
); );
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
"ADDITIONAL_DOC_REQUEST",
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([ const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId), ...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId), ...queryNotes.map((n) => n.authorId),
...docRequestNotes.map((n) => n.authorId),
]); ]);
const documents: Awaited< const documents: Awaited<
@@ -763,9 +774,51 @@ export class BookingTransitionService {
documents, documents,
allApproved, allApproved,
documentsOpen: clearanceDocumentsOpen(booking), documentsOpen: clearanceDocumentsOpen(booking),
docRequests: docRequestNotes.map((n) => ({
id: n.id,
note: n.note,
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
at: n.createdAt.toISOString(),
})),
}; };
} }
/**
* GL asks the customer for additional clearance document(s). Stored as a
* review-note thread shown on both the GL clearance page and the customer's
* portal; the customer answers with an ad-hoc upload. Allowed for as long as
* documents are open (until the shipment is paid).
*/
async requestAdditionalDocuments(
bookingId: string,
note: string,
staffId: string,
): Promise<void> {
const booking = await this.bookingsService.findById(bookingId);
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
if (!note?.trim()) {
throw new BadRequestException("Describe the document(s) you need.");
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
"ADDITIONAL_DOC_REQUEST",
staffId,
);
await this.clearanceEvents.record({
bookingId,
action: "ADDITIONAL_DOCS_REQUESTED",
label: "Requested additional document(s) from the customer",
actorId: staffId,
metadata: { note: note.trim() },
});
this.notifier.additionalDocsRequested(booking, note.trim());
}
/** /**
* True when every REQUIRED field of the booking's customer-input clearance set * True when every REQUIRED field of the booking's customer-input clearance set
* has an APPROVED review row. The 100% gate before clearance can be finalized. * has an APPROVED review row. The 100% gate before clearance can be finalized.

View File

@@ -1083,6 +1083,25 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(":id/clearance/doc-requests")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL asks the customer for additional clearance document(s) — shown on the portal with author and time",
})
async requestAdditionalDocuments(
@Param("id", ParseUUIDPipe) id: string,
@Body("note") note: string,
@CurrentUser() user: AuthUserPayload,
) {
await this.transitionService.requestAdditionalDocuments(
id,
note,
resolveAuthUserId(user),
);
return { success: true };
}
@Get(":id/clearance/history") @Get(":id/clearance/history")
@BookingStaff([ @BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceEtActions,

View File

@@ -14,15 +14,15 @@ export const CLEARANCE_CHARGE_STATUSES = [
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number]; export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/** /**
* Post-finalization clearance charge billed to the customer — at most one * Clearance charge billed to the customer. One PORT_CHARGES row per booking
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the * (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency * GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid` * sets amount + currency (BILLED) and issues the invoice (SENT); the billing
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only * `clearance_charge.invoice.paid` event marks it PAID. The two levels are
* after the port charge is paid. * independent — either may be raised first.
*/ */
@Entity({ schema: 'freight', name: 'booking_clearance_charge' }) @Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId', 'type'], { unique: true }) @Index(['bookingId'])
export class BookingClearanceCharge extends BaseEntity { export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' }) @Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string; bookingId!: string;

View File

@@ -11,6 +11,12 @@ export const REVIEW_NOTE_TYPES = [
* (price/files). One row per round — the draft/change-request loop can repeat. * (price/files). One row per round — the draft/change-request loop can repeat.
*/ */
'DRAFT_DECL_CHANGE_REQUEST', 'DRAFT_DECL_CHANGE_REQUEST',
/**
* GL asked the customer for additional clearance document(s). Shown as a
* thread on both the GL clearance page and the customer's portal — the
* customer answers by uploading an ad-hoc document.
*/
'ADDITIONAL_DOC_REQUEST',
] as const; ] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];

View File

@@ -66,6 +66,12 @@ export interface BookingClearanceView {
}>; }>;
allApproved: boolean; allApproved: boolean;
documentsOpen: boolean; documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null; phase?: string | null;
milestones?: Array<{ milestones?: Array<{
id: string; id: string;
@@ -206,9 +212,14 @@ export class BookingClearanceService {
bookingId, bookingId,
'CHANGES_REQUESTED', 'CHANGES_REQUESTED',
); );
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
'ADDITIONAL_DOC_REQUEST',
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([ const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId), ...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId), ...queryNotes.map((n) => n.authorId),
...docRequestNotes.map((n) => n.authorId),
]); ]);
const documents: BookingClearanceView['documents'] = []; const documents: BookingClearanceView['documents'] = [];
@@ -357,6 +368,12 @@ export class BookingClearanceService {
documents, documents,
allApproved, allApproved,
documentsOpen: clearanceDocumentsOpen(booking), documentsOpen: clearanceDocumentsOpen(booking),
docRequests: docRequestNotes.map((n) => ({
id: n.id,
note: n.note,
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
at: n.createdAt.toISOString(),
})),
phase, phase,
milestones: milestones.map((m) => ({ milestones: milestones.map((m) => ({
id: m.id, id: m.id,

View File

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

View File

@@ -67,6 +67,8 @@ export function ClearanceChargesTab({
onViewFile, onViewFile,
}: ClearanceChargesTabProps) { }: ClearanceChargesTabProps) {
const qc = useQueryClient(); 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({ const { data: charges, isLoading } = useQuery({
queryKey: ["clearance-charges", bookingId], queryKey: ["clearance-charges", bookingId],
queryFn: () => bookingsService.getClearanceCharges(bookingId), queryFn: () => bookingsService.getClearanceCharges(bookingId),
@@ -109,6 +111,8 @@ export function ClearanceChargesTab({
bookingsService.createMiscellaneousCharge(bookingId, p.file, p), bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
onSuccess: (next) => { onSuccess: (next) => {
toast.success("Miscellaneous charge created"); toast.success("Miscellaneous charge created");
// Remount the form so the next charge starts from an empty one.
setMiscCreated((n) => n + 1);
refresh(next); refresh(next);
}, },
onError, onError,
@@ -124,7 +128,9 @@ export function ClearanceChargesTab({
} }
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null; 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 = const busy =
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending; uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
@@ -137,7 +143,7 @@ export function ClearanceChargesTab({
return ( return (
<Stack gap="md" maw={860}> <Stack gap="md" maw={860}>
<ChargeCard <ChargeCard
title="1 · Port charges" title="Port charges"
charge={port} charge={port}
roleMode={roleMode} roleMode={roleMode}
busy={busy} busy={busy}
@@ -175,34 +181,48 @@ export function ClearanceChargesTab({
} }
/> />
<ChargeCard {/* Any number of miscellaneous charges, in any order relative to the
title="2 · Miscellaneous charges" port charge — each is billed and paid on its own. */}
charge={misc} {miscCharges.map((c, i) => (
roleMode={roleMode} <ChargeCard
busy={busy} key={c.id}
emptyHint={ title={
port?.status !== "PAID" miscCharges.length > 1
? "Unlocks once the port charge is paid." ? `Miscellaneous charge ${i + 1}`
: roleMode === "ET" : "Miscellaneous charge"
? "Create the miscellaneous charge with its document, amount and currency." }
: "GL Ethiopia creates this charge once the port charge is paid." charge={c}
} roleMode={roleMode}
onViewFile={onViewFile} busy={busy}
onBill={(amount, currency) => emptyHint=""
misc && bill.mutate({ chargeId: misc.id, amount, currency }) onViewFile={onViewFile}
} onBill={(amount, currency) =>
onSend={() => misc && send.mutate(misc.id)} bill.mutate({ chargeId: c.id, amount, currency })
etCreate={ }
roleMode === "ET" && !misc && port?.status === "PAID" ? ( onSend={() => send.mutate(c.id)}
<MiscCreateForm />
busy={createMisc.isPending} ))}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency }) {roleMode === "ET" && (
} <Paper withBorder radius="md" p="md">
/> <Text fz="14px" fw={700} c="edr-text" mb={4}>
) : null {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 && ( {totals.size > 0 && (
<Paper withBorder radius="md" p="md"> <Paper withBorder radius="md" p="md">

View File

@@ -36,6 +36,7 @@ import {
BookingContractCard, BookingContractCard,
} from "@/components/bookings/detail"; } from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection"; import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper"; import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline"; import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
@@ -363,6 +364,14 @@ export default function DocumentClearanceDetailPage() {
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}> <Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
<Box style={{ position: "sticky", top: 24 }}> <Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg"> <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 ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null} {booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? ( {isPhasedGeneral ? (

View File

@@ -432,6 +432,11 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView; 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). */ /** Clearance action history — reviews, workflow steps, charges (newest first). */
getClearanceHistory: async ( getClearanceHistory: async (
id: string, id: string,

View File

@@ -12,6 +12,7 @@ import {
Download, Download,
Eye, Eye,
FileText, FileText,
MessageSquare,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -55,6 +56,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
clearance, clearance,
customerDocs, customerDocs,
glDocs, glDocs,
workflowFiles,
isReady, isReady,
needsCompletion, needsCompletion,
awaitingGlCompletion, awaitingGlCompletion,
@@ -152,6 +154,56 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
))} ))}
</Stack> </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). */} {/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && ( {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 ? ( {canUpload ? (
<ClearanceAdHocUploadSection <ClearanceAdHocUploadSection
rows={adHoc} rows={adHoc}

View File

@@ -97,6 +97,18 @@ export function useClearanceFlow(booking: Freight.IBooking) {
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance], [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 // OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the
// customer resubmits via the same clearance/proceed endpoint. // customer resubmits via the same clearance/proceed endpoint.
@@ -205,6 +217,7 @@ export function useClearanceFlow(booking: Freight.IBooking) {
isLoading: clearanceQuery.isLoading, isLoading: clearanceQuery.isLoading,
customerDocs, customerDocs,
glDocs, glDocs,
workflowFiles,
isReady, isReady,
needsCompletion, needsCompletion,
completeTo, completeTo,

View File

@@ -856,6 +856,15 @@ export interface ClearanceCharge {
paidAt: string | null; paidAt: string | null;
} }
/** A GL→customer request for additional clearance document(s). */
export interface ClearanceDocRequest {
id: string;
note: string;
/** Staff display name; null when unresolvable. */
byName: string | null;
at: string;
}
/** One entry of a clearance document's audit trail, oldest first. */ /** One entry of a clearance document's audit trail, oldest first. */
export interface ClearanceDocumentEvent { export interface ClearanceDocumentEvent {
type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED"; type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED";
@@ -930,6 +939,8 @@ export interface ClearanceView {
* clearance is finalized. * clearance is finalized.
*/ */
documentsOpen?: boolean; documentsOpen?: boolean;
/** GL→customer requests for additional documents, newest first. */
docRequests?: ClearanceDocRequest[];
/** Phased clearance (GENERAL + customs per-booking). */ /** Phased clearance (GENERAL + customs per-booking). */
phase?: ContractDocPhase | null; phase?: ContractDocPhase | null;
milestones?: IClearanceMilestone[]; milestones?: IClearanceMilestone[];