mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Merge pull request #616 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -3,6 +3,10 @@ import Handlebars from 'handlebars';
|
|||||||
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
||||||
export interface RenderedClause {
|
export interface RenderedClause {
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Computed outline number, e.g. "3" or "2.1.4". */
|
||||||
|
number: string;
|
||||||
|
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||||
|
depth: number;
|
||||||
bullets: string[];
|
bullets: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +20,26 @@ export interface RenderedArticle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a template article body into clauses. Format: one clause per line;
|
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
|
||||||
* lines prefixed with "- " become bullets nested under the preceding clause.
|
* The token's segment count sets the clause depth; its digits are ignored —
|
||||||
* A body that reduces to a single clause without bullets renders as a plain
|
* numbering is recomputed sequentially so stale numbers self-heal.
|
||||||
* paragraph rather than a numbered list of one.
|
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
|
||||||
|
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
|
||||||
|
* end the line — that is an empty clause still being typed in the editor.
|
||||||
|
*/
|
||||||
|
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
||||||
|
|
||||||
|
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
||||||
|
const MAX_CLAUSE_DEPTH = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template article body into clauses. Format: one clause per line.
|
||||||
|
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
||||||
|
* sub-clause at that depth — the typed digits are stripped and renumbered
|
||||||
|
* sequentially, so editing order never leaves stale numbers in the document.
|
||||||
|
* Lines prefixed with "- " become bullets nested under the preceding clause.
|
||||||
|
* A body that reduces to a single un-numbered clause without bullets renders
|
||||||
|
* as a plain paragraph rather than a numbered list of one.
|
||||||
*/
|
*/
|
||||||
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
|
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
|
||||||
const lines = (body ?? '')
|
const lines = (body ?? '')
|
||||||
@@ -28,20 +48,44 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
|
|||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
const clauses: RenderedClause[] = [];
|
const clauses: RenderedClause[] = [];
|
||||||
|
// counters[i] = current number at depth i+1; truncated when a shallower
|
||||||
|
// clause arrives so deeper numbering restarts at 1.
|
||||||
|
const counters: number[] = [];
|
||||||
|
let sawNumberToken = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('- ')) {
|
if (line.startsWith('- ')) {
|
||||||
const bullet = line.slice(2).trim();
|
const bullet = line.slice(2).trim();
|
||||||
if (clauses.length === 0) {
|
if (clauses.length === 0) {
|
||||||
clauses.push({ text: bullet, bullets: [] });
|
counters.splice(0, counters.length, 1);
|
||||||
|
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
|
||||||
} else {
|
} else {
|
||||||
clauses[clauses.length - 1].bullets.push(bullet);
|
clauses[clauses.length - 1].bullets.push(bullet);
|
||||||
}
|
}
|
||||||
} else {
|
continue;
|
||||||
clauses.push({ text: line, bullets: [] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const match = CLAUSE_NUMBER_RE.exec(line);
|
||||||
|
const token = match ? (match[1] ?? match[2]) : null;
|
||||||
|
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
|
||||||
|
// A sub-clause can only sit directly under an existing parent — "1.1.1"
|
||||||
|
// typed as the first line clamps to whatever level is actually open.
|
||||||
|
depth = Math.min(depth, counters.length + 1);
|
||||||
|
if (match) sawNumberToken = true;
|
||||||
|
|
||||||
|
counters.splice(depth);
|
||||||
|
while (counters.length < depth) counters.push(0);
|
||||||
|
counters[depth - 1] += 1;
|
||||||
|
|
||||||
|
clauses.push({
|
||||||
|
text: match ? line.slice(match[0].length).trim() : line,
|
||||||
|
number: counters.slice(0, depth).join('.'),
|
||||||
|
depth,
|
||||||
|
bullets: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
|
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
|
||||||
return { paragraph: clauses[0].text, clauses: [] };
|
return { paragraph: clauses[0].text, clauses: [] };
|
||||||
}
|
}
|
||||||
return { clauses };
|
return { clauses };
|
||||||
|
|||||||
@@ -26,6 +26,40 @@ describe('parseArticleBody', () => {
|
|||||||
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
|
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
|
||||||
expect(parsed.clauses).toEqual([]);
|
expect(parsed.clauses).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
|
||||||
|
const parsed = parseArticleBody(
|
||||||
|
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
||||||
|
);
|
||||||
|
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
||||||
|
['1', 1, 'Scope'],
|
||||||
|
['1.1', 2, 'Rail transport'],
|
||||||
|
['1.1.1', 3, 'Wagon supply'],
|
||||||
|
['2', 1, 'Payment'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps a sub-clause with no open parent to the next available level', () => {
|
||||||
|
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
||||||
|
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||||
|
['1', 1],
|
||||||
|
['2', 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves prose that merely starts with a number un-tokenized', () => {
|
||||||
|
const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.');
|
||||||
|
expect(parsed.clauses.map((c) => c.text)).toEqual([
|
||||||
|
'10 tons is the minimum load.',
|
||||||
|
'Payment in advance.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a single explicitly numbered line as a clause, not a paragraph', () => {
|
||||||
|
const parsed = parseArticleBody('1. Only clause.');
|
||||||
|
expect(parsed.paragraph).toBeUndefined();
|
||||||
|
expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interpolateTemplateText', () => {
|
describe('interpolateTemplateText', () => {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<ol class="clauses">
|
<ol class="clauses">
|
||||||
{{#each clauses}}
|
{{#each clauses}}
|
||||||
<li>
|
<li class="clause depth-{{depth}}">
|
||||||
|
<span class="clause-no">{{number}}.</span>
|
||||||
{{text}}
|
{{text}}
|
||||||
{{#if bullets.length}}
|
{{#if bullets.length}}
|
||||||
<ul class="clause-bullets">
|
<ul class="clause-bullets">
|
||||||
|
|||||||
@@ -282,28 +282,27 @@
|
|||||||
.article-name { color: #0e5b45; }
|
.article-name { color: #0e5b45; }
|
||||||
.article-paragraph { margin: 4px 0 0; }
|
.article-paragraph { margin: 4px 0 0; }
|
||||||
ol.clauses {
|
ol.clauses {
|
||||||
counter-reset: clause;
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
ol.clauses > li {
|
ol.clauses > li.clause {
|
||||||
counter-increment: clause;
|
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
padding-left: 24px;
|
|
||||||
position: relative;
|
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
}
|
}
|
||||||
ol.clauses > li::before {
|
ol.clauses .clause-no {
|
||||||
color: #0e5b45;
|
color: #0e5b45;
|
||||||
content: counter(clause) ".";
|
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
left: 0;
|
margin-right: 6px;
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
}
|
}
|
||||||
|
/* Sub-clause indentation: each outline level steps in. */
|
||||||
|
ol.clauses > li.depth-2 { padding-left: 20px; }
|
||||||
|
ol.clauses > li.depth-3 { padding-left: 40px; }
|
||||||
|
ol.clauses > li.depth-4 { padding-left: 60px; }
|
||||||
|
ol.clauses > li.depth-5 { padding-left: 80px; }
|
||||||
|
ol.clauses > li.depth-6 { padding-left: 100px; }
|
||||||
ul.clause-bullets {
|
ul.clause-bullets {
|
||||||
margin: 5px 0 2px;
|
margin: 5px 0 2px;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||||
};
|
};
|
||||||
|
|
||||||
// Input set has two required docs.
|
// Input set has two required docs. Non-customs bookings resolve to the
|
||||||
|
// ONE_TIME self-clearance document set.
|
||||||
const inputSetting = {
|
const inputSetting = {
|
||||||
code: 'clearance_import_container_without_customs',
|
code: 'contract_clearance_selfclear_import_container',
|
||||||
fields: [
|
fields: [
|
||||||
{ fileKey: 'commercial_invoice', isRequired: true },
|
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||||
{ fileKey: 'packing_list', isRequired: true },
|
{ fileKey: 'packing_list', isRequired: true },
|
||||||
@@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
*/
|
*/
|
||||||
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
||||||
const inputSetting = {
|
const inputSetting = {
|
||||||
code: 'clearance_import_container_without_customs',
|
code: 'contract_clearance_selfclear_import_container',
|
||||||
fields: [
|
fields: [
|
||||||
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
||||||
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
||||||
|
|||||||
@@ -988,6 +988,15 @@ export class BookingTransitionService {
|
|||||||
"OPERATION_CHANGES_REQUESTED",
|
"OPERATION_CHANGES_REQUESTED",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// A bare initiated instance (clearance-first flow) carries no cargo or
|
||||||
|
// price — it must go through the contract completion endpoint, which
|
||||||
|
// persists cargo, prices, invoices and only then lands here itself.
|
||||||
|
if (booking.contractId && !(Number(booking.totalAmount) > 0)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This booking must be completed (cargo and shipment day) before requesting operation.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const date = new Date(scheduledDate);
|
const date = new Date(scheduledDate);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new BadRequestException("A valid schedule date is required");
|
throw new BadRequestException("A valid schedule date is required");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ function mockQueryBuilder() {
|
|||||||
const qb = {
|
const qb = {
|
||||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
leftJoin: jest.fn().mockReturnThis(),
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
@@ -15,6 +16,8 @@ function mockQueryBuilder() {
|
|||||||
take: jest.fn().mockReturnThis(),
|
take: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn(),
|
getMany: jest.fn(),
|
||||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||||
|
getCount: jest.fn().mockResolvedValue(0),
|
||||||
|
getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }),
|
||||||
};
|
};
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => {
|
|||||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||||
'clearance_import_container_with_customs',
|
'clearance_import_container_with_customs',
|
||||||
);
|
);
|
||||||
|
// Non-customs bookings self-clear with the same document set a ONE_TIME
|
||||||
|
// self-clear contract uses.
|
||||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
||||||
'clearance_import_container_without_customs',
|
'contract_clearance_selfclear_import_container',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => {
|
|||||||
'clearance_export_bulk_with_customs',
|
'clearance_export_bulk_with_customs',
|
||||||
);
|
);
|
||||||
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
||||||
'clearance_export_bulk_without_customs',
|
'contract_clearance_selfclear_export_bulk',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ export function clearanceSettingCode(
|
|||||||
const op = operationFor(tradeDirection);
|
const op = operationFor(tradeDirection);
|
||||||
if (!op) return null;
|
if (!op) return null;
|
||||||
const freight = freightFor(freightType);
|
const freight = freightFor(freightType);
|
||||||
const customs = includesCustoms ? 'with_customs' : 'without_customs';
|
// Non-customs (Path A) bookings self-clear: the customer proves his own
|
||||||
return `clearance_${op}_${freight}_${customs}`;
|
// clearance with the SAME smaller document set a ONE_TIME self-clear
|
||||||
|
// contract uses (customs declaration, release permit, …) — not the
|
||||||
|
// GL-oriented booking sets.
|
||||||
|
if (!includesCustoms) {
|
||||||
|
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||||
|
}
|
||||||
|
return `clearance_${op}_${freight}_with_customs`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The GL-output (customs output) setting code, keyed on op + freight. */
|
/** The GL-output (customs output) setting code, keyed on op + freight. */
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ export class BookingRequestService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clearance-first flow: the request immediately initiates a BARE booking
|
||||||
|
// instance (no cargo, no date, no price) that enters per-booking phased
|
||||||
|
// customs clearance. GL no longer screens the request up front — it
|
||||||
|
// reviews the documents in the clearance queue and completes the booking
|
||||||
|
// (container numbers, VGM, shipment day) once clearance is ready. The
|
||||||
|
// instance is created first so a failure leaves no half-linked request.
|
||||||
|
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
||||||
|
contract,
|
||||||
|
{ contractRouteId: dto.contractRouteId, userId },
|
||||||
|
);
|
||||||
|
|
||||||
const reference = await this.generateReference();
|
const reference = await this.generateReference();
|
||||||
const request = await this.repo.create({
|
const request = await this.repo.create({
|
||||||
reference,
|
reference,
|
||||||
@@ -120,7 +131,8 @@ export class BookingRequestService {
|
|||||||
requestedByUserId: userId ?? null,
|
requestedByUserId: userId ?? null,
|
||||||
contractRouteId: dto.contractRouteId ?? null,
|
contractRouteId: dto.contractRouteId ?? null,
|
||||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
status: 'PENDING',
|
status: 'ACCEPTED',
|
||||||
|
createdBookingId: booking.id,
|
||||||
requestedLines,
|
requestedLines,
|
||||||
notes: dto.notes ?? null,
|
notes: dto.notes ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
|
|||||||
@@ -58,6 +58,31 @@ export class ClearanceMilestoneService {
|
|||||||
await this.seed(postBooking, { bookingId });
|
await this.seed(postBooking, { bookingId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed whichever pre/post-booking milestones the booking is still missing,
|
||||||
|
* keyed by milestoneCode. Plain seeding is a blind insert, so paths that can
|
||||||
|
* run more than once (completing an initiated instance whose pre-booking
|
||||||
|
* milestones were seeded at initiation, or a consolidation pairing replay)
|
||||||
|
* must go through this instead — a duplicate timeline breaks the phase
|
||||||
|
* derivation.
|
||||||
|
*/
|
||||||
|
async ensureBookingMilestones(
|
||||||
|
bookingId: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.repo.find({ where: { bookingId } });
|
||||||
|
const have = new Set(existing.map((m) => m.milestoneCode));
|
||||||
|
const { preBooking, postBooking } = splitMilestones(tradeDirection);
|
||||||
|
await this.seed(
|
||||||
|
preBooking.filter((d) => !have.has(d.code)),
|
||||||
|
{ bookingId },
|
||||||
|
);
|
||||||
|
await this.seed(
|
||||||
|
postBooking.filter((d) => !have.has(d.code)),
|
||||||
|
{ bookingId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async seed(
|
private async seed(
|
||||||
defs: MilestoneDef[],
|
defs: MilestoneDef[],
|
||||||
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
|
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
const milestoneService = {
|
const milestoneService = {
|
||||||
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||||
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
|
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
|
||||||
|
ensureBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||||
...overrides.milestoneService,
|
...overrides.milestoneService,
|
||||||
};
|
};
|
||||||
const contractsRepository = {
|
const contractsRepository = {
|
||||||
@@ -144,9 +145,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
||||||
|
|
||||||
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||||
// GENERAL customs → per-booking pre + post milestones.
|
// GENERAL customs → per-booking milestones, via the idempotent ensure so a
|
||||||
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
|
// pairing replay (or an initiated instance's pre-seeded timeline) never
|
||||||
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
|
// duplicates rows.
|
||||||
|
expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith(
|
||||||
|
'b-1',
|
||||||
|
'EXPORT',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
|
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -192,6 +193,11 @@ export class ContractBookingService {
|
|||||||
// their only chance to hard-block an unbalanceable set. Entry order is
|
// their only chance to hard-block an unbalanceable set. Entry order is
|
||||||
// irrelevant (the check sorts by weight before pairing).
|
// irrelevant (the check sorts by weight before pairing).
|
||||||
await this.assert20ftPairableAtCreate(dto);
|
await this.assert20ftPairableAtCreate(dto);
|
||||||
|
// A container number may appear once per train (same day + route).
|
||||||
|
await this.assertContainerNumbersAvailable(dto, {
|
||||||
|
originYardId: route?.originYardId ?? null,
|
||||||
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||||
@@ -426,17 +432,100 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete a bare initiated booking after Operations finalized its per-booking
|
* Initiate a BARE booking instance for a GENERAL + customs shipment request
|
||||||
* clearance (CLEARANCE_READY) or returned it for changes
|
* (Path B, clearance-first). Called by BookingRequestService.submit AFTER it
|
||||||
|
* validated the contract (general customs, active, capacity) — the request
|
||||||
|
* itself carries the quantities; the instance carries none. Pre-booking
|
||||||
|
* customs milestones are seeded immediately so the instance enters the same
|
||||||
|
* phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking.
|
||||||
|
* GL completes the booking (cargo + day) via {@link completeUnderContract}
|
||||||
|
* once the clearance reaches CLEARANCE_READY.
|
||||||
|
*/
|
||||||
|
async initiateForShipmentRequest(
|
||||||
|
contract: Contract,
|
||||||
|
opts: { contractRouteId?: string; userId?: string | null },
|
||||||
|
): Promise<Booking> {
|
||||||
|
const generalCustoms =
|
||||||
|
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||||
|
if (!generalCustoms) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Shipment-request initiation applies only to general customs contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||||
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||||
|
|
||||||
|
const booking = await insertWithGeneratedReference(
|
||||||
|
() => this.generateReference(),
|
||||||
|
(reference) =>
|
||||||
|
this.bookingsRepository.create({
|
||||||
|
reference,
|
||||||
|
companyId: contract.companyId ?? null,
|
||||||
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
|
isGovernment: contract.isGovernment,
|
||||||
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
|
status: 'AWAITING_DOCUMENTS',
|
||||||
|
bookingType: 'ONE_TIME',
|
||||||
|
contractId: contract.id,
|
||||||
|
contractRouteId: route?.id ?? null,
|
||||||
|
contractKind: contract.contractKind,
|
||||||
|
createdByRole: 'CUSTOMER',
|
||||||
|
createdByUserId: opts.userId ?? null,
|
||||||
|
scheduledDate: null,
|
||||||
|
serviceTypeId: contract.serviceTypeId,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
contractType: 'NEW',
|
||||||
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
|
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||||
|
originYardId: route?.originYardId ?? null,
|
||||||
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
freightType: contract.freightType,
|
||||||
|
cargoTypeId: this.resolveCargoTypeId(contract, {}),
|
||||||
|
isHazardous: contract.isHazardous,
|
||||||
|
isReefer: contract.isReefer,
|
||||||
|
cargoTotalWeightVgm: 0,
|
||||||
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
|
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||||
|
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||||
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
|
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
||||||
|
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
||||||
|
} as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-booking phase only — the post-booking milestones (loading, transit)
|
||||||
|
// are seeded when GL completes the booking, mirroring the ONE_TIME flow
|
||||||
|
// where GL's booking creation seeds them.
|
||||||
|
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||||
|
booking.id,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a bare initiated booking after its per-booking clearance is
|
||||||
|
* finalized (CLEARANCE_READY) or operations returned it for changes
|
||||||
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
|
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
|
||||||
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
|
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
|
||||||
* window + open-departure checks, pricing, consolidation and invoicing all run
|
* window + open-departure checks, pricing, consolidation and invoicing all run
|
||||||
* here — the same gates a one-time shipment passes at creation.
|
* here — the same gates a one-time shipment passes at creation.
|
||||||
|
*
|
||||||
|
* Actor rules mirror {@link assertGate}: a customs (Path B) instance is
|
||||||
|
* completed by GL Ethiopia only; a non-customs (Path A) instance by the
|
||||||
|
* customer (or staff).
|
||||||
*/
|
*/
|
||||||
async completeUnderContract(
|
async completeUnderContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
|
actorPermissions?: unknown,
|
||||||
): Promise<CreateBookingUnderContractResult> {
|
): Promise<CreateBookingUnderContractResult> {
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
@@ -450,6 +539,18 @@ export class ContractBookingService {
|
|||||||
'Clearance must be finalized before the booking can be completed.',
|
'Clearance must be finalized before the booking can be completed.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Path B: only GL Ethiopia completes a customs instance — the customer
|
||||||
|
// never enters shipment data on a customs contract.
|
||||||
|
if (contract.customsClearingEnabled) {
|
||||||
|
const isGlActor =
|
||||||
|
actorPermissions != null &&
|
||||||
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||||
|
if (!isGlActor) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!dto.scheduledDate) {
|
if (!dto.scheduledDate) {
|
||||||
throw new BadRequestException('A binding shipment day is required');
|
throw new BadRequestException('A binding shipment day is required');
|
||||||
}
|
}
|
||||||
@@ -457,6 +558,15 @@ export class ContractBookingService {
|
|||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Completion is booking time: the route's booking window must be open —
|
||||||
|
// the same config-driven gate a direct one-time booking passes at create.
|
||||||
|
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||||
|
originYardId: booking.originYardId ?? null,
|
||||||
|
destinationYardId: booking.destinationYardId ?? null,
|
||||||
|
scheduledDate: dto.scheduledDate,
|
||||||
|
direction: contract.tradeDirection ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
const freightType = contract.freightType;
|
const freightType = contract.freightType;
|
||||||
const hasCargo =
|
const hasCargo =
|
||||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||||
@@ -471,6 +581,15 @@ export class ContractBookingService {
|
|||||||
if (freightType === 'CONTAINER') {
|
if (freightType === 'CONTAINER') {
|
||||||
await this.assertWithinMaxCapacity(contract, dto);
|
await this.assertWithinMaxCapacity(contract, dto);
|
||||||
await this.assert20ftPairableAtCreate(dto);
|
await this.assert20ftPairableAtCreate(dto);
|
||||||
|
// A container number may appear once per train (same day + route).
|
||||||
|
await this.assertContainerNumbersAvailable(
|
||||||
|
dto,
|
||||||
|
{
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
|
},
|
||||||
|
booking.id,
|
||||||
|
);
|
||||||
await this.persistContainers(booking.id, contract, dto);
|
await this.persistContainers(booking.id, contract, dto);
|
||||||
}
|
}
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
@@ -541,9 +660,18 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invoice the now-priced booking (idempotent, non-blocking).
|
// Invoice the now-priced booking and, for a customs instance, seed the
|
||||||
await this.finalizeContractBooking(booking.id, contract, false);
|
// post-booking milestones (pre-booking ones exist since initiation —
|
||||||
|
// ensure* fills only what is missing). Idempotent, non-blocking.
|
||||||
|
const generalCustoms =
|
||||||
|
contract.contractKind === 'GENERAL' &&
|
||||||
|
Boolean(contract.customsClearingEnabled);
|
||||||
|
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
|
||||||
await this.maybeCompleteContract(contract);
|
await this.maybeCompleteContract(contract);
|
||||||
|
} else if (freightType === 'CONTAINER') {
|
||||||
|
// Resubmit only re-picks the shipment day — the persisted container
|
||||||
|
// numbers must be free on the newly chosen train day too.
|
||||||
|
await this.assertPersistedContainersAvailable(booking, dto.scheduledDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
|
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
|
||||||
@@ -627,12 +755,11 @@ export class ContractBookingService {
|
|||||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
} as never);
|
} as never);
|
||||||
} else if (generalCustoms) {
|
} else if (generalCustoms) {
|
||||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
// Per-booking clearance: seed the full milestone timeline on the booking.
|
||||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
// ensure* skips codes that already exist — an initiated instance carries
|
||||||
bookingId,
|
// its pre-booking milestones from initiation, and a consolidation pairing
|
||||||
contract.tradeDirection,
|
// replay must not duplicate the timeline.
|
||||||
);
|
await this.milestoneService.ensureBookingMilestones(
|
||||||
await this.milestoneService.seedPostBookingMilestones(
|
|
||||||
bookingId,
|
bookingId,
|
||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
@@ -1236,6 +1363,131 @@ export class ContractBookingService {
|
|||||||
* balanced onto wagons (pair diff over the global cap). Same rule the
|
* balanced onto wagons (pair diff over the global cap). Same rule the
|
||||||
* shipment-form preview reports as `pairingErrors`, enforced server-side.
|
* shipment-form preview reports as `pairingErrors`, enforced server-side.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A physical container rides one train only. Reject the submission when a
|
||||||
|
* container number is entered twice in the same booking (the portal checks
|
||||||
|
* this client-side, the API must not trust it) or already sits on another
|
||||||
|
* customer's active booking for the same train — same shipment day AND same
|
||||||
|
* route (origin/destination yards).
|
||||||
|
*/
|
||||||
|
private async assertContainerNumbersAvailable(
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
route: { originYardId?: string | null; destinationYardId?: string | null },
|
||||||
|
excludeBookingId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const numbers = (dto.containers ?? []).flatMap((line) =>
|
||||||
|
(line.units ?? [])
|
||||||
|
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
|
||||||
|
.filter((n) => n.length > 0),
|
||||||
|
);
|
||||||
|
if (!numbers.length) return;
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const withinBooking = new Set<string>();
|
||||||
|
for (const n of numbers) {
|
||||||
|
if (seen.has(n)) withinBooking.add(n);
|
||||||
|
seen.add(n);
|
||||||
|
}
|
||||||
|
if (withinBooking.size) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercity bookings have no shipment day yet — nothing to clash with.
|
||||||
|
if (!dto.scheduledDate) return;
|
||||||
|
|
||||||
|
await this.assertNumbersFreeOnTrain(
|
||||||
|
numbers,
|
||||||
|
dto.scheduledDate,
|
||||||
|
route,
|
||||||
|
excludeBookingId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same train guard for a booking whose containers are already persisted
|
||||||
|
* (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its
|
||||||
|
* stored numbers must be free on the newly chosen day for its route.
|
||||||
|
*/
|
||||||
|
private async assertPersistedContainersAvailable(
|
||||||
|
booking: Booking,
|
||||||
|
scheduledDate: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource
|
||||||
|
.getRepository(BookingContainerUnit)
|
||||||
|
.createQueryBuilder('unit')
|
||||||
|
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
|
||||||
|
.select('unit.container_number', 'containerNumber')
|
||||||
|
.where('line.booking_id = :bookingId', { bookingId: booking.id })
|
||||||
|
.getRawMany();
|
||||||
|
const numbers = rows.map((r) => r.containerNumber).filter(Boolean);
|
||||||
|
if (!numbers.length) return;
|
||||||
|
await this.assertNumbersFreeOnTrain(
|
||||||
|
numbers,
|
||||||
|
scheduledDate,
|
||||||
|
{
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
|
},
|
||||||
|
booking.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject when any of `numbers` sits on another active booking of the same
|
||||||
|
* train — same day and same route. Bookings without route yards (legacy
|
||||||
|
* rows) are matched on the day alone rather than let through.
|
||||||
|
*/
|
||||||
|
private async assertNumbersFreeOnTrain(
|
||||||
|
numbers: string[],
|
||||||
|
scheduledDate: string,
|
||||||
|
route: { originYardId?: string | null; destinationYardId?: string | null },
|
||||||
|
excludeBookingId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(BookingContainerUnit)
|
||||||
|
.createQueryBuilder('unit')
|
||||||
|
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
|
||||||
|
.innerJoin(Booking, 'b', 'b.id = line.booking_id')
|
||||||
|
.select('unit.container_number', 'containerNumber')
|
||||||
|
.addSelect('b.reference', 'reference')
|
||||||
|
.where('unit.container_number IN (:...numbers)', { numbers })
|
||||||
|
.andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate })
|
||||||
|
.andWhere('b.status NOT IN (:...terminal)', {
|
||||||
|
terminal: TERMINAL_BOOKING_STATUSES,
|
||||||
|
})
|
||||||
|
.andWhere('b.deleted_at IS NULL');
|
||||||
|
if (route.originYardId && route.destinationYardId) {
|
||||||
|
// Same train = same day + same corridor. A clashing booking whose yards
|
||||||
|
// were never denormalized still blocks (NULL yards match any route).
|
||||||
|
qb.andWhere(
|
||||||
|
'(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)',
|
||||||
|
{ originYardId: route.originYardId },
|
||||||
|
).andWhere(
|
||||||
|
'(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)',
|
||||||
|
{ destinationYardId: route.destinationYardId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (excludeBookingId) {
|
||||||
|
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
|
||||||
|
}
|
||||||
|
const clashes: Array<{ containerNumber: string; reference: string }> =
|
||||||
|
await qb.getRawMany();
|
||||||
|
|
||||||
|
if (clashes.length) {
|
||||||
|
const detail = [
|
||||||
|
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
|
||||||
|
]
|
||||||
|
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
|
||||||
|
.join(', ');
|
||||||
|
throw new ConflictException(
|
||||||
|
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
|
||||||
|
'A container can only be on one booking per train — remove it or pick another shipment day.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async assert20ftPairableAtCreate(
|
private async assert20ftPairableAtCreate(
|
||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -826,8 +826,16 @@ export class ContractsController {
|
|||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@Body() dto: CreateBookingUnderContractDto,
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.contractBookingService.completeUnderContract(id, bookingId, dto);
|
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||||
|
// service checks the actor's contracts:create_booking permission.
|
||||||
|
return this.contractBookingService.completeUnderContract(
|
||||||
|
id,
|
||||||
|
bookingId,
|
||||||
|
dto,
|
||||||
|
user,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/validate-shipment')
|
@Post(':id/validate-shipment')
|
||||||
|
|||||||
@@ -700,6 +700,16 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
bookingsRepository.findBatchPoolByCorridorDay
|
bookingsRepository.findBatchPoolByCorridorDay
|
||||||
.mockResolvedValueOnce([waiting])
|
.mockResolvedValueOnce([waiting])
|
||||||
.mockResolvedValue([]);
|
.mockResolvedValue([]);
|
||||||
|
// expire()'s paid-guard and reserve()'s idempotency guard both re-read the
|
||||||
|
// booking fresh — answer with the matching row, not the paidBooking default
|
||||||
|
// (which would make the guard rescue-allocate the lapsed reservation).
|
||||||
|
const byId: Record<string, Booking> = { lapsed, waiting };
|
||||||
|
dataSource
|
||||||
|
.getRepository()
|
||||||
|
.findOne.mockImplementation(
|
||||||
|
async (opts: { where?: { id?: string } }) =>
|
||||||
|
byId[opts?.where?.id ?? ''] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
await service.settleDueReservations(trainId);
|
await service.settleDueReservations(trainId);
|
||||||
|
|
||||||
@@ -725,6 +735,14 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
return Promise.resolve(reads === 1 ? [lapsed] : []);
|
return Promise.resolve(reads === 1 ? [lapsed] : []);
|
||||||
});
|
});
|
||||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||||
|
// expire()'s paid-guard re-reads the booking fresh — answer with the
|
||||||
|
// (unpaid) lapsed row, not the paidBooking default.
|
||||||
|
dataSource
|
||||||
|
.getRepository()
|
||||||
|
.findOne.mockImplementation(
|
||||||
|
async (opts: { where?: { id?: string } }) =>
|
||||||
|
opts?.where?.id === 'lapsed' ? lapsed : null,
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
service.settleDueReservations(trainId),
|
service.settleDueReservations(trainId),
|
||||||
@@ -733,6 +751,37 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
|
|
||||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never expires a reservation whose payment landed — allocates it instead', async () => {
|
||||||
|
const latePaid = booking('late-paid', 50, {
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
paymentDeadline: new Date(Date.now() - 60_000),
|
||||||
|
});
|
||||||
|
bookingsRepository.findReservedForSchedule
|
||||||
|
.mockResolvedValueOnce([latePaid])
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||||
|
// The payment webhook flipped paymentStatus between the settle's list
|
||||||
|
// read and expire()'s fresh re-read — the deadline had already passed.
|
||||||
|
dataSource
|
||||||
|
.getRepository()
|
||||||
|
.findOne.mockImplementation(
|
||||||
|
async (opts: { where?: { id?: string } }) =>
|
||||||
|
opts?.where?.id === 'late-paid'
|
||||||
|
? { ...latePaid, paymentStatus: 'PAID' }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.settleDueReservations(trainId);
|
||||||
|
|
||||||
|
// Money was taken → the booking boards. Never expired.
|
||||||
|
expect(notifier.expired).not.toHaveBeenCalled();
|
||||||
|
expect(notifier.secured).toHaveBeenCalledTimes(1);
|
||||||
|
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
|
||||||
|
[{ trainScheduleId: trainId, bookingId: 'late-paid' }],
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -428,7 +428,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
relations: { company: true },
|
relations: { company: true },
|
||||||
});
|
});
|
||||||
if (!booking?.trainScheduleId) return;
|
if (!booking) return;
|
||||||
|
if (!booking.trainScheduleId) {
|
||||||
|
// A paid booking with no train is money taken and nothing boarding —
|
||||||
|
// scream so staff pin it to a schedule manually (batch board / assign).
|
||||||
|
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||||||
|
this.logger.error(
|
||||||
|
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
|
||||||
|
`its reservation was likely expired before the payment landed. ` +
|
||||||
|
`Assign it to a schedule manually from the batch board.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isBatchPaid =
|
const isBatchPaid =
|
||||||
booking.status === "SELECTED_FOR_BATCH" ||
|
booking.status === "SELECTED_FOR_BATCH" ||
|
||||||
@@ -2124,8 +2136,38 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||||
|
* `reason` picks the customer message: 'payment' (pay window lapsed) or
|
||||||
|
* 'no-capacity' (no train on the chosen day could take the booking).
|
||||||
|
*
|
||||||
|
* PAID GUARD: a booking whose payment has landed is never expired — money was
|
||||||
|
* taken, so it boards, even when the webhook arrived after the deadline or the
|
||||||
|
* settle read a stale row. It allocates onto the train it was selected for; if
|
||||||
|
* the wagon planner then finds no physical wagon, the booking stays linked and
|
||||||
|
* staff assign wagons manually. Consolidated bookings are exempt from the
|
||||||
|
* rescue: the shared wagon is both-or-neither, and settleReserved owns that
|
||||||
|
* pair decision.
|
||||||
*/
|
*/
|
||||||
private async expire(booking: Booking): Promise<void> {
|
private async expire(
|
||||||
|
booking: Booking,
|
||||||
|
reason: "payment" | "no-capacity" = "payment",
|
||||||
|
): Promise<void> {
|
||||||
|
if (!booking.consolidationPartnerId) {
|
||||||
|
const fresh = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: booking.id }, relations: { company: true } });
|
||||||
|
const paid =
|
||||||
|
fresh != null &&
|
||||||
|
(fresh.paymentStatus === "PAID" || fresh.status === "PAID");
|
||||||
|
const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId;
|
||||||
|
if (paid && paidScheduleId) {
|
||||||
|
this.logger.log(
|
||||||
|
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
|
||||||
|
`landed; allocating on schedule ${paidScheduleId} instead`,
|
||||||
|
);
|
||||||
|
await this.allocate(paidScheduleId, fresh, "paid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const freedScheduleId = booking.trainScheduleId;
|
const freedScheduleId = booking.trainScheduleId;
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
trainScheduleId: null,
|
trainScheduleId: null,
|
||||||
@@ -2146,13 +2188,84 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||||
// source-agnostic.
|
// source-agnostic.
|
||||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
|
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
|
||||||
this.notifier.expired(booking);
|
if (reason === "no-capacity") {
|
||||||
|
this.notifier.expiredNoCapacity(booking);
|
||||||
|
} else {
|
||||||
|
this.notifier.expired(booking);
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` +
|
`[BATCH] EXPIRED ${booking.reference} — ` +
|
||||||
`wagons back to the pool for top-up`,
|
(reason === "no-capacity"
|
||||||
|
? "no train on its day had capacity left"
|
||||||
|
: "payment window passed; freed its wagons back to the pool for top-up"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-of-day sweep: once a schedule's window cycle concludes and NO other
|
||||||
|
* train on the same route-day can still run a cycle, the waiting pool for
|
||||||
|
* that day is dead — a FULLY_EXECUTED booking left in it would wait forever.
|
||||||
|
* Expire every leftover commercial booking and tell the customers to rebook
|
||||||
|
* another day. Government bookings are never auto-expired (they preempt).
|
||||||
|
* Returns how many bookings were expired.
|
||||||
|
*/
|
||||||
|
async expireLeftoverDayPool(scheduleId: string): Promise<number> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (!schedule?.scheduledDepartureDate) return 0;
|
||||||
|
const day = eatDay(schedule.scheduledDepartureDate);
|
||||||
|
const group: RouteDayGroup = {
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
day,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Another train on this route-day that can still take bookings keeps the
|
||||||
|
// pool alive — when IT concludes, its own sweep runs this check again.
|
||||||
|
const siblings = await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
originStationId: group.originYardId,
|
||||||
|
destinationStationId: group.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originStationId: group.originYardId,
|
||||||
|
destinationStationId: group.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Scheduled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const anotherTrainStillOpen = siblings.some(
|
||||||
|
(s) =>
|
||||||
|
s.id !== schedule.id &&
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
eatDay(s.scheduledDepartureDate) === day &&
|
||||||
|
s.windowPhase !== "DONE" &&
|
||||||
|
s.bookingWindowStatus !== "FULL",
|
||||||
|
);
|
||||||
|
if (anotherTrainStillOpen) return 0;
|
||||||
|
|
||||||
|
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||||
|
const pool = corridorYards.length
|
||||||
|
? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day)
|
||||||
|
: await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||||
|
group.originYardId,
|
||||||
|
group.destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
const leftovers = pool.filter((b) => !b.isGovernment);
|
||||||
|
for (const booking of leftovers) {
|
||||||
|
await this.expire(booking, "no-capacity");
|
||||||
|
}
|
||||||
|
if (leftovers.length) {
|
||||||
|
this.logger.log(
|
||||||
|
`[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` +
|
||||||
|
`expired ${leftovers.length} waiting booking(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return leftovers.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
|||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
@@ -139,7 +140,7 @@ export class BookingJourneyService {
|
|||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.train_schedule_bookings',
|
TrainScheduleBooking,
|
||||||
'tsb',
|
'tsb',
|
||||||
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||||
{ scheduleId },
|
{ scheduleId },
|
||||||
@@ -218,8 +219,10 @@ export class BookingJourneyService {
|
|||||||
const bookings = await this.dataSource
|
const bookings = await this.dataSource
|
||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
|
// Entity-class join: a raw 'freight.table' string is parsed by TypeORM as
|
||||||
|
// an alias.property path ("freight" alias was not found) — runtime 500.
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.train_schedule_bookings',
|
TrainScheduleBooking,
|
||||||
'tsb',
|
'tsb',
|
||||||
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||||
{ scheduleId },
|
{ scheduleId },
|
||||||
@@ -350,7 +353,7 @@ export class BookingJourneyService {
|
|||||||
.createQueryBuilder('alloc')
|
.createQueryBuilder('alloc')
|
||||||
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.train_schedules',
|
TrainSchedule,
|
||||||
'schedule',
|
'schedule',
|
||||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||||
{ scheduleId },
|
{ scheduleId },
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ export class BookingNotifierService {
|
|||||||
this.inApp(b, 'Payment window expired', msg);
|
this.inApp(b, 'Payment window expired', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every train on the booking's chosen day filled up (or no further train runs)
|
||||||
|
* before the waiting list reached this booking — it expired unplaced. HIGH so
|
||||||
|
* the customer hears about it by email/SMS and rebooks another day.
|
||||||
|
*/
|
||||||
|
expiredNoCapacity(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Booking ${b.reference ?? b.id} could not be placed: every train for your selected day ` +
|
||||||
|
`is full and no other train is scheduled that day. The booking has expired — ` +
|
||||||
|
`please rebook for another day. No re-approval is needed.`;
|
||||||
|
void this.notifyContact(b, msg, 'EXPIRED (NO CAPACITY)');
|
||||||
|
this.inApp(b, 'No capacity — booking expired', msg, {
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
scheduleFull(b: Booking): void {
|
scheduleFull(b: Booking): void {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,
|
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
isScheduleFull: jest.Mock;
|
isScheduleFull: jest.Mock;
|
||||||
hasLiveReservations: jest.Mock;
|
hasLiveReservations: jest.Mock;
|
||||||
refreshWindowStatus: jest.Mock;
|
refreshWindowStatus: jest.Mock;
|
||||||
|
expireLeftoverDayPool: jest.Mock;
|
||||||
};
|
};
|
||||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||||
@@ -73,6 +74,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
// No reservation is mid-pay-window by default, so the cycle concludes.
|
// No reservation is mid-pay-window by default, so the cycle concludes.
|
||||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||||
|
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
trainSchedulesRepository = {
|
trainSchedulesRepository = {
|
||||||
findById: jest.fn().mockResolvedValue(null),
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
@@ -186,6 +188,8 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
|
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
|
||||||
expect(s.windowPhase).toBe('DONE');
|
expect(s.windowPhase).toBe('DONE');
|
||||||
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
|
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
|
||||||
|
// The day's leftover waiting list is swept once this train is done.
|
||||||
|
expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
|
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
|
||||||
@@ -210,6 +214,8 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
});
|
});
|
||||||
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
|
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
|
||||||
expect(s.windowPhase).toBe('DONE');
|
expect(s.windowPhase).toBe('DONE');
|
||||||
|
// No further train can run for this day → leftover waiting list is swept.
|
||||||
|
expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no transition fires before its deadline (idempotent tick)', async () => {
|
it('no transition fires before its deadline (idempotent tick)', async () => {
|
||||||
|
|||||||
@@ -357,6 +357,10 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
||||||
);
|
);
|
||||||
|
// This train is done. If no other train on the route-day can still take
|
||||||
|
// the waiting list, those bookings have nowhere to go — expire + notify
|
||||||
|
// them now instead of leaving them FULLY_EXECUTED forever.
|
||||||
|
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +394,9 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
|
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
|
||||||
`departure — window DONE`,
|
`departure — window DONE`,
|
||||||
);
|
);
|
||||||
|
// No further cycle on this train. Same sweep as the FULL branch: if no
|
||||||
|
// sibling train can still take the day's waiting list, expire + notify.
|
||||||
|
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { WagonStatus } from '@edr/types';
|
||||||
|
import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class BulkSetWagonStatusDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
wagonIds!: string[];
|
||||||
|
|
||||||
|
@IsEnum(WagonStatus)
|
||||||
|
status!: WagonStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class BulkTransferWagonsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
wagonIds!: string[];
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
toYardId!: string;
|
||||||
|
}
|
||||||
@@ -10,12 +10,16 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||||
|
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||||
|
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||||
import { WagonsService } from './wagons.service';
|
import { WagonsService } from './wagons.service';
|
||||||
|
|
||||||
@ApiTags('wagons')
|
@ApiTags('wagons')
|
||||||
@@ -78,6 +82,20 @@ export class WagonsController {
|
|||||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonsService.unassignFromTrain(id);
|
return this.wagonsService.unassignFromTrain(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bulk-transfer')
|
||||||
|
@FleetManage()
|
||||||
|
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
|
||||||
|
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
|
||||||
|
return this.wagonsService.bulkTransfer(dto, user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-status')
|
||||||
|
@FleetManage()
|
||||||
|
@ApiOperation({ summary: 'Set the status of multiple wagons' })
|
||||||
|
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
|
||||||
|
return this.wagonsService.bulkSetStatus(dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate controller for train‑specific reorder (registered in module)
|
// Separate controller for train‑specific reorder (registered in module)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { WagonMovementKind, WagonStatus } from '@edr/types';
|
import { WagonMovementKind, WagonStatus } from '@edr/types';
|
||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||||
|
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||||
|
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||||
import { Wagon } from './entities/wagon.entity';
|
import { Wagon } from './entities/wagon.entity';
|
||||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from '../trains/entities/train.entity';
|
||||||
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WagonsService {
|
export class WagonsService {
|
||||||
@@ -168,6 +171,101 @@ export class WagonsService {
|
|||||||
return this.wagonRepo.save(wagon);
|
return this.wagonRepo.save(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relocate many wagons to one destination yard in a single transaction. Each
|
||||||
|
* wagon whose yard actually changes gets a `wagon_movements` ledger row (kind
|
||||||
|
* `Manual`) so the yard history stays auditable — mirrors the single-wagon
|
||||||
|
* `update` path. Wagons already in the destination yard are skipped.
|
||||||
|
*/
|
||||||
|
async bulkTransfer(
|
||||||
|
dto: BulkTransferWagonsDto,
|
||||||
|
userId?: string | null,
|
||||||
|
): Promise<{ moved: number }> {
|
||||||
|
const { wagonIds, toYardId } = dto;
|
||||||
|
if (!wagonIds.length) return { moved: 0 };
|
||||||
|
|
||||||
|
const yard = await this.dataSource
|
||||||
|
.getRepository(Yard)
|
||||||
|
.findOne({ where: { id: toYardId } });
|
||||||
|
if (!yard) throw new NotFoundException('Destination yard not found');
|
||||||
|
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
try {
|
||||||
|
const wagons = await queryRunner.manager.find(Wagon, {
|
||||||
|
where: { id: In(wagonIds) },
|
||||||
|
});
|
||||||
|
if (wagons.length !== wagonIds.length) {
|
||||||
|
throw new NotFoundException('One or more wagons not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let moved = 0;
|
||||||
|
for (const wagon of wagons) {
|
||||||
|
const previousYardId = wagon.currentYardId ?? null;
|
||||||
|
if (previousYardId === toYardId) continue;
|
||||||
|
wagon.currentYardId = toYardId;
|
||||||
|
// Drop the eager relation so the scalar FK wins on save (see `update`).
|
||||||
|
wagon.currentYard = null;
|
||||||
|
await queryRunner.manager.save(Wagon, wagon);
|
||||||
|
await queryRunner.manager.save(
|
||||||
|
queryRunner.manager.create(WagonMovement, {
|
||||||
|
wagonId: wagon.id,
|
||||||
|
fromYardId: previousYardId,
|
||||||
|
toYardId,
|
||||||
|
kind: WagonMovementKind.Manual,
|
||||||
|
movedByUserId: userId ?? null,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
return { moved };
|
||||||
|
} catch (err) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the same status on many wagons in one transaction (e.g. flip a batch
|
||||||
|
* from Available to Assigned in the yard workspace). Only the `status` column
|
||||||
|
* is touched — train assignment is managed through the assign/unassign flow.
|
||||||
|
*/
|
||||||
|
async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> {
|
||||||
|
const { wagonIds, status } = dto;
|
||||||
|
if (!wagonIds.length) return { updated: 0 };
|
||||||
|
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
try {
|
||||||
|
const wagons = await queryRunner.manager.find(Wagon, {
|
||||||
|
where: { id: In(wagonIds) },
|
||||||
|
});
|
||||||
|
if (wagons.length !== wagonIds.length) {
|
||||||
|
throw new NotFoundException('One or more wagons not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const wagon of wagons) {
|
||||||
|
wagon.status = status;
|
||||||
|
}
|
||||||
|
await queryRunner.manager.save(Wagon, wagons);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
return { updated: wagons.length };
|
||||||
|
} catch (err) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
|
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
|||||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||||
|
import { Company } from '../companies/entities/company.entity';
|
||||||
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
|
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
@@ -3655,23 +3659,25 @@ export class WarehouseInventoryService {
|
|||||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||||
.leftJoinAndSelect('inv.yard', 'yard')
|
.leftJoinAndSelect('inv.yard', 'yard')
|
||||||
.leftJoinAndSelect('inv.zone', 'zone')
|
.leftJoinAndSelect('inv.zone', 'zone')
|
||||||
.leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
// Entity-class joins: TypeORM parses a raw 'freight.table' string as an
|
||||||
.leftJoin('freight.companies', 'company', 'company.id = booking.company_id')
|
// alias.property path ("freight" alias was not found) — runtime 500.
|
||||||
|
.leftJoin(Booking, 'booking', 'booking.id = inv.booking_id')
|
||||||
|
.leftJoin(Company, 'company', 'company.id = booking.company_id')
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
'freight.containers',
|
Container,
|
||||||
'container',
|
'container',
|
||||||
`((inv.container_id IS NOT NULL AND container.id = inv.container_id)
|
`((inv.container_id IS NOT NULL AND container.id = inv.container_id)
|
||||||
OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id))
|
OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id))
|
||||||
AND container.deleted_at IS NULL`,
|
AND container.deleted_at IS NULL`,
|
||||||
)
|
)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
'freight.cargoes',
|
Cargo,
|
||||||
'cargo',
|
'cargo',
|
||||||
`((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
|
`((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
|
||||||
OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id))
|
OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id))
|
||||||
AND cargo.deleted_at IS NULL`,
|
AND cargo.deleted_at IS NULL`,
|
||||||
)
|
)
|
||||||
.leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
.leftJoin(CargoType, 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
||||||
.addSelect('booking.reference', 'b_reference')
|
.addSelect('booking.reference', 'b_reference')
|
||||||
.addSelect('company.name', 'c_name')
|
.addSelect('company.name', 'c_name')
|
||||||
.addSelect('container.container_number', 'ct_number')
|
.addSelect('container.container_number', 'ct_number')
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { Route } from '../routes/entities/route.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,9 +52,11 @@ export class WarehouseSchedulingAdapterService {
|
|||||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||||
.leftJoinAndSelect('inv.yard', 'yard')
|
.leftJoinAndSelect('inv.yard', 'yard')
|
||||||
.leftJoinAndSelect('inv.zone', 'zone')
|
.leftJoinAndSelect('inv.zone', 'zone')
|
||||||
.innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
// Entity-class joins: TypeORM parses a raw 'freight.table' string as an
|
||||||
|
// alias.property path ("freight" alias was not found) — runtime 500.
|
||||||
|
.innerJoin(Booking, 'booking', 'booking.id = inv.booking_id')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.routes',
|
Route,
|
||||||
'route',
|
'route',
|
||||||
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
|
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
|
||||||
{ routeId },
|
{ routeId },
|
||||||
|
|||||||
@@ -867,6 +867,18 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Completion of an initiated (bare) instance after per-booking
|
||||||
|
clearance — same form, submits to the complete endpoint. */}
|
||||||
|
<Route
|
||||||
|
path="contracts/:id/bookings/:bookingId/complete"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||||
|
>
|
||||||
|
<GlCreateBookingForm />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="bookings/:id/milestones"
|
path="bookings/:id/milestones"
|
||||||
element={<BookingMilestonesRedirect />}
|
element={<BookingMilestonesRedirect />}
|
||||||
|
|||||||
@@ -145,13 +145,37 @@ function bulkUnitOfMeasure(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GlCreateBookingForm() {
|
export default function GlCreateBookingForm() {
|
||||||
const { id } = useParams<{ id: string }>();
|
// With `bookingId` the form runs in COMPLETION mode: the bare instance
|
||||||
|
// (auto-initiated by the customer's shipment request) already finished its
|
||||||
|
// per-booking customs clearance, and this form supplies the deferred cargo
|
||||||
|
// (container numbers, VGM) + binding shipment day. Same window gate, same
|
||||||
|
// validation and price confirmation — the submit completes the existing
|
||||||
|
// booking instead of creating a new one.
|
||||||
|
const { id, bookingId: completeBookingId } = useParams<{
|
||||||
|
id: string;
|
||||||
|
bookingId?: string;
|
||||||
|
}>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const requestId = searchParams.get("requestId");
|
const requestIdParam = searchParams.get("requestId");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: contract, isLoading } = useContractDetail(id);
|
const { data: contract, isLoading } = useContractDetail(id);
|
||||||
const mutations = useContractMutations(id ?? "");
|
const mutations = useContractMutations(id ?? "");
|
||||||
|
|
||||||
|
// Completion mode without an explicit ?requestId=: find the shipment request
|
||||||
|
// that initiated this instance so the quantities still prefill.
|
||||||
|
const { data: contractRequests } = useQuery({
|
||||||
|
queryKey: ["shipment-requests-for-contract", id],
|
||||||
|
queryFn: () => contractsService.listBookingRequests(id!),
|
||||||
|
enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam,
|
||||||
|
});
|
||||||
|
const requestId =
|
||||||
|
requestIdParam ??
|
||||||
|
(completeBookingId
|
||||||
|
? (contractRequests?.find(
|
||||||
|
(r) => r.createdBookingId === completeBookingId,
|
||||||
|
)?.id ?? null)
|
||||||
|
: null);
|
||||||
|
|
||||||
const { data: bookingRequest } = useQuery({
|
const { data: bookingRequest } = useQuery({
|
||||||
queryKey: ["shipment-request", requestId],
|
queryKey: ["shipment-request", requestId],
|
||||||
queryFn: () => contractsService.getBookingRequest(requestId!),
|
queryFn: () => contractsService.getBookingRequest(requestId!),
|
||||||
@@ -174,18 +198,17 @@ export default function GlCreateBookingForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Next future window across all routes, used for the "next window" notice —
|
// Next future window across all routes, used for the "next window" notice —
|
||||||
// the train dispatching soonest among those not yet open, matching the
|
// the next moment booking OPENS (chronological), which may belong to a
|
||||||
// departure-date ordering of the window cards.
|
// later-departing train. Departure-first ordering here named the soonest
|
||||||
|
// train's later opening as "next" while another lane opened earlier.
|
||||||
const nextWindow = useMemo(() => {
|
const nextWindow = useMemo(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return (bookingWindows ?? [])
|
return (bookingWindows ?? [])
|
||||||
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
||||||
.sort((a, b) => {
|
.sort(
|
||||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
(a, b) =>
|
||||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
|
||||||
if (da !== db) return da - db;
|
)[0];
|
||||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
|
||||||
})[0];
|
|
||||||
}, [bookingWindows]);
|
}, [bookingWindows]);
|
||||||
|
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
@@ -636,6 +659,18 @@ export default function GlCreateBookingForm() {
|
|||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|
||||||
|
if (completeBookingId) {
|
||||||
|
// Completion mode: cargo + day land on the already-cleared instance —
|
||||||
|
// the request was linked and accepted at submission time.
|
||||||
|
mutations.completeBooking.mutate(
|
||||||
|
{ bookingId: completeBookingId, payload },
|
||||||
|
{
|
||||||
|
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
mutations.createBooking.mutate(payload, {
|
mutations.createBooking.mutate(payload, {
|
||||||
onSuccess: async (booking) => {
|
onSuccess: async (booking) => {
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
@@ -685,10 +720,12 @@ export default function GlCreateBookingForm() {
|
|||||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
|
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
<Text fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
New Shipment Booking
|
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" mt={4}>
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
Book a shipment on behalf of the customer for contract {contract.reference}.
|
{completeBookingId
|
||||||
|
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
|
||||||
|
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
@@ -1261,7 +1298,11 @@ export default function GlCreateBookingForm() {
|
|||||||
<Modal
|
<Modal
|
||||||
opened={priceOpen}
|
opened={priceOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!mutations.createBooking.isPending) setPriceOpen(false);
|
if (
|
||||||
|
!mutations.createBooking.isPending &&
|
||||||
|
!mutations.completeBooking.isPending
|
||||||
|
)
|
||||||
|
setPriceOpen(false);
|
||||||
}}
|
}}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -1413,7 +1454,10 @@ export default function GlCreateBookingForm() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<X size={16} />}
|
leftSection={<X size={16} />}
|
||||||
onClick={() => setPriceOpen(false)}
|
onClick={() => setPriceOpen(false)}
|
||||||
disabled={mutations.createBooking.isPending}
|
disabled={
|
||||||
|
mutations.createBooking.isPending ||
|
||||||
|
mutations.completeBooking.isPending
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Reject & edit
|
Reject & edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1421,7 +1465,10 @@ export default function GlCreateBookingForm() {
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
loading={mutations.createBooking.isPending}
|
loading={
|
||||||
|
mutations.createBooking.isPending ||
|
||||||
|
mutations.completeBooking.isPending
|
||||||
|
}
|
||||||
disabled={
|
disabled={
|
||||||
validateShipmentMutation.isPending ||
|
validateShipmentMutation.isPending ||
|
||||||
pairingErrors.length > 0 ||
|
pairingErrors.length > 0 ||
|
||||||
@@ -1429,7 +1476,7 @@ export default function GlCreateBookingForm() {
|
|||||||
}
|
}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Confirm & book
|
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
NumberInput,
|
||||||
|
Progress,
|
||||||
|
Select,
|
||||||
|
Slider,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { Wagon } from "@/services/wagon.service";
|
||||||
|
|
||||||
|
export interface WagonYardWorkspaceModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE = Freight.WagonStatus.Available;
|
||||||
|
const ASSIGNED = Freight.WagonStatus.Assigned;
|
||||||
|
|
||||||
|
const clampInt = (v: number | string, max: number): number => {
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0;
|
||||||
|
return Math.min(Math.floor(n), max);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */
|
||||||
|
const QuantityField = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
max,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (n: number) => void;
|
||||||
|
max: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const set = (v: number | string) => onChange(clampInt(v, max));
|
||||||
|
const off = disabled || max === 0;
|
||||||
|
return (
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
<NumberInput
|
||||||
|
value={value}
|
||||||
|
onChange={set}
|
||||||
|
min={0}
|
||||||
|
max={max}
|
||||||
|
allowNegative={false}
|
||||||
|
clampBehavior="strict"
|
||||||
|
disabled={off}
|
||||||
|
radius="md"
|
||||||
|
w={92}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={value}
|
||||||
|
onChange={set}
|
||||||
|
min={0}
|
||||||
|
max={Math.max(max, 1)}
|
||||||
|
disabled={off}
|
||||||
|
label={(v) => `${v}`}
|
||||||
|
color="edr-green"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||||
|
Half
|
||||||
|
</Button>
|
||||||
|
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||||
|
All ({max})
|
||||||
|
</Button>
|
||||||
|
{value > 0 ? (
|
||||||
|
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Box w={10} h={10} style={{ borderRadius: 3, background: `var(--mantine-color-${color}-6)` }} />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk yard operations. Pick a yard + wagon type (the two selects filter each
|
||||||
|
* other to combinations that actually hold stock), read the live Available /
|
||||||
|
* Assigned split, then move a quantity to another yard or flip a quantity
|
||||||
|
* between Available and Assigned — replacing one-wagon-at-a-time edits.
|
||||||
|
*/
|
||||||
|
const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: wagons = [], isLoading } = useQuery(api.wagons.list.queryOptions({ input: {} }));
|
||||||
|
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
|
||||||
|
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
|
||||||
|
|
||||||
|
const [yardId, setYardId] = useState<string | null>(null);
|
||||||
|
const [typeId, setTypeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||||
|
const [transferQty, setTransferQty] = useState(0);
|
||||||
|
const [freeAfterMove, setFreeAfterMove] = useState(false);
|
||||||
|
const [toAssignedQty, setToAssignedQty] = useState(0);
|
||||||
|
const [toAvailableQty, setToAvailableQty] = useState(0);
|
||||||
|
|
||||||
|
const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions());
|
||||||
|
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
|
||||||
|
|
||||||
|
const yardName = useMemo(() => {
|
||||||
|
const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
|
||||||
|
return (id: string) => byId.get(id) ?? id;
|
||||||
|
}, [yards]);
|
||||||
|
|
||||||
|
const typeInfo = useMemo(() => {
|
||||||
|
const byId = new Map(wagonTypes.map((t) => [t.id, t]));
|
||||||
|
return {
|
||||||
|
label: (id: string) => {
|
||||||
|
const t = byId.get(id);
|
||||||
|
return t ? `${t.code}${t.name ? ` - ${t.name}` : ""}` : id;
|
||||||
|
},
|
||||||
|
code: (id: string) => byId.get(id)?.code ?? id,
|
||||||
|
};
|
||||||
|
}, [wagonTypes]);
|
||||||
|
|
||||||
|
const yardWagons = useMemo(
|
||||||
|
() => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)),
|
||||||
|
[wagons],
|
||||||
|
);
|
||||||
|
|
||||||
|
const yardOptions = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const w of yardWagons) {
|
||||||
|
if (typeId && w.wagonTypeId !== typeId) continue;
|
||||||
|
ids.add(w.currentYardId);
|
||||||
|
}
|
||||||
|
return [...ids]
|
||||||
|
.map((id) => ({ value: id, label: yardName(id) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [yardWagons, typeId, yardName]);
|
||||||
|
|
||||||
|
const typeOptions = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const w of yardWagons) {
|
||||||
|
if (yardId && w.currentYardId !== yardId) continue;
|
||||||
|
ids.add(w.wagonTypeId);
|
||||||
|
}
|
||||||
|
return [...ids]
|
||||||
|
.map((id) => ({ value: id, label: typeInfo.label(id) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [yardWagons, yardId, typeInfo]);
|
||||||
|
|
||||||
|
const matching = useMemo(() => {
|
||||||
|
if (!yardId || !typeId) return [] as Wagon[];
|
||||||
|
return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId);
|
||||||
|
}, [yardWagons, yardId, typeId]);
|
||||||
|
|
||||||
|
const availableWagons = useMemo(() => matching.filter((w) => w.status === AVAILABLE), [matching]);
|
||||||
|
const assignedWagons = useMemo(() => matching.filter((w) => w.status === ASSIGNED), [matching]);
|
||||||
|
const otherWagons = useMemo(
|
||||||
|
() => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED),
|
||||||
|
[matching],
|
||||||
|
);
|
||||||
|
// Available first, then assigned, then the rest — a partial move relocates
|
||||||
|
// idle wagons before touching assigned ones.
|
||||||
|
const transferPool = useMemo(
|
||||||
|
() => [...availableWagons, ...assignedWagons, ...otherWagons],
|
||||||
|
[availableWagons, assignedWagons, otherWagons],
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = matching.length;
|
||||||
|
const availableCount = availableWagons.length;
|
||||||
|
const assignedCount = assignedWagons.length;
|
||||||
|
const otherCount = otherWagons.length;
|
||||||
|
|
||||||
|
const destinationYardOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
yards
|
||||||
|
.filter((y) => y.id !== yardId)
|
||||||
|
.map((y) => ({ value: y.id, label: y.label || y.code || y.id }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||||
|
[yards, yardId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bothSelected = Boolean(yardId && typeId);
|
||||||
|
|
||||||
|
// Reset action inputs when the selection changes.
|
||||||
|
useEffect(() => {
|
||||||
|
setTransferYardId(null);
|
||||||
|
setTransferQty(0);
|
||||||
|
setFreeAfterMove(false);
|
||||||
|
setToAssignedQty(0);
|
||||||
|
setToAvailableQty(0);
|
||||||
|
}, [yardId, typeId]);
|
||||||
|
|
||||||
|
// Reset the whole workspace when closed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) {
|
||||||
|
setYardId(null);
|
||||||
|
setTypeId(null);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
// Keep quantities within bounds as counts shift after each action.
|
||||||
|
useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]);
|
||||||
|
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||||
|
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
||||||
|
|
||||||
|
const showError = (err: unknown, fallback: string) => {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
|
||||||
|
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransfer = async () => {
|
||||||
|
if (!transferYardId || transferQty < 1) return;
|
||||||
|
const ids = transferPool.slice(0, transferQty).map((w) => w.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
try {
|
||||||
|
const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId });
|
||||||
|
if (freeAfterMove) {
|
||||||
|
await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE });
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${
|
||||||
|
freeAfterMove ? " · set Available" : ""
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
setTransferQty(0);
|
||||||
|
setTransferYardId(null);
|
||||||
|
setFreeAfterMove(false);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Transfer failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFlip = async (
|
||||||
|
pool: Wagon[],
|
||||||
|
qty: number,
|
||||||
|
status: Freight.WagonStatus,
|
||||||
|
label: string,
|
||||||
|
reset: () => void,
|
||||||
|
) => {
|
||||||
|
if (qty < 1) return;
|
||||||
|
const ids = pool.slice(0, qty).map((w) => w.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
try {
|
||||||
|
const res = await setStatus.mutateAsync({ wagonIds: ids, status });
|
||||||
|
toast({ title: `${res.updated} wagon(s) set to ${label}` });
|
||||||
|
reset();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Status update failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const busy = transfer.isPending || setStatus.isPending;
|
||||||
|
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
size="min(1080px, 96vw)"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
overlayProps={{ blur: 2 }}
|
||||||
|
title={
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||||
|
<Warehouse size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>Wagon Yard Operations</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Move and re-status wagons in bulk — no one-by-one edits
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* ---- Selection ---- */}
|
||||||
|
<Card withBorder radius="md" padding="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Grid gap="md" align="flex-end">
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Select
|
||||||
|
label="Yard"
|
||||||
|
placeholder="Select a yard"
|
||||||
|
data={yardOptions}
|
||||||
|
value={yardId}
|
||||||
|
onChange={setYardId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
leftSection={<Warehouse size={16} />}
|
||||||
|
nothingFoundMessage="No yards with stock"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Select
|
||||||
|
label="Wagon type"
|
||||||
|
placeholder="Select a wagon type"
|
||||||
|
data={typeOptions}
|
||||||
|
value={typeId}
|
||||||
|
onChange={setTypeId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
leftSection={<Layers size={16} />}
|
||||||
|
nothingFoundMessage="No wagon types here"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" p="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : !bothSelected ? (
|
||||||
|
<Card withBorder radius="md" padding="xl">
|
||||||
|
<Stack align="center" gap={6}>
|
||||||
|
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||||
|
<Layers size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600}>Pick a yard and a wagon type</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||||
|
You'll see how many wagons of that type sit in that yard, how many are available
|
||||||
|
vs assigned, and can move or re-status them all at once.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ---- Overview hero ---- */}
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Group gap="lg" align="center" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="3rem" fw={800} lh={1}>
|
||||||
|
{total}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{typeInfo.code(typeId!)} wagons
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} c="dimmed">
|
||||||
|
<Warehouse size={14} />
|
||||||
|
<Text size="sm">{yardName(yardId!)}</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="lg" wrap="wrap">
|
||||||
|
<LegendDot color="teal" label="Available" value={availableCount} />
|
||||||
|
<LegendDot color="blue" label="Assigned" value={assignedCount} />
|
||||||
|
{otherCount > 0 ? <LegendDot color="gray" label="Other" value={otherCount} /> : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Progress.Root size={22} radius="md" mt="md">
|
||||||
|
<Progress.Section value={pct(availableCount)} color="teal">
|
||||||
|
{availableCount > 0 ? <Progress.Label>{availableCount}</Progress.Label> : null}
|
||||||
|
</Progress.Section>
|
||||||
|
<Progress.Section value={pct(assignedCount)} color="blue">
|
||||||
|
{assignedCount > 0 ? <Progress.Label>{assignedCount}</Progress.Label> : null}
|
||||||
|
</Progress.Section>
|
||||||
|
<Progress.Section value={pct(otherCount)} color="gray">
|
||||||
|
{otherCount > 0 ? <Progress.Label>{otherCount}</Progress.Label> : null}
|
||||||
|
</Progress.Section>
|
||||||
|
</Progress.Root>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ---- Actions ---- */}
|
||||||
|
<Grid gap="lg">
|
||||||
|
{/* Transfer */}
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Card withBorder radius="md" h="100%" padding="lg">
|
||||||
|
<Group gap="xs" mb="md">
|
||||||
|
<ThemeIcon variant="light" color="grape" radius="md" size="md">
|
||||||
|
<ArrowRightLeft size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700}>Move to another yard</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
How many wagons
|
||||||
|
</Text>
|
||||||
|
<QuantityField value={transferQty} onChange={setTransferQty} max={total} />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Destination yard"
|
||||||
|
placeholder="Select destination"
|
||||||
|
data={destinationYardOptions}
|
||||||
|
value={transferYardId}
|
||||||
|
onChange={setTransferYardId}
|
||||||
|
searchable
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={freeAfterMove}
|
||||||
|
onChange={(e) => setFreeAfterMove(e.currentTarget.checked)}
|
||||||
|
label="Set moved wagons to Available"
|
||||||
|
color="teal"
|
||||||
|
/>
|
||||||
|
{transferYardId && transferQty > 0 ? (
|
||||||
|
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{yardName(yardId!)} {total}
|
||||||
|
<Text span c="red.6" fw={700}>
|
||||||
|
{" "}
|
||||||
|
−{transferQty}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{yardName(transferYardId)}
|
||||||
|
<Text span c="teal.7" fw={700}>
|
||||||
|
{" "}
|
||||||
|
+{transferQty}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
leftSection={<ArrowRightLeft size={16} />}
|
||||||
|
onClick={handleTransfer}
|
||||||
|
loading={transfer.isPending}
|
||||||
|
disabled={busy || !transferYardId || transferQty < 1}
|
||||||
|
color="edr-green"
|
||||||
|
>
|
||||||
|
Move {transferQty > 0 ? `${transferQty} ` : ""}wagon{transferQty === 1 ? "" : "s"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Re-status */}
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Card withBorder radius="md" h="100%" padding="lg">
|
||||||
|
<Group gap="xs" mb="md">
|
||||||
|
<ThemeIcon variant="light" color="orange" radius="md" size="md">
|
||||||
|
<ArrowRightLeft size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700}>Change availability</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb={6}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<ThemeIcon variant="light" color="blue" radius="sm" size="sm">
|
||||||
|
<CircleSlash size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Available → Assigned
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Badge color="teal" variant="light">
|
||||||
|
{availableCount} free
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<QuantityField
|
||||||
|
value={toAssignedQty}
|
||||||
|
onChange={setToAssignedQty}
|
||||||
|
max={availableCount}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
mt="sm"
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
disabled={busy || toAssignedQty < 1}
|
||||||
|
loading={setStatus.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
handleFlip(availableWagons, toAssignedQty, ASSIGNED, "Assigned", () =>
|
||||||
|
setToAssignedQty(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Assign {toAssignedQty > 0 ? `${toAssignedQty} ` : ""}wagon
|
||||||
|
{toAssignedQty === 1 ? "" : "s"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider variant="dashed" />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb={6}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<ThemeIcon variant="light" color="teal" radius="sm" size="sm">
|
||||||
|
<CheckCircle2 size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Assigned → Available
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Badge color="blue" variant="light">
|
||||||
|
{assignedCount} assigned
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<QuantityField
|
||||||
|
value={toAvailableQty}
|
||||||
|
onChange={setToAvailableQty}
|
||||||
|
max={assignedCount}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
mt="sm"
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
disabled={busy || toAvailableQty < 1}
|
||||||
|
loading={setStatus.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
handleFlip(assignedWagons, toAvailableQty, AVAILABLE, "Available", () =>
|
||||||
|
setToAvailableQty(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Free up {toAvailableQty > 0 ? `${toAvailableQty} ` : ""}wagon
|
||||||
|
{toAvailableQty === 1 ? "" : "s"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WagonYardWorkspaceModal;
|
||||||
@@ -211,6 +211,8 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
||||||
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
||||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||||
|
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
||||||
|
`/contracts/${id}/bookings/${bookingId}/complete`,
|
||||||
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
|
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
|
||||||
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
||||||
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
|
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Badge, Group, Text, Tooltip } from "@mantine/core";
|
||||||
|
import { Boxes, Container, Weight } from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact human summary of a shipment request's requested cargo lines — the
|
||||||
|
* quantities the customer asked for, before GL enters the real booking cargo.
|
||||||
|
* Container contracts read "2 × 20ft, 1 × 40ft"; bulk reads "500 t" or
|
||||||
|
* "300 items" depending on the contract's cargo configuration.
|
||||||
|
*/
|
||||||
|
export function summarizeRequestedCargo(
|
||||||
|
lines?: Freight.RequestedShipmentLines | null,
|
||||||
|
): string {
|
||||||
|
if (!lines) return "—";
|
||||||
|
const containers = (lines.containers ?? []).filter((c) => (c.quantity ?? 0) > 0);
|
||||||
|
if (containers.length) {
|
||||||
|
return containers.map((c) => `${c.quantity} × ${c.containerSize}`).join(", ");
|
||||||
|
}
|
||||||
|
if (lines.bulk) {
|
||||||
|
if (lines.bulk.cargoWeightTons) return `${lines.bulk.cargoWeightTons} t`;
|
||||||
|
if (lines.bulk.itemCount) return `${lines.bulk.itemCount} items`;
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the requested cargo as small badges (per container type, or bulk). */
|
||||||
|
export function RequestedCargoChips({
|
||||||
|
lines,
|
||||||
|
size = "sm",
|
||||||
|
}: {
|
||||||
|
lines?: Freight.RequestedShipmentLines | null;
|
||||||
|
size?: "xs" | "sm";
|
||||||
|
}) {
|
||||||
|
const containers = (lines?.containers ?? []).filter((c) => (c.quantity ?? 0) > 0);
|
||||||
|
|
||||||
|
if (containers.length) {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{containers.map((c, i) => {
|
||||||
|
const flags: string[] = [];
|
||||||
|
if ((c.hazardousQuantity ?? 0) > 0)
|
||||||
|
flags.push(`${c.hazardousQuantity} hazardous`);
|
||||||
|
if ((c.reeferQuantity ?? 0) > 0)
|
||||||
|
flags.push(`${c.reeferQuantity} reefer`);
|
||||||
|
const chip = (
|
||||||
|
<Badge
|
||||||
|
size={size}
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<Container size={12} />}
|
||||||
|
>
|
||||||
|
{c.quantity} × {c.containerSize}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return flags.length ? (
|
||||||
|
<Tooltip key={i} label={flags.join(" · ")} withArrow>
|
||||||
|
{chip}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{chip}</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines?.bulk && (lines.bulk.cargoWeightTons || lines.bulk.itemCount)) {
|
||||||
|
const isWeight = Boolean(lines.bulk.cargoWeightTons);
|
||||||
|
const value = lines.bulk.cargoWeightTons ?? lines.bulk.itemCount ?? 0;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
size={size}
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={isWeight ? <Weight size={12} /> : <Boxes size={12} />}
|
||||||
|
>
|
||||||
|
{value} {isWeight ? "t" : "items"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -214,6 +214,27 @@ export function useContractMutations(contractId: string) {
|
|||||||
onError: () => toast.error("Failed to create booking"),
|
onError: () => toast.error("Failed to create booking"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const completeBooking = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
bookingId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
bookingId: string;
|
||||||
|
payload: Freight.CreateBookingUnderContractDto;
|
||||||
|
}) =>
|
||||||
|
contractsService.completeBookingUnderContract(
|
||||||
|
contractId,
|
||||||
|
bookingId,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Booking completed");
|
||||||
|
void invalidateContractDetail(qc, contractId);
|
||||||
|
},
|
||||||
|
onError: (e: Error) =>
|
||||||
|
toast.error(e.message || "Failed to complete booking"),
|
||||||
|
});
|
||||||
|
|
||||||
const isPending =
|
const isPending =
|
||||||
staffAccept.isPending ||
|
staffAccept.isPending ||
|
||||||
requestChanges.isPending ||
|
requestChanges.isPending ||
|
||||||
@@ -233,6 +254,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
generateContract,
|
generateContract,
|
||||||
signContract,
|
signContract,
|
||||||
createBooking,
|
createBooking,
|
||||||
|
completeBooking,
|
||||||
isPending,
|
isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
PackagePlus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -36,12 +38,18 @@ import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMile
|
|||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { downloadBookingFile } from "@/services/files.service";
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||||
|
import { RequestedCargoChips } from "@/features/clearance/requestedCargo";
|
||||||
|
|
||||||
export default function DocumentClearanceDetailPage() {
|
export default function DocumentClearanceDetailPage() {
|
||||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||||
const id = params.id ?? params.bookingId;
|
const id = params.id ?? params.bookingId;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const { data: booking } = useBookingDetail(id);
|
const { data: booking } = useBookingDetail(id);
|
||||||
@@ -58,6 +66,21 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
|
|
||||||
const { data: bookingMilestones } = useBookingMilestones(id);
|
const { data: bookingMilestones } = useBookingMilestones(id);
|
||||||
|
|
||||||
|
// The originating shipment request carries the quantities the customer asked
|
||||||
|
// for (per container type, or bulk weight/items). The bare instance itself has
|
||||||
|
// no cargo until GL completes the booking, so surface the request here.
|
||||||
|
const { data: contractRequests } = useQuery({
|
||||||
|
queryKey: ["shipment-requests-for-contract", booking?.contractId],
|
||||||
|
queryFn: () => contractsService.listBookingRequests(booking!.contractId!),
|
||||||
|
enabled: Boolean(booking?.contractId),
|
||||||
|
});
|
||||||
|
const requestedLines = useMemo(
|
||||||
|
() =>
|
||||||
|
(contractRequests ?? []).find((r) => r.createdBookingId === id)
|
||||||
|
?.requestedLines ?? null,
|
||||||
|
[contractRequests, id],
|
||||||
|
);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const docs = (clearance?.documents ?? []).filter(
|
const docs = (clearance?.documents ?? []).filter(
|
||||||
(d) => d.uploadedBy === "customer",
|
(d) => d.uploadedBy === "customer",
|
||||||
@@ -76,6 +99,17 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
booking?.contractKind === "GENERAL" &&
|
booking?.contractKind === "GENERAL" &&
|
||||||
Boolean(clearance?.phase);
|
Boolean(clearance?.phase);
|
||||||
|
|
||||||
|
// Bare initiated instance whose clearance is done: GL completes the booking
|
||||||
|
// (container numbers, VGM, shipment day) via the completion form.
|
||||||
|
// Creating the booking is a GL Ethiopia action — never available to Djibouti GL.
|
||||||
|
const canCompleteBooking =
|
||||||
|
booking?.status === "CLEARANCE_READY" &&
|
||||||
|
Boolean(booking?.contractId) &&
|
||||||
|
Boolean(booking?.customsClearingEnabled) &&
|
||||||
|
!(Number(booking?.totalAmount ?? 0) > 0) &&
|
||||||
|
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||||
|
!isDjiboutiGl(user);
|
||||||
|
|
||||||
const docsPhaseComplete =
|
const docsPhaseComplete =
|
||||||
clearance?.milestones?.some(
|
clearance?.milestones?.some(
|
||||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||||
@@ -146,9 +180,30 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
action={
|
||||||
|
canCompleteBooking ? (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<PackagePlus size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create booking
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
<ClearanceHero
|
||||||
|
booking={booking}
|
||||||
|
clearance={clearance}
|
||||||
|
stats={stats}
|
||||||
|
requestedLines={requestedLines}
|
||||||
|
/>
|
||||||
|
|
||||||
{isPhasedGeneral ? (
|
{isPhasedGeneral ? (
|
||||||
<Paper withBorder radius="md" p="lg">
|
<Paper withBorder radius="md" p="lg">
|
||||||
@@ -189,6 +244,10 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||||
workflowFiles={workflowFiles}
|
workflowFiles={workflowFiles}
|
||||||
roleMode="ET"
|
roleMode="ET"
|
||||||
|
// A bare initiated instance still has no cargo/price — the
|
||||||
|
// stepper's "Create booking" step must read as NOT-yet-created
|
||||||
|
// so it never claims the booking is done before GL completes it.
|
||||||
|
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
|
||||||
onChanged={() => void refetch()}
|
onChanged={() => void refetch()}
|
||||||
onViewFile={view}
|
onViewFile={view}
|
||||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
@@ -256,10 +315,12 @@ function ClearanceHero({
|
|||||||
booking,
|
booking,
|
||||||
clearance,
|
clearance,
|
||||||
stats,
|
stats,
|
||||||
|
requestedLines,
|
||||||
}: {
|
}: {
|
||||||
booking: ReturnType<typeof useBookingDetail>["data"];
|
booking: ReturnType<typeof useBookingDetail>["data"];
|
||||||
clearance: Freight.ClearanceView;
|
clearance: Freight.ClearanceView;
|
||||||
stats: { pct: number; approved: number; total: number };
|
stats: { pct: number; approved: number; total: number };
|
||||||
|
requestedLines?: Freight.RequestedShipmentLines | null;
|
||||||
}) {
|
}) {
|
||||||
const direction = booking?.tradeDirection ?? "—";
|
const direction = booking?.tradeDirection ?? "—";
|
||||||
const origin =
|
const origin =
|
||||||
@@ -325,6 +386,18 @@ function ClearanceHero({
|
|||||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{requestedLines ? (
|
||||||
|
<>
|
||||||
|
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
|
||||||
|
<Group gap={10} align="center" wrap="wrap">
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
|
||||||
|
Requested cargo
|
||||||
|
</Text>
|
||||||
|
<RequestedCargoChips lines={requestedLines} size="sm" />
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Banknote,
|
|
||||||
Building2,
|
Building2,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
@@ -35,6 +34,7 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
|
ListTree,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
@@ -60,7 +60,7 @@ import {
|
|||||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||||
|
|
||||||
const BODY_HINT =
|
const BODY_HINT =
|
||||||
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
|
'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
|
||||||
|
|
||||||
interface ArticleDraft {
|
interface ArticleDraft {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -105,12 +105,6 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
|||||||
icon: CalendarRange,
|
icon: CalendarRange,
|
||||||
hint: "Year the contract is signed",
|
hint: "Year the contract is signed",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
token: "{{pricing.totalAmount}}",
|
|
||||||
label: "Total price",
|
|
||||||
icon: Banknote,
|
|
||||||
hint: "Total contract price from the pricing schedule",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||||
@@ -243,7 +237,11 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
|||||||
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||||
];
|
];
|
||||||
|
|
||||||
const KNOWN_TOKENS = new Set<string>(ALL_PLACEHOLDERS.map((p) => p.token));
|
const KNOWN_TOKENS = new Set<string>([
|
||||||
|
...ALL_PLACEHOLDERS.map((p) => p.token),
|
||||||
|
// Still filled by the renderer, just no longer offered as an insert button.
|
||||||
|
"{{pricing.totalAmount}}",
|
||||||
|
]);
|
||||||
|
|
||||||
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
|
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
|
||||||
function unknownTokens(text: string): string[] {
|
function unknownTokens(text: string): string[] {
|
||||||
@@ -253,6 +251,10 @@ function unknownTokens(text: string): string[] {
|
|||||||
|
|
||||||
interface ParsedClause {
|
interface ParsedClause {
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Computed outline number, e.g. "3" or "2.1.4". */
|
||||||
|
number: string;
|
||||||
|
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||||
|
depth: number;
|
||||||
bullets: string[];
|
bullets: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,28 +264,93 @@ interface ParsedBody {
|
|||||||
clauses: ParsedClause[];
|
clauses: ParsedClause[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leading outline token on a clause line ("1. ", "2.1 ", "1.1.1) ") — its
|
||||||
|
* segment count sets the depth; the digits themselves are recomputed. Single
|
||||||
|
* segment requires "."/")" so prose like "10 tons…" is untouched; a token may
|
||||||
|
* end the line (empty clause still being typed).
|
||||||
|
*/
|
||||||
|
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
||||||
|
|
||||||
|
/** Depth of the outline token in a CLAUSE_NUMBER_RE match, else null. */
|
||||||
|
function matchDepth(match: RegExpExecArray | null): number | null {
|
||||||
|
if (!match) return null;
|
||||||
|
const token = match[1] ?? match[2];
|
||||||
|
return Math.min(token.split(".").length, MAX_CLAUSE_DEPTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deepest supported sub-clause level. */
|
||||||
|
const MAX_CLAUSE_DEPTH = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
||||||
* line, "- " nests a bullet under the previous clause, and a single bullet-less
|
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
|
||||||
* clause renders as a plain paragraph instead of a numbered list of one.
|
* at that depth and is renumbered sequentially; "- " nests a bullet under the
|
||||||
|
* previous clause; a single un-numbered bullet-less clause renders as a plain
|
||||||
|
* paragraph instead of a numbered list of one.
|
||||||
*/
|
*/
|
||||||
function parseArticleBody(body: string): ParsedBody {
|
function parseArticleBody(body: string): ParsedBody {
|
||||||
const clauses: ParsedClause[] = [];
|
const clauses: ParsedClause[] = [];
|
||||||
|
const counters: number[] = [];
|
||||||
|
let sawNumberToken = false;
|
||||||
for (const raw of body.split("\n")) {
|
for (const raw of body.split("\n")) {
|
||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
if (line.startsWith("- ") && clauses.length > 0) {
|
if (line.startsWith("- ") && clauses.length > 0) {
|
||||||
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
|
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
|
||||||
} else {
|
continue;
|
||||||
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
|
|
||||||
}
|
}
|
||||||
|
const cleaned = line.replace(/^- /, "");
|
||||||
|
const match = CLAUSE_NUMBER_RE.exec(cleaned);
|
||||||
|
let depth = matchDepth(match) ?? 1;
|
||||||
|
// A sub-clause can only sit directly under an existing parent.
|
||||||
|
depth = Math.min(depth, counters.length + 1);
|
||||||
|
if (match) sawNumberToken = true;
|
||||||
|
counters.splice(depth);
|
||||||
|
while (counters.length < depth) counters.push(0);
|
||||||
|
counters[depth - 1] += 1;
|
||||||
|
clauses.push({
|
||||||
|
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
|
||||||
|
number: counters.slice(0, depth).join("."),
|
||||||
|
depth,
|
||||||
|
bullets: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
|
if (
|
||||||
|
clauses.length === 1 &&
|
||||||
|
clauses[0].bullets.length === 0 &&
|
||||||
|
!sawNumberToken
|
||||||
|
) {
|
||||||
return { paragraph: clauses[0].text, clauses: [] };
|
return { paragraph: clauses[0].text, clauses: [] };
|
||||||
}
|
}
|
||||||
return { clauses };
|
return { clauses };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite the leading outline tokens in a body so every numbered clause line
|
||||||
|
* carries its computed sequential number (stale numbers self-heal). Lines
|
||||||
|
* without a number token and bullet lines pass through untouched.
|
||||||
|
*/
|
||||||
|
function renumberBody(body: string): string {
|
||||||
|
const counters: number[] = [];
|
||||||
|
return body
|
||||||
|
.split("\n")
|
||||||
|
.map((raw) => {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith("- ")) return raw;
|
||||||
|
const match = CLAUSE_NUMBER_RE.exec(line);
|
||||||
|
let depth = matchDepth(match) ?? 1;
|
||||||
|
depth = Math.min(depth, counters.length + 1);
|
||||||
|
counters.splice(depth);
|
||||||
|
while (counters.length < depth) counters.push(0);
|
||||||
|
counters[depth - 1] += 1;
|
||||||
|
if (!match) return raw;
|
||||||
|
const number = counters.slice(0, depth).join(".");
|
||||||
|
return `${number}. ${line.slice(match[0].length).trim()}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||||
function HighlightedText({ text }: { text: string }) {
|
function HighlightedText({ text }: { text: string }) {
|
||||||
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||||
@@ -632,13 +699,63 @@ function ArticleEditorModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertLinePrefix = (prefix: string) => {
|
/**
|
||||||
|
* Insert a structured line (clause / sub-clause / bullet) on a fresh line
|
||||||
|
* below the one the caret is on. Clause lines get their outline number typed
|
||||||
|
* in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
|
||||||
|
* renumbered so the text always matches the preview.
|
||||||
|
*/
|
||||||
|
const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
|
||||||
const el = bodyRef.current;
|
const el = bodyRef.current;
|
||||||
const start = el?.selectionStart ?? body.length;
|
|
||||||
// Start the snippet on its own line unless the caret already is.
|
|
||||||
const needsNewline = start > 0 && body[start - 1] !== "\n";
|
|
||||||
lastFocused.current = "body";
|
lastFocused.current = "body";
|
||||||
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
|
const caret = el?.selectionStart ?? body.length;
|
||||||
|
// Structured lines never split a sentence — insert after the caret's line.
|
||||||
|
const lineEnd = body.indexOf("\n", caret);
|
||||||
|
const insertAt = lineEnd === -1 ? body.length : lineEnd;
|
||||||
|
const before = body.slice(0, insertAt);
|
||||||
|
const after = body.slice(insertAt); // "" or starts with "\n"
|
||||||
|
|
||||||
|
let prefix: string;
|
||||||
|
if (kind === "bullet") {
|
||||||
|
prefix = "- ";
|
||||||
|
} else {
|
||||||
|
// New clause always starts a fresh top-level number. Sub-clause nests
|
||||||
|
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
|
||||||
|
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
|
||||||
|
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
|
||||||
|
const above = parseArticleBody(before);
|
||||||
|
const lastDepth = above.paragraph
|
||||||
|
? 1
|
||||||
|
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
|
||||||
|
const depth =
|
||||||
|
kind === "sub"
|
||||||
|
? lastDepth <= 1
|
||||||
|
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
|
||||||
|
: lastDepth
|
||||||
|
: 1;
|
||||||
|
// Digits are placeholders — renumberBody assigns the real value.
|
||||||
|
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeLines = before.length > 0 ? before.split("\n") : [];
|
||||||
|
const afterLines =
|
||||||
|
after.length > 0 ? after.slice(1).split("\n") : [];
|
||||||
|
const insertedIdx = beforeLines.length;
|
||||||
|
const joined = [...beforeLines, prefix, ...afterLines].join("\n");
|
||||||
|
const next = kind === "bullet" ? joined : renumberBody(joined);
|
||||||
|
setBody(next);
|
||||||
|
|
||||||
|
// Caret lands at the end of the inserted line, ready for typing.
|
||||||
|
const caretTarget = next
|
||||||
|
.split("\n")
|
||||||
|
.slice(0, insertedIdx + 1)
|
||||||
|
.join("\n").length;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const field = bodyRef.current;
|
||||||
|
if (!field) return;
|
||||||
|
field.focus();
|
||||||
|
field.setSelectionRange(caretTarget, caretTarget);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||||
@@ -724,26 +841,60 @@ function ArticleEditorModal({
|
|||||||
))}
|
))}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
<Tooltip label="Start a new numbered clause" withArrow>
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
Add structure
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
<Tooltip
|
||||||
|
label="New line with the next clause number typed for you (1., 2., 3., …)"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<ListOrdered size={13} />}
|
leftSection={<ListOrdered size={13} />}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => insertLinePrefix("")}
|
onClick={() => insertStructuredLine("clause")}
|
||||||
>
|
>
|
||||||
New clause
|
New clause
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Nest a bullet under the previous clause" withArrow>
|
<Tooltip
|
||||||
|
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ListTree size={13} />}
|
||||||
|
disabled={body.trim().length === 0}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertStructuredLine("sub")}
|
||||||
|
>
|
||||||
|
Sub-clause
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
label="New line with a bullet (•) under the current clause"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<ListPlus size={13} />}
|
leftSection={<ListPlus size={13} />}
|
||||||
|
disabled={body.trim().length === 0}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => insertLinePrefix("- ")}
|
onClick={() => insertStructuredLine("bullet")}
|
||||||
>
|
>
|
||||||
Bullet
|
Bullet
|
||||||
</Button>
|
</Button>
|
||||||
@@ -804,10 +955,10 @@ function ArticleEditorModal({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{parsed.clauses.map((clause, i) => (
|
{parsed.clauses.map((clause, i) => (
|
||||||
<Box key={i}>
|
<Box key={i} pl={(clause.depth - 1) * 20}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
<Text component="span" fw={600} c="edr-green.7">
|
<Text component="span" fw={600} c="edr-green.7">
|
||||||
{i + 1}.{" "}
|
{clause.number}.{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<HighlightedText text={clause.text} />
|
<HighlightedText text={clause.text} />
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Menu,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -16,16 +17,18 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Calendar,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
Flag,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
MoreHorizontal,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Send,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShipWheel,
|
ShipWheel,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
@@ -46,17 +49,23 @@ import { PageHeader } from "@/components/page/PageHeader";
|
|||||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import {
|
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
useContractClearanceQueue,
|
|
||||||
useEtClearanceQueue,
|
|
||||||
} from "@/hooks/contracts/useContracts";
|
|
||||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||||
|
import {
|
||||||
|
RequestedCargoChips,
|
||||||
|
summarizeRequestedCargo,
|
||||||
|
} from "@/features/clearance/requestedCargo";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
type QueueTab = "all" | "et" | "shipments";
|
type QueueTab = "all" | "shipments";
|
||||||
|
|
||||||
|
/** Persist the selected queue tab so returning from a detail keeps it. */
|
||||||
|
const QUEUE_TAB_STORAGE_KEY = "edr.clearance.queueTab";
|
||||||
|
|
||||||
interface ClearanceRow {
|
interface ClearanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -197,21 +206,35 @@ export default function ContractClearanceListPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||||
const canCreateBooking = hasPermission(
|
// Creating a booking under a cleared contract is a GL Ethiopia action — never
|
||||||
user,
|
// available to Djibouti GL.
|
||||||
FREIGHT_PERMS.contracts.createBooking,
|
const canCreateBooking =
|
||||||
);
|
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||||
|
!isDjiboutiGl(user);
|
||||||
|
|
||||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
const defaultQueue: QueueTab = canReview ? "all" : "shipments";
|
||||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
const [queueTab, setQueueTab] = useState<QueueTab>(() => {
|
||||||
|
const stored =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.localStorage.getItem(QUEUE_TAB_STORAGE_KEY)
|
||||||
|
: null;
|
||||||
|
return stored === "all" || stored === "shipments" ? stored : defaultQueue;
|
||||||
|
});
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [view, setView] = useState<ViewMode>("table");
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
|
const selectQueueTab = useCallback((tab: QueueTab) => {
|
||||||
|
setQueueTab(tab);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(QUEUE_TAB_STORAGE_KEY, tab);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Contract clearance rows feed both the Contracts tab and the header KPIs, so
|
||||||
|
// they load regardless of the active tab.
|
||||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||||
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments");
|
useContractClearanceQueue(true);
|
||||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
|
||||||
useEtClearanceQueue(queueTab === "et");
|
|
||||||
const {
|
const {
|
||||||
data: bookingQueue,
|
data: bookingQueue,
|
||||||
isLoading: bookingsLoading,
|
isLoading: bookingsLoading,
|
||||||
@@ -220,18 +243,12 @@ export default function ContractClearanceListPage() {
|
|||||||
refetch: refetchBookings,
|
refetch: refetchBookings,
|
||||||
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
||||||
|
|
||||||
const data = queueTab === "et" ? etData : allData;
|
const data = allData;
|
||||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
const isLoading = queueTab === "shipments" ? bookingsLoading : allLoading;
|
||||||
const isError = queueTab === "et" ? etError : allError;
|
const isError = queueTab === "shipments" ? bookingsError : allError;
|
||||||
const isFetching =
|
const isFetching = queueTab === "shipments" ? bookingsFetching : allFetching;
|
||||||
queueTab === "et"
|
|
||||||
? etFetching
|
|
||||||
: queueTab === "shipments"
|
|
||||||
? bookingsFetching
|
|
||||||
: allFetching;
|
|
||||||
const refetch = () => {
|
const refetch = () => {
|
||||||
if (queueTab === "et") void refetchEt();
|
if (queueTab === "shipments") void refetchBookings();
|
||||||
else if (queueTab === "shipments") void refetchBookings();
|
|
||||||
else void refetchAll();
|
else void refetchAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,19 +259,8 @@ export default function ContractClearanceListPage() {
|
|||||||
value: "all",
|
value: "all",
|
||||||
label: (
|
label: (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<ShieldCheck size={15} />
|
<FileText size={15} />
|
||||||
<Box visibleFrom="sm">All</Box>
|
<Box visibleFrom="sm">Contracts</Box>
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (canEt) {
|
|
||||||
opts.push({
|
|
||||||
value: "et",
|
|
||||||
label: (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Flag size={15} />
|
|
||||||
<Box visibleFrom="sm">ET queue</Box>
|
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -273,9 +279,36 @@ export default function ContractClearanceListPage() {
|
|||||||
return opts;
|
return opts;
|
||||||
}, [canReview, canEt]);
|
}, [canReview, canEt]);
|
||||||
|
|
||||||
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
|
// If a persisted/default tab isn't available for this user, fall back to the
|
||||||
|
// first permitted tab.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
queueTabOptions.length > 0 &&
|
||||||
|
!queueTabOptions.some((o) => o.value === queueTab)
|
||||||
|
) {
|
||||||
|
selectQueueTab(queueTabOptions[0].value);
|
||||||
|
}
|
||||||
|
}, [queueTabOptions, queueTab, selectQueueTab]);
|
||||||
|
|
||||||
|
// Shipment requests carry the requested quantities (per container type, or
|
||||||
|
// bulk weight/items). Map them onto the booking rows by createdBookingId so
|
||||||
|
// the queue shows what each shipment was requested for.
|
||||||
|
const { data: requestQueue } = useQuery({
|
||||||
|
queryKey: ["shipment-request-queue"],
|
||||||
|
queryFn: () => contractsService.getBookingRequestQueue(),
|
||||||
|
enabled: queueTab === "shipments",
|
||||||
|
});
|
||||||
|
const requestedByBooking = useMemo(() => {
|
||||||
|
const map = new Map<string, Freight.RequestedShipmentLines>();
|
||||||
|
for (const req of requestQueue ?? []) {
|
||||||
|
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [requestQueue]);
|
||||||
|
|
||||||
|
// GENERAL-contract shipment bookings in per-booking clearance.
|
||||||
const bookingRows = useMemo(() => {
|
const bookingRows = useMemo(() => {
|
||||||
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
const rows: ShipmentBookingRow[] = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
reference: b.reference,
|
reference: b.reference,
|
||||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||||
@@ -284,6 +317,15 @@ export default function ContractClearanceListPage() {
|
|||||||
tradeDirection: b.tradeDirection ?? "—",
|
tradeDirection: b.tradeDirection ?? "—",
|
||||||
freightType: b.freightType ?? "—",
|
freightType: b.freightType ?? "—",
|
||||||
status: b.status,
|
status: b.status,
|
||||||
|
requested: requestedByBooking.get(b.id) ?? null,
|
||||||
|
contractId: b.contractId ?? null,
|
||||||
|
contractReference: b.contractReference ?? null,
|
||||||
|
contractKind: b.contractKind ?? null,
|
||||||
|
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
||||||
|
createdAt: b.createdAt ?? null,
|
||||||
|
// A bare initiated instance has no cargo/price yet — GL still has to create
|
||||||
|
// (complete) the booking.
|
||||||
|
bookingCreated: Number(b.totalAmount ?? 0) > 0,
|
||||||
}));
|
}));
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return rows;
|
if (!q) return rows;
|
||||||
@@ -291,10 +333,12 @@ export default function ContractClearanceListPage() {
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.reference.toLowerCase().includes(q) ||
|
r.reference.toLowerCase().includes(q) ||
|
||||||
r.customerLabel.toLowerCase().includes(q) ||
|
r.customerLabel.toLowerCase().includes(q) ||
|
||||||
|
(r.contractReference ?? "").toLowerCase().includes(q) ||
|
||||||
r.originLabel.toLowerCase().includes(q) ||
|
r.originLabel.toLowerCase().includes(q) ||
|
||||||
r.destinationLabel.toLowerCase().includes(q),
|
r.destinationLabel.toLowerCase().includes(q) ||
|
||||||
|
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [bookingQueue, query]);
|
}, [bookingQueue, query, requestedByBooking]);
|
||||||
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => (data?.items ?? []).map(toClearanceRow),
|
() => (data?.items ?? []).map(toClearanceRow),
|
||||||
@@ -420,7 +464,7 @@ export default function ContractClearanceListPage() {
|
|||||||
id: "go",
|
id: "go",
|
||||||
size: 150,
|
size: 150,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.ready ? (
|
row.original.ready && canCreateBooking ? (
|
||||||
<Group justify="flex-end" pr="xs">
|
<Group justify="flex-end" pr="xs">
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
@@ -444,7 +488,7 @@ export default function ContractClearanceListPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[navigate],
|
[navigate, canCreateBooking],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -464,29 +508,16 @@ export default function ContractClearanceListPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Group gap="sm" wrap="nowrap">
|
<ActionIcon
|
||||||
{canCreateBooking ? (
|
variant="default"
|
||||||
<Button
|
size="lg"
|
||||||
variant="filled"
|
radius="md"
|
||||||
color="edr-green"
|
onClick={() => refetch()}
|
||||||
radius="md"
|
loading={isFetching}
|
||||||
leftSection={<Send size={15} />}
|
aria-label="Refresh"
|
||||||
onClick={() => navigate("/dashboard/shipment-requests")}
|
>
|
||||||
>
|
<RefreshCw size={16} />
|
||||||
Shipment requests
|
</ActionIcon>
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
loading={isFetching}
|
|
||||||
aria-label="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -525,7 +556,7 @@ export default function ContractClearanceListPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
value={queueTab}
|
value={queueTab}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setQueueTab(v as QueueTab);
|
selectQueueTab(v as QueueTab);
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
}}
|
}}
|
||||||
data={queueTabOptions}
|
data={queueTabOptions}
|
||||||
@@ -600,7 +631,16 @@ export default function ContractClearanceListPage() {
|
|||||||
rows={bookingRows}
|
rows={bookingRows}
|
||||||
loading={bookingsLoading}
|
loading={bookingsLoading}
|
||||||
error={bookingsError}
|
error={bookingsError}
|
||||||
|
canCreateBooking={canCreateBooking}
|
||||||
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
|
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
|
||||||
|
onCreateBooking={(row) =>
|
||||||
|
navigate(
|
||||||
|
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onViewContract={(contractId) =>
|
||||||
|
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : view === "table" ? (
|
) : view === "table" ? (
|
||||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||||
@@ -650,8 +690,26 @@ interface ShipmentBookingRow {
|
|||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
freightType: string;
|
freightType: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** Requested quantities from the originating shipment request. */
|
||||||
|
requested: Freight.RequestedShipmentLines | null;
|
||||||
|
/** Contract this shipment booking was created under. */
|
||||||
|
contractId: string | null;
|
||||||
|
contractReference: string | null;
|
||||||
|
contractKind: "ONE_TIME" | "GENERAL" | null;
|
||||||
|
customs: boolean;
|
||||||
|
createdAt: string | null;
|
||||||
|
/** true once GL has actually created (completed) the booking. */
|
||||||
|
bookingCreated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (iso: string | null) => {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
const prettyStatus = (s: string) =>
|
const prettyStatus = (s: string) =>
|
||||||
s
|
s
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -670,13 +728,26 @@ function ShipmentBookingsTable({
|
|||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
canCreateBooking,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onCreateBooking,
|
||||||
|
onViewContract,
|
||||||
}: {
|
}: {
|
||||||
rows: ShipmentBookingRow[];
|
rows: ShipmentBookingRow[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
|
canCreateBooking: boolean;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
|
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||||
|
onViewContract: (contractId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||||
|
const isBookable = (r: ShipmentBookingRow) =>
|
||||||
|
canCreateBooking &&
|
||||||
|
Boolean(r.contractId) &&
|
||||||
|
!r.bookingCreated &&
|
||||||
|
r.status === "CLEARANCE_READY";
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -699,6 +770,28 @@ function ShipmentBookingsTable({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "contract",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<Stack gap={4} py={2}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||||
|
<Text size="sm" fw={500} truncate maw={150}>
|
||||||
|
{r.contractReference ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{r.contractKind ? (
|
||||||
|
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||||
|
{r.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "route",
|
id: "route",
|
||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
@@ -725,6 +818,28 @@ function ShipmentBookingsTable({
|
|||||||
<Badge variant="outline" color="gray" radius="sm">
|
<Badge variant="outline" color="gray" radius="sm">
|
||||||
{prettyStatus(row.original.freightType)}
|
{prettyStatus(row.original.freightType)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<CustomsBadge customs={row.original.customs} />
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "requested",
|
||||||
|
header: () => (
|
||||||
|
<span className={bookingTable.headerCell}>Requested cargo</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<RequestedCargoChips lines={row.original.requested} size="sm" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Created</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Calendar size={13} className="shrink-0 text-muted-foreground" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -732,26 +847,95 @@ function ShipmentBookingsTable({
|
|||||||
id: "status",
|
id: "status",
|
||||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<Group gap={6} wrap="nowrap">
|
||||||
variant="light"
|
<Badge
|
||||||
color={shipmentStatusColor(row.original.status)}
|
variant="light"
|
||||||
radius="sm"
|
color={shipmentStatusColor(row.original.status)}
|
||||||
>
|
radius="sm"
|
||||||
{prettyStatus(row.original.status)}
|
>
|
||||||
</Badge>
|
{prettyStatus(row.original.status)}
|
||||||
),
|
</Badge>
|
||||||
},
|
{row.original.bookingCreated ? (
|
||||||
{
|
<Tooltip label="Booking created by GL Ethiopia" withArrow>
|
||||||
id: "chevron",
|
<Badge
|
||||||
header: "",
|
variant="light"
|
||||||
cell: () => (
|
color="blue"
|
||||||
<Group justify="flex-end" pr="xs">
|
radius="sm"
|
||||||
<ChevronRight size={16} className="text-muted-foreground" />
|
leftSection={<PackagePlus size={11} />}
|
||||||
|
>
|
||||||
|
Booked
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
size: 200,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const bookable = isBookable(r);
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
gap={6}
|
||||||
|
pr="xs"
|
||||||
|
wrap="nowrap"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{bookable ? (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<PackagePlus size={14} />}
|
||||||
|
onClick={() => onCreateBooking(r)}
|
||||||
|
>
|
||||||
|
Create booking
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Row actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
|
||||||
|
Open booking
|
||||||
|
</Menu.Item>
|
||||||
|
{bookable ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<PackagePlus size={14} />}
|
||||||
|
onClick={() => onCreateBooking(r)}
|
||||||
|
>
|
||||||
|
Create booking
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
{r.contractId ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<ExternalLink size={14} />}
|
||||||
|
onClick={() => onViewContract(r.contractId!)}
|
||||||
|
>
|
||||||
|
View contract
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[],
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!loading && !error && rows.length === 0) {
|
if (!loading && !error && rows.length === 0) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -14,9 +14,19 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
|
PackagePlus,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||||
@@ -46,6 +56,7 @@ type GlClearanceDetail =
|
|||||||
reference: string;
|
reference: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
clearance: Freight.ClearanceView;
|
clearance: Freight.ClearanceView;
|
||||||
|
booking: BookingDetail;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||||
@@ -70,6 +81,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
|||||||
reference: booking.reference,
|
reference: booking.reference,
|
||||||
tradeDirection: booking.tradeDirection,
|
tradeDirection: booking.tradeDirection,
|
||||||
clearance,
|
clearance,
|
||||||
|
booking,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,6 +89,8 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
|||||||
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||||
export default function GlClearanceDetailPage() {
|
export default function GlClearanceDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||||
|
|
||||||
@@ -125,6 +139,20 @@ export default function GlClearanceDetailPage() {
|
|||||||
? (data.clearance.vesselDepartureDate ?? null)
|
? (data.clearance.vesselDepartureDate ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// The shipment booking instance backing this clearance (per-booking GENERAL
|
||||||
|
// customs). Bare until GL completes it: no cargo, no price.
|
||||||
|
const shipmentBooking = data.kind === "booking" ? data.booking : null;
|
||||||
|
const bookingCompleted = Number(shipmentBooking?.totalAmount ?? 0) > 0;
|
||||||
|
// Import boundary (DO collected) / export boundary (release) reached →
|
||||||
|
// clearance is ready and GL creates the real booking. Show the create-booking
|
||||||
|
// CTA here so the GL user who finishes the DJ step isn't left without a next
|
||||||
|
// action. Permission-gated so only booking creators (GL Ethiopia) see it.
|
||||||
|
const canCompleteBooking =
|
||||||
|
shipmentBooking?.status === "CLEARANCE_READY" &&
|
||||||
|
Boolean(shipmentBooking?.contractId) &&
|
||||||
|
!bookingCompleted &&
|
||||||
|
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -144,6 +172,7 @@ export default function GlClearanceDetailPage() {
|
|||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{isImport ? (
|
{isImport ? (
|
||||||
<Button
|
<Button
|
||||||
|
variant={canCompleteBooking ? "default" : "filled"}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<Upload size={16} />}
|
||||||
disabled={!canUploadDo}
|
disabled={!canUploadDo}
|
||||||
@@ -153,6 +182,7 @@ export default function GlClearanceDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
variant={canCompleteBooking ? "default" : "filled"}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<Upload size={16} />}
|
||||||
onClick={() => setUploadKind("ro")}
|
onClick={() => setUploadKind("ro")}
|
||||||
@@ -160,6 +190,19 @@ export default function GlClearanceDetailPage() {
|
|||||||
{hasRo ? "Replace RO" : "Upload RO"}
|
{hasRo ? "Replace RO" : "Upload RO"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canCompleteBooking && shipmentBooking ? (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<PackagePlus size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create booking
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -209,7 +252,15 @@ export default function GlClearanceDetailPage() {
|
|||||||
<PhasedClearanceActionPanel
|
<PhasedClearanceActionPanel
|
||||||
contractId={data.kind === "contract" ? id : undefined}
|
contractId={data.kind === "contract" ? id : undefined}
|
||||||
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
||||||
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
|
// For a per-booking instance, "created" means COMPLETED (has
|
||||||
|
// cargo/price), not merely that a booking row exists — a bare
|
||||||
|
// instance is not yet a real booking. Contract-level clearance
|
||||||
|
// keeps its linked-booking signal.
|
||||||
|
bookingCreated={
|
||||||
|
data.kind === "booking"
|
||||||
|
? bookingCompleted
|
||||||
|
: Boolean(linkedBookingId)
|
||||||
|
}
|
||||||
bookingMilestones={
|
bookingMilestones={
|
||||||
data.kind === "booking"
|
data.kind === "booking"
|
||||||
? (data.clearance.milestones ?? [])
|
? (data.clearance.milestones ?? [])
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() {
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Shipment Requests"
|
title="Shipment Requests"
|
||||||
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
|
subtitle="Customer requests to ship under general customs contracts. Each request starts its booking's clearance immediately — complete the booking from the clearance page once it is ready."
|
||||||
meta={
|
meta={
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Warehouse } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
|||||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||||
|
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
@@ -46,6 +47,7 @@ const FleetResourcePage = () => {
|
|||||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||||
|
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||||
|
|
||||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||||
@@ -369,12 +371,25 @@ const FleetResourcePage = () => {
|
|||||||
{config.subtitle}
|
{config.subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
<Group gap="sm">
|
||||||
setEditing(null);
|
{slug === "wagons" ? (
|
||||||
setFormOpen(true);
|
<Button
|
||||||
}}>
|
variant="light"
|
||||||
{config.addLabel}
|
color="edr-green"
|
||||||
</Button>
|
leftSection={<Warehouse size={16} />}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
|
onClick={() => setWagonWorkspaceOpen(true)}
|
||||||
|
>
|
||||||
|
Yard Workspace
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}>
|
||||||
|
{config.addLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||||
@@ -389,31 +404,28 @@ const FleetResourcePage = () => {
|
|||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
filters={
|
filters={
|
||||||
listFilterSelects ? (
|
listFilterSelects ? (
|
||||||
<Group gap="xs" wrap="wrap">
|
<Group gap="sm" wrap="wrap" align="center">
|
||||||
{listFilterSelects.map((filter) => (
|
{listFilterSelects.map((filter) => (
|
||||||
<Group key={filter.key} gap={4} wrap="wrap">
|
<Select
|
||||||
<Text size="xs" fw={500} c="dimmed">{filter.label}:</Text>
|
key={filter.key}
|
||||||
<Group gap={4} wrap="wrap">
|
aria-label={filter.label}
|
||||||
{filter.data.map((option) => (
|
placeholder={filter.data[0]?.label ?? filter.label}
|
||||||
<Button
|
data={filter.data}
|
||||||
key={option.value}
|
value={filter.value}
|
||||||
size="xs"
|
onChange={(value) => {
|
||||||
radius="md"
|
setListFilterValues((prev) => ({
|
||||||
variant={filter.value === option.value ? "filled" : "outline"}
|
...prev,
|
||||||
styles={{ label: { fontWeight: 500 } }}
|
[filter.key]: value ?? "ALL",
|
||||||
onClick={() => {
|
}));
|
||||||
setListFilterValues((prev) => ({
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
...prev,
|
}}
|
||||||
[filter.key]: option.value,
|
size="sm"
|
||||||
}));
|
radius="lg"
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
w={200}
|
||||||
}}
|
searchable={filter.data.length > 8}
|
||||||
>
|
comboboxProps={{ withinPortal: true }}
|
||||||
{option.label}
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||||
</Button>
|
/>
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||||
@@ -586,6 +598,13 @@ const FleetResourcePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{slug === "wagons" ? (
|
||||||
|
<WagonYardWorkspaceModal
|
||||||
|
opened={wagonWorkspaceOpen}
|
||||||
|
onClose={() => setWagonWorkspaceOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{slug === "wagons" ? (
|
{slug === "wagons" ? (
|
||||||
<WagonMovementHistoryModal
|
<WagonMovementHistoryModal
|
||||||
opened={Boolean(historyTarget)}
|
opened={Boolean(historyTarget)}
|
||||||
|
|||||||
@@ -1591,6 +1591,30 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
() => [["wagons"]],
|
() => [["wagons"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
bulkTransfer: endpoint<
|
||||||
|
{ wagonIds: string[]; toYardId: string },
|
||||||
|
{ moved: number }
|
||||||
|
>(
|
||||||
|
"wagons",
|
||||||
|
"bulkTransfer",
|
||||||
|
({ wagonIds, toYardId }) =>
|
||||||
|
wagonService.bulkTransfer(wagonIds, toYardId).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => [["wagons"]],
|
||||||
|
),
|
||||||
|
|
||||||
|
bulkSetStatus: endpoint<
|
||||||
|
{ wagonIds: string[]; status: Wagon["status"] },
|
||||||
|
{ updated: number }
|
||||||
|
>(
|
||||||
|
"wagons",
|
||||||
|
"bulkSetStatus",
|
||||||
|
({ wagonIds, status }) =>
|
||||||
|
wagonService.bulkSetStatus(wagonIds, status).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => [["wagons"]],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
trains: {
|
trains: {
|
||||||
|
|||||||
@@ -502,6 +502,31 @@ export const contractsService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a bare initiated booking instance once its per-booking clearance
|
||||||
|
* is CLEARANCE_READY — same payload as create; the API persists cargo,
|
||||||
|
* prices, invoices, checks the booking window and moves the booking to the
|
||||||
|
* operations queue.
|
||||||
|
*/
|
||||||
|
completeBookingUnderContract: async (
|
||||||
|
id: string,
|
||||||
|
bookingId: string,
|
||||||
|
payload: Freight.CreateBookingUnderContractDto,
|
||||||
|
): Promise<{ id: string; reference: string; warnings?: string[] }> => {
|
||||||
|
const result = await postContract<{
|
||||||
|
booking?: { id: string; reference: string };
|
||||||
|
id?: string;
|
||||||
|
reference?: string;
|
||||||
|
warnings?: string[];
|
||||||
|
}>(C.BOOKINGS_COMPLETE(id, bookingId), payload);
|
||||||
|
const booking = result.booking ?? result;
|
||||||
|
return {
|
||||||
|
id: booking.id ?? "",
|
||||||
|
reference: booking.reference ?? "",
|
||||||
|
warnings: result.warnings,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-create validation + authoritative price preview: the same
|
* Pre-create validation + authoritative price preview: the same
|
||||||
* BookingPricingService pass that prices the booking on create (rail +
|
* BookingPricingService pass that prices the booking on create (rail +
|
||||||
|
|||||||
@@ -83,4 +83,10 @@ export const wagonService = {
|
|||||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||||
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
||||||
|
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
|
||||||
|
bulkTransfer: (wagonIds: string[], toYardId: string) =>
|
||||||
|
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
|
||||||
|
/** Set the same status on many wagons in a single call. */
|
||||||
|
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) =>
|
||||||
|
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Button, Group, type ButtonProps } from "@mantine/core";
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
type ButtonProps,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -57,7 +64,9 @@ export function ContractCustomerAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "pay") {
|
if (action.type === "pay") {
|
||||||
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
|
return (
|
||||||
|
<PayNowButton booking={action.booking} label={action.label} size={size} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "initiate") {
|
if (action.type === "initiate") {
|
||||||
@@ -118,6 +127,7 @@ export function InitiateBookingButton({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -137,6 +147,7 @@ export function InitiateBookingButton({
|
|||||||
toast.success(
|
toast.success(
|
||||||
"Booking initiated — upload your clearance documents to start the review.",
|
"Booking initiated — upload your clearance documents to start the review.",
|
||||||
);
|
);
|
||||||
|
setConfirmOpen(false);
|
||||||
navigate(`/bookings/${booking.id}`);
|
navigate(`/bookings/${booking.id}`);
|
||||||
},
|
},
|
||||||
onError: (e: Error) =>
|
onError: (e: Error) =>
|
||||||
@@ -144,37 +155,87 @@ export function InitiateBookingButton({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
size={size}
|
<Modal
|
||||||
radius="md"
|
opened={confirmOpen}
|
||||||
h={listStyle ? 34 : undefined}
|
onClose={() => {
|
||||||
variant="filled"
|
if (!mutation.isPending) setConfirmOpen(false);
|
||||||
color="edr-green"
|
}}
|
||||||
fullWidth={fullWidth}
|
centered
|
||||||
leftSection={<Icon size={15} />}
|
radius="lg"
|
||||||
loading={mutation.isPending}
|
size="md"
|
||||||
onClick={(e) => {
|
closeOnClickOutside={!mutation.isPending}
|
||||||
e.stopPropagation();
|
closeOnEscape={!mutation.isPending}
|
||||||
mutation.mutate();
|
withCloseButton={!mutation.isPending}
|
||||||
}}
|
title={
|
||||||
styles={
|
<Group gap={10} wrap="nowrap">
|
||||||
listStyle
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
? {
|
<Icon size={18} />
|
||||||
root: {
|
</ThemeIcon>
|
||||||
fontWeight: 600,
|
<Text fw={700}>Initiate a new booking?</Text>
|
||||||
fontSize: 13,
|
</Group>
|
||||||
paddingInline: 14,
|
}
|
||||||
whiteSpace: "nowrap" as const,
|
>
|
||||||
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
<Text size="sm" c="dimmed">
|
||||||
},
|
This creates a new shipment booking under contract{" "}
|
||||||
}
|
<Text span fw={700} c="#10202F">
|
||||||
: undefined
|
{contract.reference}
|
||||||
}
|
</Text>
|
||||||
fw={listStyle ? undefined : 700}
|
. You'll upload the clearance documents next, and the shipment
|
||||||
fz={listStyle ? undefined : 13}
|
quantity is drawn down from your contract's reserved capacity.
|
||||||
>
|
</Text>
|
||||||
{label}
|
<Group justify="flex-end" gap="sm" mt="lg">
|
||||||
</Button>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setConfirmOpen(false)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
>
|
||||||
|
Yes, initiate booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
radius="md"
|
||||||
|
h={listStyle ? 34 : undefined}
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
leftSection={<Icon size={15} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
styles={
|
||||||
|
listStyle
|
||||||
|
? {
|
||||||
|
root: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
paddingInline: 14,
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
fw={listStyle ? undefined : 700}
|
||||||
|
fz={listStyle ? undefined : 13}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +252,12 @@ export function ContractCustomerActionCell({
|
|||||||
return (
|
return (
|
||||||
<Group gap={8} wrap="nowrap" justify="flex-end">
|
<Group gap={8} wrap="nowrap" justify="flex-end">
|
||||||
{docButton}
|
{docButton}
|
||||||
<ContractCustomerAction contract={contract} bookings={bookings} size="sm" listStyle />
|
<ContractCustomerAction
|
||||||
|
contract={contract}
|
||||||
|
bookings={bookings}
|
||||||
|
size="sm"
|
||||||
|
listStyle
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Freight } from "@edr/types";
|
|||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { isBookingLive } from "@/pages/bookings/BookingDetailPage/utils";
|
||||||
import { ACTIVE_STATUSES } from "./constants";
|
import { ACTIVE_STATUSES } from "./constants";
|
||||||
|
|
||||||
export function useMyPortalData(selectedProfileId?: string) {
|
export function useMyPortalData(selectedProfileId?: string) {
|
||||||
@@ -24,6 +25,17 @@ export function useMyPortalData(selectedProfileId?: string) {
|
|||||||
sortOrder: "DESC",
|
sortOrder: "DESC",
|
||||||
companyProfileId: selectedProfileId,
|
companyProfileId: selectedProfileId,
|
||||||
},
|
},
|
||||||
|
// The home tiles read each booking's status directly, but staff/system
|
||||||
|
// transitions (operations accepting an order, batch selection, clearance
|
||||||
|
// review) never push here. Poll while any booking is still live so those
|
||||||
|
// changes surface — e.g. an accepted order leaving "Operation request
|
||||||
|
// under review" — and stop once everything has settled.
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const items =
|
||||||
|
(query.state.data as { items?: Freight.IBooking[] } | undefined)
|
||||||
|
?.items ?? [];
|
||||||
|
return items.some((b) => isBookingLive(b.status)) ? 30_000 : false;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||||
import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react";
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
|
PackagePlus,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -12,15 +19,19 @@ import { CardTitle, SectionCard } from "./layout";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer-facing clearance section on the booking detail page: a compact
|
* Customer-facing clearance section on the booking detail page: a compact
|
||||||
* status summary with a single action button. The document grid, re-uploads,
|
* status summary with a single action button. The document grid and re-uploads
|
||||||
* and the shipment-day picker all live in the shared {@link BookingActionModal}
|
* live in the shared {@link BookingActionModal} (the same modal the My
|
||||||
* (the same modal the My Shipments list uses), so the flow behaves identically
|
* Shipments list uses); a finished bare instance instead shows a "Book" button
|
||||||
* from both entry points.
|
* that navigates to the booking form.
|
||||||
*/
|
*/
|
||||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const action = getBookingNextAction(booking);
|
const action = getBookingNextAction(booking);
|
||||||
|
// BOOK: clearance finished on a bare instance — go straight to the booking
|
||||||
|
// form (cargo + shipment day + window check) instead of opening the modal.
|
||||||
|
const isBookAction = action?.kind === "BOOK" && Boolean(action.to);
|
||||||
|
|
||||||
if (status === "OPERATION_REQUESTED") {
|
if (status === "OPERATION_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +47,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
const summary =
|
const summary =
|
||||||
status === "CLEARANCE_READY" ? (
|
status === "CLEARANCE_READY" ? (
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||||
Clearance is complete. Pick a shipment day and proceed to operation.
|
{isBookAction
|
||||||
|
? "Clearance is complete. Book your shipment — enter the cargo details and pick a shipment day inside an open booking window."
|
||||||
|
: "Clearance is complete. Pick a shipment day and proceed to operation."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||||
@@ -59,8 +72,16 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<ClipboardList size={16} />}
|
leftSection={
|
||||||
onClick={() => setModalOpen(true)}
|
isBookAction ? (
|
||||||
|
<PackagePlus size={16} />
|
||||||
|
) : (
|
||||||
|
<ClipboardList size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
isBookAction ? navigate(action!.to!) : setModalOpen(true)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -70,15 +91,18 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
{summary}
|
{summary}
|
||||||
|
|
||||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||||
Use “{action?.label ?? "the action button"}” to manage your clearance
|
{isBookAction
|
||||||
documents.
|
? "Use “Book” to enter the cargo details and schedule your shipment."
|
||||||
|
: `Use “${action?.label ?? "the action button"}” to manage your clearance documents.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<BookingActionModal
|
{!isBookAction && (
|
||||||
booking={booking}
|
<BookingActionModal
|
||||||
opened={modalOpen}
|
booking={booking}
|
||||||
onClose={() => setModalOpen(false)}
|
opened={modalOpen}
|
||||||
/>
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { Check, MoveRight } from "lucide-react";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants";
|
import {
|
||||||
|
ARRIVAL_STAGE,
|
||||||
|
CONTRACT_ARRIVAL_STAGE,
|
||||||
|
CONTRACT_PROGRESS_STAGES,
|
||||||
|
PROGRESS_STAGES,
|
||||||
|
STATUS_MAP,
|
||||||
|
resolveContractStage,
|
||||||
|
resolveStage,
|
||||||
|
} from "../constants";
|
||||||
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
||||||
import { SectionCard } from "./layout";
|
import { SectionCard } from "./layout";
|
||||||
|
|
||||||
@@ -65,20 +73,50 @@ export function StatusHero({
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const status = booking.status;
|
const status = booking.status;
|
||||||
const stage = resolveStage(booking);
|
// Contract-drawdown bookings (initiated under a contract) follow a dedicated
|
||||||
|
// wizard — initiated → submitted → accepted → payment → … — instead of the
|
||||||
|
// direct booking's Request/Approval/Contract stages.
|
||||||
|
const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status);
|
||||||
|
const stages = isContractDrawdown
|
||||||
|
? CONTRACT_PROGRESS_STAGES
|
||||||
|
: PROGRESS_STAGES;
|
||||||
|
const arrivalStage = isContractDrawdown
|
||||||
|
? CONTRACT_ARRIVAL_STAGE
|
||||||
|
: ARRIVAL_STAGE;
|
||||||
|
const stage = isContractDrawdown
|
||||||
|
? resolveContractStage(booking)
|
||||||
|
: resolveStage(booking);
|
||||||
|
// Contract-drawdown instance in the clearance gate: it was INITIATED with one
|
||||||
|
// click (no cargo/date yet), not submitted through the wizard.
|
||||||
|
const isInitiatedInstance =
|
||||||
|
status === "AWAITING_DOCUMENTS" && Boolean(booking.contractId);
|
||||||
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
|
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
|
||||||
// stage from the train's ARRIVED state while staying IN_TRANSIT, so the
|
// stage from the train's ARRIVED state while staying IN_TRANSIT, so the
|
||||||
// headline is overridden here. Bookings with a per-booking journey carry the
|
// headline is overridden here. Bookings with a per-booking journey carry the
|
||||||
// ARRIVED status themselves and use its own STATUS_MAP copy.
|
// ARRIVED status themselves and use its own STATUS_MAP copy.
|
||||||
const cfg =
|
const cfg =
|
||||||
stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE
|
stage === arrivalStage && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE
|
||||||
? {
|
? {
|
||||||
title: "Train arrived at destination",
|
title: "Train arrived at destination",
|
||||||
description:
|
description:
|
||||||
"Your shipment reached its destination yard and is being unloaded and prepared for release.",
|
"Your shipment reached its destination yard and is being unloaded and prepared for release.",
|
||||||
stage,
|
stage,
|
||||||
}
|
}
|
||||||
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
|
: isInitiatedInstance
|
||||||
|
? {
|
||||||
|
title: "Booking initiated — clearance documents needed",
|
||||||
|
description:
|
||||||
|
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
|
||||||
|
stage,
|
||||||
|
}
|
||||||
|
: isContractDrawdown && status === "FULLY_EXECUTED"
|
||||||
|
? {
|
||||||
|
title: "Accepted by operations",
|
||||||
|
description:
|
||||||
|
"Operations accepted your order. Complete payment once your train is selected to secure the slot.",
|
||||||
|
stage,
|
||||||
|
}
|
||||||
|
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
|
||||||
const negative = isNegative(status);
|
const negative = isNegative(status);
|
||||||
const draft = isDraftLike(status);
|
const draft = isDraftLike(status);
|
||||||
|
|
||||||
@@ -121,6 +159,7 @@ export function StatusHero({
|
|||||||
{children ?? (
|
{children ?? (
|
||||||
<ProgressTracker
|
<ProgressTracker
|
||||||
current={stage}
|
current={stage}
|
||||||
|
stages={stages}
|
||||||
tone={draft ? "ink" : "green"}
|
tone={draft ? "ink" : "green"}
|
||||||
negative={negative}
|
negative={negative}
|
||||||
/>
|
/>
|
||||||
@@ -131,13 +170,16 @@ export function StatusHero({
|
|||||||
|
|
||||||
function ProgressTracker({
|
function ProgressTracker({
|
||||||
current,
|
current,
|
||||||
|
stages = PROGRESS_STAGES,
|
||||||
tone = "green",
|
tone = "green",
|
||||||
}: {
|
}: {
|
||||||
current: number;
|
current: number;
|
||||||
|
/** Which stage set to render — direct or contract-drawdown. */
|
||||||
|
stages?: typeof PROGRESS_STAGES;
|
||||||
tone?: "green" | "ink";
|
tone?: "green" | "ink";
|
||||||
negative?: boolean;
|
negative?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const last = PROGRESS_STAGES.length - 1;
|
const last = stages.length - 1;
|
||||||
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
||||||
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
|
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
|
||||||
|
|
||||||
@@ -153,7 +195,7 @@ function ProgressTracker({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-start" style={{ minWidth: 640 }}>
|
<div className="flex items-start" style={{ minWidth: 640 }}>
|
||||||
{PROGRESS_STAGES.map((stage, idx) => {
|
{stages.map((stage, idx) => {
|
||||||
const state =
|
const state =
|
||||||
idx < current ? "done" : idx === current ? "active" : "idle";
|
idx < current ? "done" : idx === current ? "active" : "idle";
|
||||||
const Icon = stage.icon;
|
const Icon = stage.icon;
|
||||||
|
|||||||
@@ -95,6 +95,125 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
|
|||||||
(s) => s.label === "Arrival",
|
(s) => s.label === "Arrival",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress stages for a CONTRACT-DRAWDOWN booking (created under a contract via
|
||||||
|
* initiate → clearance → book). These bookings never pass through the direct
|
||||||
|
* wizard's Request/Approval/Contract stages — the contract is already executed.
|
||||||
|
* Their journey is: initiated (bare instance in clearance) → submitted (booking
|
||||||
|
* completed, sent to operations) → accepted (operations accepted) → payment →
|
||||||
|
* loading → transit → arrival → unloading → complete.
|
||||||
|
*/
|
||||||
|
export const CONTRACT_PROGRESS_STAGES = [
|
||||||
|
{
|
||||||
|
// The instance was initiated with one click and is going through per-booking
|
||||||
|
// clearance (upload → review → ready). One-time drawdowns without clearance
|
||||||
|
// start here too until they are booked.
|
||||||
|
label: "Initiated",
|
||||||
|
icon: FileText,
|
||||||
|
statuses: [
|
||||||
|
"DRAFT",
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
"AWAITING_DOCUMENTS",
|
||||||
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The customer (or GL) completed the booking — cargo + shipment day — and it
|
||||||
|
// is submitted to operations for acceptance.
|
||||||
|
label: "Submitted",
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
statuses: [
|
||||||
|
"OPERATION_REQUEST_PENDING",
|
||||||
|
"OPERATION_CHANGES_REQUESTED",
|
||||||
|
"OPERATION_PRICE_PENDING_CONFIRM",
|
||||||
|
"OPERATION_REQUESTED",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Operations accepted the order — it now sits in the batch holding pool
|
||||||
|
// awaiting a train and its pay window.
|
||||||
|
label: "Accepted",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
statuses: ["FULLY_EXECUTED", "READY_FOR_ASSIGNMENT"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Payment",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
statuses: [
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
"EXPIRED",
|
||||||
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Loading",
|
||||||
|
icon: Ship,
|
||||||
|
statuses: [
|
||||||
|
"PAID",
|
||||||
|
"PNR_GENERATED",
|
||||||
|
"PENDING_CONSOLIDATION",
|
||||||
|
"CONSOLIDATED",
|
||||||
|
"WAGON_ASSIGNED",
|
||||||
|
"INVOICED",
|
||||||
|
"ROAD_DISPATCH_PENDING",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "In Transit",
|
||||||
|
icon: Train,
|
||||||
|
statuses: ["IN_TRANSIT"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Lights up from the assigned train's ARRIVED state while the booking is
|
||||||
|
// still IN_TRANSIT — no status of its own (see resolveContractStage).
|
||||||
|
label: "Arrival",
|
||||||
|
icon: MapPin,
|
||||||
|
statuses: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Unloading",
|
||||||
|
icon: PackageOpen,
|
||||||
|
statuses: ["ARRIVED"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Complete",
|
||||||
|
icon: PackageCheck,
|
||||||
|
statuses: ["COMPLETED", "DELIVERED"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Contract-drawdown Arrival stage index. */
|
||||||
|
export const CONTRACT_ARRIVAL_STAGE = CONTRACT_PROGRESS_STAGES.findIndex(
|
||||||
|
(s) => s.label === "Arrival",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** status → contract-drawdown stage index, derived from the stage array. */
|
||||||
|
const CONTRACT_STAGE_BY_STATUS: Record<string, number> = {};
|
||||||
|
CONTRACT_PROGRESS_STAGES.forEach((stage, index) => {
|
||||||
|
stage.statuses.forEach((status) => {
|
||||||
|
CONTRACT_STAGE_BY_STATUS[status] = index;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract-drawdown stage for a booking, factoring in the assigned train's
|
||||||
|
* status the same way {@link resolveStage} does for direct bookings.
|
||||||
|
*/
|
||||||
|
export function resolveContractStage(booking: {
|
||||||
|
status: string;
|
||||||
|
trainScheduleStatus?: string | null;
|
||||||
|
}): number {
|
||||||
|
if (
|
||||||
|
booking.status === "IN_TRANSIT" &&
|
||||||
|
booking.trainScheduleStatus === "ARRIVED"
|
||||||
|
) {
|
||||||
|
return CONTRACT_ARRIVAL_STAGE;
|
||||||
|
}
|
||||||
|
return CONTRACT_STAGE_BY_STATUS[booking.status] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage for a booking, factoring in the assigned train's operational status:
|
* Stage for a booking, factoring in the assigned train's operational status:
|
||||||
* a booking with per-booking journey data reaches ARRIVED (the Unloading
|
* a booking with per-booking journey data reaches ARRIVED (the Unloading
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Box, Center, Loader, Stack, Text } from "@mantine/core";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import { ChangesRequestedView } from "./ChangesRequestedView";
|
|||||||
import { DraftBookingView } from "./DraftBookingView";
|
import { DraftBookingView } from "./DraftBookingView";
|
||||||
import { PageShell, SectionCard } from "./components/layout";
|
import { PageShell, SectionCard } from "./components/layout";
|
||||||
import { ReadonlyBookingView } from "./ReadonlyBookingView";
|
import { ReadonlyBookingView } from "./ReadonlyBookingView";
|
||||||
import { isDraftLike } from "./utils";
|
import { isBookingLive, isDraftLike } from "./utils";
|
||||||
|
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -21,7 +22,22 @@ export default function BookingDetailPage() {
|
|||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useQuery(
|
} = useQuery(
|
||||||
api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
api.bookings.get.queryOptions({
|
||||||
|
input: { id: id! },
|
||||||
|
enabled: !!id,
|
||||||
|
// Staff/system transitions (operations accepting an order, batch
|
||||||
|
// selection, clearance review, transit) happen without any customer
|
||||||
|
// action and can't push to this open page. Poll while the booking is
|
||||||
|
// still live so those changes surface — e.g. an accepted operation
|
||||||
|
// request leaving the "under review" state — and stop once it settles.
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
isBookingLive(
|
||||||
|
(query.state.data as Freight.IBooking | undefined)?.status,
|
||||||
|
)
|
||||||
|
? 20_000
|
||||||
|
: false,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchBooking = () => {
|
const refetchBooking = () => {
|
||||||
@@ -94,5 +110,7 @@ export default function BookingDetailPage() {
|
|||||||
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <ReadonlyBookingView booking={booking} onBookingUpdated={refetchBooking} />;
|
return (
|
||||||
|
<ReadonlyBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED";
|
|||||||
export const isDraftLike = (s: string) =>
|
export const isDraftLike = (s: string) =>
|
||||||
s === "DRAFT" || s === "CHANGES_REQUESTED";
|
s === "DRAFT" || s === "CHANGES_REQUESTED";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal booking statuses — nothing changes server-side once a booking lands
|
||||||
|
* here, so the customer view has no reason to keep polling.
|
||||||
|
*/
|
||||||
|
const SETTLED_STATUSES = new Set([
|
||||||
|
"COMPLETED",
|
||||||
|
"DELIVERED",
|
||||||
|
"CANCELLED",
|
||||||
|
"REJECTED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while a booking can still change from a staff/system action the customer
|
||||||
|
* did not trigger (operations accepting an order, batch selection, clearance
|
||||||
|
* review, transit progress). Used to poll the customer-facing booking queries so
|
||||||
|
* those transitions surface without a manual reload — e.g. an accepted operation
|
||||||
|
* request flipping out of "under review".
|
||||||
|
*/
|
||||||
|
export const isBookingLive = (s?: string | null) =>
|
||||||
|
!!s && !SETTLED_STATUSES.has(s);
|
||||||
|
|
||||||
export function fmtDate(value?: string | null) {
|
export function fmtDate(value?: string | null) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Box, Button } from "@mantine/core";
|
import { Box, Button } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
PackagePlus,
|
||||||
|
PencilLine,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -19,6 +26,7 @@ const ICON_BY_KIND: Record<
|
|||||||
UPLOAD_DOCUMENTS: Upload,
|
UPLOAD_DOCUMENTS: Upload,
|
||||||
FIX_DOCUMENTS: AlertCircle,
|
FIX_DOCUMENTS: AlertCircle,
|
||||||
SCHEDULE_OPERATION: ArrowRight,
|
SCHEDULE_OPERATION: ArrowRight,
|
||||||
|
BOOK: PackagePlus,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BookingActionButtonProps {
|
interface BookingActionButtonProps {
|
||||||
@@ -39,6 +47,7 @@ export function BookingActionButton({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
}: BookingActionButtonProps) {
|
}: BookingActionButtonProps) {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Staff returned the booking for changes — let the customer update the docs
|
// Staff returned the booking for changes — let the customer update the docs
|
||||||
// they submitted and resubmit, in place.
|
// they submitted and resubmit, in place.
|
||||||
@@ -49,6 +58,9 @@ export function BookingActionButton({
|
|||||||
|
|
||||||
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
||||||
const label = action ? action.label : "Update & resubmit";
|
const label = action ? action.label : "Update & resubmit";
|
||||||
|
// BOOK navigates to the booking form (cargo + day + window check) — the
|
||||||
|
// same page a one-time booking uses — instead of opening the modal.
|
||||||
|
const navigateTo = action?.kind === "BOOK" ? action.to : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Mantine modals portal to <body>, but React events still bubble through
|
// Mantine modals portal to <body>, but React events still bubble through
|
||||||
@@ -65,7 +77,8 @@ export function BookingActionButton({
|
|||||||
leftSection={<Icon size={14} />}
|
leftSection={<Icon size={14} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
open();
|
if (navigateTo) navigate(navigateTo);
|
||||||
|
else open();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -77,7 +90,7 @@ export function BookingActionButton({
|
|||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : navigateTo ? null : (
|
||||||
<BookingActionModal booking={booking} opened={opened} onClose={close} />
|
<BookingActionModal booking={booking} opened={opened} onClose={close} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -48,24 +48,25 @@ function BookingActionModalBody({
|
|||||||
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
|
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// Sized and styled to match the contract clearance modal
|
||||||
|
// (ContractClearanceAction) so both flows read as the same surface.
|
||||||
<Modal
|
<Modal
|
||||||
opened
|
opened
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
centered
|
centered
|
||||||
size={560}
|
size="xl"
|
||||||
radius={16}
|
radius="md"
|
||||||
padding={24}
|
|
||||||
title={
|
title={
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={16} fw={800} c="#10202F">
|
<Text fw={700} fz={16}>
|
||||||
{action?.title ?? "Booking"}
|
{action?.title ?? "Clearance documents"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12} c="dimmed" ff="monospace">
|
<Text fz={12} c="dimmed" ff="monospace">
|
||||||
{reference}
|
{reference}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
styles={{ body: { paddingTop: 8 } }}
|
styles={{ body: { paddingTop: 8 } }}
|
||||||
>
|
>
|
||||||
{flow.isLoading || !flow.clearance ? (
|
{flow.isLoading || !flow.clearance ? (
|
||||||
@@ -106,7 +107,10 @@ function BookingActionModalBody({
|
|||||||
Complete booking
|
Complete booking
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
flow.isReady && (
|
// Customs bare instances await GL completion — no customer
|
||||||
|
// proceed button (the server rejects it anyway).
|
||||||
|
flow.isReady &&
|
||||||
|
!flow.awaitingGlCompletion && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
glDocs,
|
glDocs,
|
||||||
isReady,
|
isReady,
|
||||||
needsCompletion,
|
needsCompletion,
|
||||||
|
awaitingGlCompletion,
|
||||||
canUpload,
|
canUpload,
|
||||||
isInitialUpload,
|
isInitialUpload,
|
||||||
status,
|
status,
|
||||||
@@ -81,9 +82,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||||
{needsCompletion
|
{needsCompletion
|
||||||
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
|
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
|
||||||
: clearance.includesCustoms
|
: awaitingGlCompletion
|
||||||
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
? "Customs clearance is complete. Global Logistics is completing your booking (cargo details and shipment day) — you will be notified when payment is due."
|
||||||
: "Clearance is ready. You can now proceed to operation."}
|
: clearance.includesCustoms
|
||||||
|
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
||||||
|
: "Clearance is ready. You can now proceed to operation."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||||
@@ -211,7 +214,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReady && !needsCompletion && (
|
{isReady && !needsCompletion && !awaitingGlCompletion && (
|
||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
<Text fz="13px" fw={700} c="#10202F" mb={6}>
|
<Text fz="13px" fw={700} c="#10202F" mb={6}>
|
||||||
Choose your shipment day
|
Choose your shipment day
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { Freight } from "@edr/types";
|
|||||||
export type BookingActionKind =
|
export type BookingActionKind =
|
||||||
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
||||||
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
||||||
| "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation
|
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
||||||
|
| "BOOK"; // CLEARANCE_READY bare instance — navigate to the booking form
|
||||||
|
|
||||||
export interface BookingNextAction {
|
export interface BookingNextAction {
|
||||||
kind: BookingActionKind;
|
kind: BookingActionKind;
|
||||||
@@ -17,6 +18,8 @@ export interface BookingNextAction {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Modal title. */
|
/** Modal title. */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Set for navigation actions (BOOK) — the button navigates instead of opening the modal. */
|
||||||
|
to?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||||
@@ -37,6 +40,18 @@ const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActionBooking = Pick<
|
||||||
|
Freight.IBooking,
|
||||||
|
"id" | "status" | "contractId" | "totalAmount" | "customsClearingEnabled"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Initiated instance still carrying no cargo/price (clearance-first flow). */
|
||||||
|
function isBareInstance(booking: ActionBooking): boolean {
|
||||||
|
return (
|
||||||
|
Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the customer's next clearance/operation action for a booking, or
|
* Resolve the customer's next clearance/operation action for a booking, or
|
||||||
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
|
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
|
||||||
@@ -47,8 +62,27 @@ const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
|||||||
* "under review" state when nothing is actually queried.
|
* "under review" state when nothing is actually queried.
|
||||||
*/
|
*/
|
||||||
export function getBookingNextAction(
|
export function getBookingNextAction(
|
||||||
booking: Pick<Freight.IBooking, "status">,
|
booking: ActionBooking,
|
||||||
): BookingNextAction | null {
|
): BookingNextAction | null {
|
||||||
|
if (booking.status === "CLEARANCE_READY" && isBareInstance(booking)) {
|
||||||
|
// Customs (Path B): GL completes the booking — the customer can only view
|
||||||
|
// the finished clearance in the modal.
|
||||||
|
if (booking.customsClearingEnabled) {
|
||||||
|
return {
|
||||||
|
kind: "SCHEDULE_OPERATION",
|
||||||
|
label: "View clearance",
|
||||||
|
title: "Clearance complete",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Non-customs (Path A): straight to the booking form — cargo + shipment
|
||||||
|
// day + window check, the same page a one-time booking uses.
|
||||||
|
return {
|
||||||
|
kind: "BOOK",
|
||||||
|
label: "Book",
|
||||||
|
title: "Book your shipment",
|
||||||
|
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
|
||||||
|
};
|
||||||
|
}
|
||||||
return ACTION_BY_STATUS[booking.status as string] ?? null;
|
return ACTION_BY_STATUS[booking.status as string] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +92,7 @@ export function getBookingNextAction(
|
|||||||
* booking that needs documents updated and resubmitting. Used to decide whether
|
* booking that needs documents updated and resubmitting. Used to decide whether
|
||||||
* to render {@link BookingActionButton}.
|
* to render {@link BookingActionButton}.
|
||||||
*/
|
*/
|
||||||
export function bookingHasInlineAction(
|
export function bookingHasInlineAction(booking: ActionBooking): boolean {
|
||||||
booking: Pick<Freight.IBooking, "status">,
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
booking.status === "CHANGES_REQUESTED" ||
|
booking.status === "CHANGES_REQUESTED" ||
|
||||||
getBookingNextAction(booking) !== null
|
getBookingNextAction(booking) !== null
|
||||||
|
|||||||
@@ -67,16 +67,20 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isReady = status === "CLEARANCE_READY";
|
const isReady = status === "CLEARANCE_READY";
|
||||||
// Bare initiated instance (GENERAL non-customs "Initiate booking"): created
|
// Bare initiated instance: created with no cargo and no price; completion
|
||||||
// with no cargo and no price. Once ready it is COMPLETED on the full booking
|
// (cargo + shipment day + window check) happens on the full booking form.
|
||||||
// form (cargo + shipment day + window check), not date-only proceed.
|
const isBareInstance =
|
||||||
|
Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0);
|
||||||
|
// Non-customs (Path A): the CUSTOMER completes the booking himself.
|
||||||
const needsCompletion =
|
const needsCompletion =
|
||||||
isReady &&
|
isReady && isBareInstance && !clearance?.includesCustoms;
|
||||||
Boolean(booking.contractId) &&
|
|
||||||
!(Number(booking.totalAmount ?? 0) > 0);
|
|
||||||
const completeTo = needsCompletion
|
const completeTo = needsCompletion
|
||||||
? `/contracts/${booking.contractId}/bookings/${booking.id}/complete`
|
? `/contracts/${booking.contractId}/bookings/${booking.id}/complete`
|
||||||
: null;
|
: null;
|
||||||
|
// Customs (Path B): GL completes on the customer's behalf — the customer
|
||||||
|
// just sees that clearance is done and GL is preparing the booking.
|
||||||
|
const awaitingGlCompletion =
|
||||||
|
isReady && isBareInstance && Boolean(clearance?.includesCustoms);
|
||||||
const canUpload =
|
const canUpload =
|
||||||
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||||
// The very first upload (nothing in review yet). Here every required document
|
// The very first upload (nothing in review yet). Here every required document
|
||||||
@@ -158,6 +162,7 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
isReady,
|
isReady,
|
||||||
needsCompletion,
|
needsCompletion,
|
||||||
completeTo,
|
completeTo,
|
||||||
|
awaitingGlCompletion,
|
||||||
canUpload,
|
canUpload,
|
||||||
isInitialUpload,
|
isInitialUpload,
|
||||||
// staged upload state
|
// staged upload state
|
||||||
|
|||||||
@@ -44,9 +44,18 @@ export default function NewShipmentRequestPage() {
|
|||||||
const submit = useMutation({
|
const submit = useMutation({
|
||||||
mutationFn: (dto: Freight.CreateBookingRequestDto) =>
|
mutationFn: (dto: Freight.CreateBookingRequestDto) =>
|
||||||
contractsService.submitBookingRequest(id!, dto),
|
contractsService.submitBookingRequest(id!, dto),
|
||||||
onSuccess: () => {
|
onSuccess: (request) => {
|
||||||
toast.success("Shipment request submitted");
|
// Clearance-first flow: the request auto-initiates a booking instance —
|
||||||
navigate(`/contracts/${id}`);
|
// send the customer straight to it to upload clearance documents.
|
||||||
|
if (request.createdBookingId) {
|
||||||
|
toast.success(
|
||||||
|
"Shipment initiated — upload your clearance documents to start the review.",
|
||||||
|
);
|
||||||
|
navigate(`/bookings/${request.createdBookingId}`);
|
||||||
|
} else {
|
||||||
|
toast.success("Shipment request submitted");
|
||||||
|
navigate(`/contracts/${id}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message || "Could not submit request"),
|
onError: (e: Error) => toast.error(e.message || "Could not submit request"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,21 +27,30 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The next upcoming (not-yet-open) window the customer should come back for —
|
* The next upcoming (not-yet-open) window the customer should come back for —
|
||||||
* the one whose train dispatches soonest, so it lines up with the departure-date
|
* the one that OPENS soonest from now. Two guards matter here:
|
||||||
* ordering of the cards. Returns `null` when nothing upcoming carries an opening
|
* - only openings strictly in the future qualify. A train mid-cycle
|
||||||
* time. (`windowOpensAt` is still required so the banner can name a come-back time.)
|
* (doc-review/payment) still reports the window that already opened and
|
||||||
|
* closed; showing that past time as "next" told customers to come back for
|
||||||
|
* a window that was over.
|
||||||
|
* - ordered by opening time, not departure date — "next window" is the next
|
||||||
|
* moment booking opens, which may belong to a later-departing train.
|
||||||
|
* Returns `null` when nothing upcoming carries a future opening time.
|
||||||
*/
|
*/
|
||||||
export function soonestUpcomingWindow(
|
export function soonestUpcomingWindow(
|
||||||
windows: MyBookingWindow[],
|
windows: MyBookingWindow[],
|
||||||
): MyBookingWindow | null {
|
): MyBookingWindow | null {
|
||||||
|
const now = Date.now();
|
||||||
const upcoming = windows
|
const upcoming = windows
|
||||||
.filter((w) => !w.isOpenNow && w.windowOpensAt)
|
.filter(
|
||||||
.sort((a, b) => {
|
(w) =>
|
||||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
!w.isOpenNow &&
|
||||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
w.windowOpensAt &&
|
||||||
if (da !== db) return da - db;
|
new Date(w.windowOpensAt).getTime() > now,
|
||||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
)
|
||||||
});
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
|
||||||
|
);
|
||||||
return upcoming[0] ?? null;
|
return upcoming[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,12 +254,6 @@ export const contractFormSchema = z
|
|||||||
path: ["cargoTypePath"],
|
path: ["cargoTypePath"],
|
||||||
message: "Select a commodity.",
|
message: "Select a commodity.",
|
||||||
});
|
});
|
||||||
} else if (!data.cargoFreeText?.trim()) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["cargoFreeText"],
|
|
||||||
message: "Describe the cargo.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// GENERAL contracts are uncapped: no quantity cap is collected, so the
|
// GENERAL contracts are uncapped: no quantity cap is collected, so the
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
@@ -202,22 +201,6 @@ export function Step3CargoScope({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{parentId && commodityOptions.length > 0 && (
|
|
||||||
<Controller
|
|
||||||
name="cargoFreeText"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<TextInput
|
|
||||||
{...field}
|
|
||||||
label="Cargo description *"
|
|
||||||
placeholder="e.g. Charcoal, Wheat, etc."
|
|
||||||
error={fieldState.error?.message}
|
|
||||||
radius={10}
|
|
||||||
styles={fieldStyles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user