mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #967 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -9,10 +9,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
* Nullable — non-hazardous contracts leave both null, and contracts created
|
||||
* before this change have no declaration to backfill.
|
||||
*/
|
||||
export class AddContractHazardDeclaration2920000000000
|
||||
export class AddContractHazardDeclaration2960000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddContractHazardDeclaration2920000000000';
|
||||
name = 'AddContractHazardDeclaration2960000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
@@ -9,8 +9,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
* `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the
|
||||
* import arrival date gets its own column rather than overloading it.
|
||||
*/
|
||||
export class AddDoCollectionDates2930000000000 implements MigrationInterface {
|
||||
name = 'AddDoCollectionDates2930000000000';
|
||||
export class AddDoCollectionDates2970000000000 implements MigrationInterface {
|
||||
name = 'AddDoCollectionDates2970000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const table of [
|
||||
@@ -9,8 +9,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
* Nullable: requests submitted before this change fall back to the contract's
|
||||
* own currency, which is exactly what their bookings already used.
|
||||
*/
|
||||
export class AddBookingRequestCurrency2940000000000 implements MigrationInterface {
|
||||
name = 'AddBookingRequestCurrency2940000000000';
|
||||
export class AddBookingRequestCurrency2980000000000 implements MigrationInterface {
|
||||
name = 'AddBookingRequestCurrency2980000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
@@ -224,6 +224,24 @@ export class BookingLifecycleNotifierService {
|
||||
this.inApp(b, 'Operation request accepted', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia created this booking on the customer's behalf. On a customs
|
||||
* (Path B) contract the customer never books themselves, so without this they
|
||||
* would have no signal that their shipment now exists and is priced.
|
||||
*/
|
||||
createdByGlForCustomer(b: Booking): void {
|
||||
const total = Number(b.totalAmount ?? 0);
|
||||
const priced =
|
||||
total > 0
|
||||
? ` The total is ${total.toLocaleString()} ${b.paymentCurrency}.`
|
||||
: '';
|
||||
const msg =
|
||||
`Global Logistics has created shipment ${b.reference} under your contract.${priced} ` +
|
||||
`You can review it in the portal.`;
|
||||
void this.notifyContact(b, msg, 'CREATED BY GL');
|
||||
this.inApp(b, 'Shipment created for you', msg);
|
||||
}
|
||||
|
||||
/** Shipment started → in transit. */
|
||||
inTransit(b: Booking): void {
|
||||
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
|
||||
|
||||
@@ -31,34 +31,21 @@ export class BookingRequestService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Shipment requests exist because on a CUSTOMS contract the customer never
|
||||
* books directly — GL Ethiopia does it for them. The request is how the
|
||||
* customer states what to ship and, now, which currency to be invoiced in.
|
||||
*
|
||||
* GENERAL: each request opens its own per-booking clearance instance.
|
||||
* ONE_TIME: clearance already ran at the contract level, so the request only
|
||||
* records the customer's intent; GL creates the single booking from it.
|
||||
* Only GENERAL contracts that bundle customs use the request → GL → clearance
|
||||
* flow. A ONE_TIME customs contract runs its clearance at the contract level
|
||||
* and GL books it directly, with no customer-facing request step.
|
||||
*/
|
||||
private assertCustomsContract(contract: Contract): void {
|
||||
if (!contract.customsClearingEnabled) {
|
||||
private assertGeneralCustoms(contract: Contract): void {
|
||||
if (
|
||||
contract.contractKind !== 'GENERAL' ||
|
||||
!contract.customsClearingEnabled
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Shipment requests apply only to customs-clearance contracts.',
|
||||
'Shipment requests apply only to general customs-clearance contracts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses in which a ONE_TIME customs contract may take a shipment request:
|
||||
* both signatures are in and the contract is at (or past) its clearance
|
||||
* phase, but GL has not booked yet.
|
||||
*/
|
||||
private static readonly ONE_TIME_REQUESTABLE_STATUSES = [
|
||||
'FULLY_EXECUTED',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
];
|
||||
|
||||
/** Customer submits a shipment request. */
|
||||
async submit(
|
||||
contractId: string,
|
||||
@@ -67,35 +54,13 @@ export class BookingRequestService {
|
||||
): Promise<BookingRequest> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
||||
this.assertCustomsContract(contract);
|
||||
const isOneTime = contract.contractKind === 'ONE_TIME';
|
||||
|
||||
this.assertGeneralCustoms(contract);
|
||||
if (contract.status === 'CONTRACT_CLOSED') {
|
||||
throw new ConflictException(
|
||||
'This contract is completed — the full contracted quantity has been booked.',
|
||||
);
|
||||
}
|
||||
if (isOneTime) {
|
||||
if (
|
||||
!BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes(
|
||||
contract.status,
|
||||
)
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'The contract must be fully executed before requesting its shipment.',
|
||||
);
|
||||
}
|
||||
// A one-time contract carries exactly one shipment, so it carries at most
|
||||
// one open request — otherwise GL sees two conflicting currencies.
|
||||
const open = (await this.repo.findForContract(contractId)).find(
|
||||
(r) => r.status === 'PENDING',
|
||||
);
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Shipment request ${open.reference} is already open on this contract.`,
|
||||
);
|
||||
}
|
||||
} else if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new ConflictException(
|
||||
'The contract must be active before requesting a shipment.',
|
||||
);
|
||||
@@ -129,14 +94,10 @@ export class BookingRequestService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single
|
||||
// shipment is bounded by the contract scope itself, checked when GL books.
|
||||
if (!isOneTime) {
|
||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||
containers: dto.containers,
|
||||
bulk: dto.bulk,
|
||||
});
|
||||
}
|
||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||
containers: dto.containers,
|
||||
bulk: dto.bulk,
|
||||
});
|
||||
|
||||
const requestedLines: Freight.RequestedShipmentLines = isContainer
|
||||
? {
|
||||
@@ -162,17 +123,14 @@ export class BookingRequestService {
|
||||
// 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.
|
||||
// GENERAL: the request immediately opens a BARE booking instance that runs
|
||||
// per-booking phased customs clearance. ONE_TIME: clearance already ran on
|
||||
// the contract, so there is nothing to open — the request stays PENDING
|
||||
// until GL creates the contract's single booking from it.
|
||||
const booking = isOneTime
|
||||
? null
|
||||
: await this.contractBookingService.initiateForShipmentRequest(contract, {
|
||||
contractRouteId: dto.contractRouteId,
|
||||
userId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
});
|
||||
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
||||
contract,
|
||||
{
|
||||
contractRouteId: dto.contractRouteId,
|
||||
userId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
},
|
||||
);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const request = await this.repo.create({
|
||||
@@ -181,8 +139,8 @@ export class BookingRequestService {
|
||||
requestedByUserId: userId ?? null,
|
||||
contractRouteId: dto.contractRouteId ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
status: booking ? 'ACCEPTED' : 'PENDING',
|
||||
createdBookingId: booking?.id ?? null,
|
||||
status: 'ACCEPTED',
|
||||
createdBookingId: booking.id,
|
||||
requestedLines,
|
||||
// Intercity is invoiced in birr whatever the customer picked.
|
||||
paymentCurrency:
|
||||
|
||||
@@ -57,7 +57,10 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{
|
||||
createdToStaff: jest.fn(),
|
||||
createdByGlForCustomer: jest.fn(),
|
||||
} as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -933,6 +933,33 @@ export class ContractBookingService {
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
// On a customs contract the customer never books — GL Ethiopia does it for
|
||||
// them (assertGate enforces that) — so tell them their shipment now exists.
|
||||
//
|
||||
// Gated on the contract, NOT on booking.createdByRole: a GENERAL customs
|
||||
// instance is stamped CUSTOMER when the customer's shipment request opens
|
||||
// it, yet it is GL who later completes it with cargo and a price. Keying on
|
||||
// the role would silently skip exactly that case.
|
||||
//
|
||||
// Sent from here because this is the single funnel every contract booking
|
||||
// passes through exactly once (create, complete, and the deferred
|
||||
// consolidation-pairing replay), and it runs after invoicing so the message
|
||||
// can quote the priced total.
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Never let a notification failure read as a finalize failure — the
|
||||
// booking is already committed by this point.
|
||||
try {
|
||||
const priced = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
this.bookingNotifier.createdByGlForCustomer(priced ?? booking);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not notify the customer that GL created booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,9 @@ import type {
|
||||
} from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* One recorded change between two document snapshots. Granularity is per
|
||||
* article: a body edit is reported as "the body changed", not as a text diff.
|
||||
* One recorded change between two document snapshots. A body edit carries the
|
||||
* text on both sides so the audit trail shows WHAT was rewritten, not merely
|
||||
* that something was — the UI diffs the two strings for display.
|
||||
*/
|
||||
export type ContractDocumentChange =
|
||||
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
||||
@@ -16,7 +17,14 @@ export type ContractDocumentChange =
|
||||
title: string;
|
||||
fromTitle: string;
|
||||
}
|
||||
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
|
||||
| {
|
||||
kind: 'ARTICLE_BODY_CHANGED';
|
||||
articleId: string;
|
||||
title: string;
|
||||
/** Body before / after the edit. Absent on revisions recorded earlier. */
|
||||
fromBody?: string;
|
||||
toBody?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'ARTICLE_REORDERED';
|
||||
articleId: string;
|
||||
@@ -120,6 +128,8 @@ export function diffSnapshots(
|
||||
kind: 'ARTICLE_BODY_CHANGED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
fromBody: previous.body,
|
||||
toBody: article.body,
|
||||
});
|
||||
}
|
||||
if (previous.order !== article.order) {
|
||||
|
||||
@@ -244,6 +244,7 @@ export class ContractTransitionService {
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
user?: TCurrentUser | null,
|
||||
window?: { validFrom?: string | null; validUntil?: string | null },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// The route guard passes on either arm; the contract's freight type decides
|
||||
@@ -260,11 +261,20 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertValidityDaysConfigured(validityDays);
|
||||
// Staff picked an explicit window in the accept dialog — honour it verbatim
|
||||
// (any start, any end). Only the legacy days-only payload is still held to
|
||||
// the admin-configured period list.
|
||||
const picked = window?.validFrom && window?.validUntil;
|
||||
if (!picked) await this.assertValidityDaysConfigured(validityDays);
|
||||
|
||||
const validFrom = new Date();
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
const validFrom = picked ? new Date(window!.validFrom!) : new Date();
|
||||
const validUntil = picked ? new Date(window!.validUntil!) : new Date(validFrom);
|
||||
if (!picked) validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
if (validUntil.getTime() <= validFrom.getTime()) {
|
||||
throw new BadRequestException(
|
||||
'The contract validity end date must be after the start date.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.instantiateApprovalSteps(contract);
|
||||
|
||||
|
||||
@@ -360,6 +360,7 @@ export class ContractsController {
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
user,
|
||||
{ validFrom: dto.validFrom, validUntil: dto.validUntil },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { UpdateContractDocumentDto } from './contract-document.dto';
|
||||
|
||||
@@ -18,6 +25,21 @@ export class AcceptContractDto {
|
||||
@Max(3650)
|
||||
validityDays!: number;
|
||||
|
||||
/**
|
||||
* Explicit validity window picked by staff in the accept dialog. When both are
|
||||
* present they win over `validityDays` (which is then only the derived span)
|
||||
* and the configured-period check is skipped — staff may enter any range.
|
||||
*/
|
||||
@ApiPropertyOptional({ description: 'Validity start (ISO date)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Validity end (ISO date)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validUntil?: string;
|
||||
|
||||
/**
|
||||
* Optional per-contract document override edited by staff in the accept
|
||||
* dialog. When present its articles are frozen onto THIS contract; when
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { diffWords } from "./ArticleBodyDiff";
|
||||
|
||||
/** Rebuild each side from the token stream — the diff must lose nothing. */
|
||||
const rebuild = (
|
||||
tokens: ReturnType<typeof diffWords>,
|
||||
side: "before" | "after",
|
||||
): string =>
|
||||
tokens
|
||||
.filter((t) =>
|
||||
side === "before" ? t.op !== "added" : t.op !== "removed",
|
||||
)
|
||||
.map((t) => t.text)
|
||||
.join("");
|
||||
|
||||
describe("diffWords", () => {
|
||||
it("marks only the words that actually changed", () => {
|
||||
const tokens = diffWords(
|
||||
"The carrier shall deliver within 30 days.",
|
||||
"The carrier shall deliver within 45 days.",
|
||||
);
|
||||
|
||||
expect(tokens.filter((t) => t.op === "removed").map((t) => t.text)).toEqual([
|
||||
"30",
|
||||
]);
|
||||
expect(tokens.filter((t) => t.op === "added").map((t) => t.text)).toEqual([
|
||||
"45",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reconstructs both sides losslessly, whitespace included", () => {
|
||||
const before = "Payment is due\nwithin ten (10) working days.";
|
||||
const after = "Payment is due\nwithin five (5) working days of invoice.";
|
||||
const tokens = diffWords(before, after);
|
||||
|
||||
expect(rebuild(tokens, "before")).toBe(before);
|
||||
expect(rebuild(tokens, "after")).toBe(after);
|
||||
});
|
||||
|
||||
it("reports nothing changed for identical text", () => {
|
||||
const tokens = diffWords("Same clause.", "Same clause.");
|
||||
expect(tokens.every((t) => t.op === "same")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles a body being emptied or written from scratch", () => {
|
||||
expect(rebuild(diffWords("Some clause.", ""), "after")).toBe("");
|
||||
expect(rebuild(diffWords("", "Brand new clause."), "before")).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to a whole-block replace on pathological input", () => {
|
||||
// Past MAX_TOKENS the LCS table is skipped; the change must still be
|
||||
// reported truthfully rather than silently dropped.
|
||||
const before = Array.from({ length: 2000 }, (_, i) => `a${i}`).join(" ");
|
||||
const after = Array.from({ length: 2000 }, (_, i) => `b${i}`).join(" ");
|
||||
const tokens = diffWords(before, after);
|
||||
|
||||
expect(rebuild(tokens, "before")).toBe(before);
|
||||
expect(rebuild(tokens, "after")).toBe(after);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Box, Button, Group, Text } from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
type Op = "same" | "added" | "removed";
|
||||
interface Token {
|
||||
op: Op;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Split on whitespace but KEEP it, so rebuilt text preserves its spacing. */
|
||||
function tokenize(text: string): string[] {
|
||||
return text.split(/(\s+)/).filter((t) => t !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-level diff via the classic LCS table.
|
||||
*
|
||||
* ponytail: O(n·m) time and memory over word counts. Contract articles are
|
||||
* paragraphs (hundreds of words), so this is microseconds; the guard below
|
||||
* bails to a whole-block replace if an article ever gets pathological. Swap in
|
||||
* a real diff library only if that guard starts firing.
|
||||
*/
|
||||
const MAX_TOKENS = 1200;
|
||||
|
||||
export function diffWords(before: string, after: string): Token[] {
|
||||
const a = tokenize(before);
|
||||
const b = tokenize(after);
|
||||
|
||||
if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) {
|
||||
return [
|
||||
{ op: "removed", text: before },
|
||||
{ op: "added", text: after },
|
||||
];
|
||||
}
|
||||
|
||||
// lcs[i][j] = length of the longest common subsequence of a[i:] and b[j:].
|
||||
const lcs: number[][] = Array.from({ length: a.length + 1 }, () =>
|
||||
new Array<number>(b.length + 1).fill(0),
|
||||
);
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
for (let j = b.length - 1; j >= 0; j--) {
|
||||
lcs[i][j] =
|
||||
a[i] === b[j]
|
||||
? lcs[i + 1][j + 1] + 1
|
||||
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const tokens: Token[] = [];
|
||||
// Merge runs of the same op so the output is spans, not one node per word.
|
||||
const push = (op: Op, text: string) => {
|
||||
const last = tokens[tokens.length - 1];
|
||||
if (last && last.op === op) last.text += text;
|
||||
else tokens.push({ op, text });
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] === b[j]) {
|
||||
push("same", a[i]);
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
push("removed", a[i]);
|
||||
i++;
|
||||
} else {
|
||||
push("added", b[j]);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < a.length) push("removed", a[i++]);
|
||||
while (j < b.length) push("added", b[j++]);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const OP_STYLE: Record<Op, React.CSSProperties> = {
|
||||
same: {},
|
||||
added: {
|
||||
background: "var(--mantine-color-teal-1)",
|
||||
color: "var(--mantine-color-teal-9)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
removed: {
|
||||
background: "var(--mantine-color-red-1)",
|
||||
color: "var(--mantine-color-red-9)",
|
||||
borderRadius: 3,
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline before/after of an edited article body: removed words struck through
|
||||
* in red, inserted words highlighted in green. Collapsed by default — a
|
||||
* revision list stays scannable, and the full text is one click away.
|
||||
*/
|
||||
export function ArticleBodyDiff({
|
||||
fromBody,
|
||||
toBody,
|
||||
}: {
|
||||
fromBody: string;
|
||||
toBody: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tokens = useMemo(
|
||||
() => (open ? diffWords(fromBody, toBody) : []),
|
||||
[open, fromBody, toBody],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box mt={4}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
px={4}
|
||||
leftSection={
|
||||
open ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
||||
}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? "Hide changes" : "View changes"}
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<Box
|
||||
mt={6}
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
maxHeight: 320,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
component="div"
|
||||
style={{ whiteSpace: "pre-wrap", lineHeight: 1.6 }}
|
||||
>
|
||||
{tokens.map((token, index) => (
|
||||
<span key={index} style={OP_STYLE[token.op]}>
|
||||
{token.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
<Group gap="md" mt="xs">
|
||||
<Group gap={4}>
|
||||
<Box w={10} h={10} style={{ ...OP_STYLE.removed, borderRadius: 2 }} />
|
||||
<Text size="10px" c="dimmed">
|
||||
Removed
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<Box w={10} h={10} style={{ ...OP_STYLE.added, borderRadius: 2 }} />
|
||||
<Text size="10px" c="dimmed">
|
||||
Added
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -262,9 +262,9 @@ export function ContractActionsToolbar({
|
||||
validityLoading={validityLoading}
|
||||
accepting={mutations.staffAccept.isPending}
|
||||
saving={mutations.updateDocument.isPending}
|
||||
onAccept={(days, snapshot) =>
|
||||
onAccept={(days, snapshot, window) =>
|
||||
mutations.staffAccept.mutate(
|
||||
{ validityDays: days, documentSnapshot: snapshot },
|
||||
{ validityDays: days, documentSnapshot: snapshot, ...window },
|
||||
{ onSuccess: () => setEditorOpen(false) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface ContractDocumentEditorModalProps {
|
||||
onAccept?: (
|
||||
validityDays: number,
|
||||
snapshot: Freight.IContractDocumentSnapshot,
|
||||
window: { validFrom: string; validUntil: string },
|
||||
) => void;
|
||||
onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void;
|
||||
}
|
||||
@@ -184,7 +185,10 @@ export function ContractDocumentEditorModal({
|
||||
(validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (days <= 0) return;
|
||||
onAccept?.(days, snapshot);
|
||||
onAccept?.(days, snapshot, {
|
||||
validFrom: validityStart.toISOString(),
|
||||
validUntil: validityEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
onSaveEdit?.(snapshot);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { History, User } from "lucide-react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
@@ -14,6 +15,7 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ArticleBodyDiff } from "@/components/contracts/ArticleBodyDiff";
|
||||
|
||||
interface ContractRevisionTimelineProps {
|
||||
contractId: string;
|
||||
@@ -182,6 +184,14 @@ export function ContractRevisionTimeline({
|
||||
)}
|
||||
{revision.changes.map((change, index) => {
|
||||
const style = CHANGE_STYLES[change.kind];
|
||||
// A body edit carries both texts (revisions recorded before
|
||||
// that change don't) — show the word-level diff inline.
|
||||
const bodyDiff =
|
||||
change.kind === "ARTICLE_BODY_CHANGED" &&
|
||||
change.fromBody != null &&
|
||||
change.toBody != null
|
||||
? { from: change.fromBody, to: change.toBody }
|
||||
: null;
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
@@ -192,9 +202,17 @@ export function ContractRevisionTimeline({
|
||||
>
|
||||
{style?.label ?? change.kind}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||||
{changeSubject(change)}
|
||||
</Text>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||||
{changeSubject(change)}
|
||||
</Text>
|
||||
{bodyDiff && (
|
||||
<ArticleBodyDiff
|
||||
fromBody={bodyDiff.from}
|
||||
toBody={bodyDiff.to}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -138,11 +138,14 @@ export function useContractMutations(contractId: string) {
|
||||
mutationFn: (payload: {
|
||||
validityDays: number;
|
||||
documentSnapshot?: Freight.IContractDocumentSnapshot;
|
||||
validFrom?: string;
|
||||
validUntil?: string;
|
||||
}) =>
|
||||
contractsService.staffAccept(
|
||||
contractId,
|
||||
payload.validityDays,
|
||||
payload.documentSnapshot,
|
||||
{ validFrom: payload.validFrom, validUntil: payload.validUntil },
|
||||
),
|
||||
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to accept contract")),
|
||||
|
||||
@@ -203,10 +203,12 @@ export const contractsService = {
|
||||
id: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: Freight.IContractDocumentSnapshot,
|
||||
window?: { validFrom?: string; validUntil?: string },
|
||||
) =>
|
||||
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), {
|
||||
validityDays,
|
||||
documentSnapshot,
|
||||
...window,
|
||||
}),
|
||||
|
||||
/** The editable per-contract document draft (snapshot or live template). */
|
||||
|
||||
@@ -81,11 +81,11 @@ export default function NewShipmentRequestPage() {
|
||||
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
const route = contract.routes?.[0];
|
||||
// Customs contracts: GL schedules the shipment during clearance — the
|
||||
// customer only states the quantity (and currency), never picks a date. This
|
||||
// now covers ONE_TIME customs too, where GL likewise books on their behalf.
|
||||
// GENERAL customs contracts: GL schedules the shipment during clearance —
|
||||
// the customer only states the quantity (and billing currency), never a date.
|
||||
const hasCustoms =
|
||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled;
|
||||
contract.contractKind === "GENERAL" &&
|
||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||
|
||||
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
||||
|
||||
@@ -15,18 +15,6 @@ export const TERMINAL_BOOKING_STATUSES = [
|
||||
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||
|
||||
/**
|
||||
* ONE_TIME + customs: statuses where the customer may still submit the shipment
|
||||
* request GL books from. Mirrors the API source of truth in
|
||||
* `booking-request.service.ts`.
|
||||
*/
|
||||
const ONE_TIME_CUSTOMS_REQUESTABLE = [
|
||||
"FULLY_EXECUTED",
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
/** Split bookings in these statuses released their quantity — no remainder to book. */
|
||||
const RELEASING_BOOKING_STATUSES = ["CANCELLED", "REJECTED", "EXPIRED"];
|
||||
|
||||
@@ -61,31 +49,20 @@ export function getContractBookingAction(
|
||||
contract: Freight.IContract,
|
||||
bookings: Freight.IBooking[],
|
||||
): ContractBookingAction {
|
||||
// Customs: the customer never books directly — GL does it for them. The
|
||||
// shipment request is how they say what to ship and which currency to be
|
||||
// invoiced in (the contract itself quotes USD only).
|
||||
if (contract.customsClearingEnabled) {
|
||||
const requestable =
|
||||
contract.contractKind === "GENERAL"
|
||||
? contract.status === "CONTRACT_ACTIVE"
|
||||
: // ONE_TIME: both signatures in, GL has not booked yet. Mirrors
|
||||
// BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.
|
||||
ONE_TIME_CUSTOMS_REQUESTABLE.includes(contract.status);
|
||||
|
||||
// One shipment, one open request — the API rejects a second one, so don't
|
||||
// offer the button while a request is still pending.
|
||||
const hasOpenRequest =
|
||||
contract.contractKind === "ONE_TIME" &&
|
||||
bookings.some((b) => b.contractId === contract.id);
|
||||
|
||||
if (requestable && !hasOpenRequest) {
|
||||
return {
|
||||
kind: "request",
|
||||
to: `/contracts/${contract.id}/shipment-requests/new`,
|
||||
};
|
||||
}
|
||||
return { kind: "none", to: "" };
|
||||
// GENERAL + customs: customer submits a shipment request; GL creates the booking.
|
||||
if (
|
||||
contract.customsClearingEnabled &&
|
||||
contract.contractKind === "GENERAL" &&
|
||||
contract.status === "CONTRACT_ACTIVE"
|
||||
) {
|
||||
return {
|
||||
kind: "request",
|
||||
to: `/contracts/${contract.id}/shipment-requests/new`,
|
||||
};
|
||||
}
|
||||
|
||||
// Other customs (ONE_TIME): booked by GL — no customer action.
|
||||
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
||||
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
||||
|
||||
const to = `/contracts/${contract.id}/bookings/new`;
|
||||
|
||||
@@ -276,7 +276,14 @@ export type IContractDocumentChange =
|
||||
title: string;
|
||||
fromTitle: string;
|
||||
}
|
||||
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
|
||||
| {
|
||||
kind: 'ARTICLE_BODY_CHANGED';
|
||||
articleId: string;
|
||||
title: string;
|
||||
/** Body before / after the edit. Absent on revisions recorded earlier. */
|
||||
fromBody?: string;
|
||||
toBody?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'ARTICLE_REORDERED';
|
||||
articleId: string;
|
||||
|
||||
Reference in New Issue
Block a user