mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
Merge branch 'dev' into dj-franc
This commit is contained in:
@@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod
|
||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||
import { PublicationsModule } from "./modules/publications/publications.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
@@ -231,6 +232,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
LogoSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
PublicationsModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Public document library for the freight portal (PDFs, Markdown write-ups,
|
||||
* PowerPoint decks about the platform), managed from the backoffice. Each row
|
||||
* is one whole file stored in MinIO under `publications/` — a re-upload
|
||||
* replaces the object and the row's file columns, there is no per-version
|
||||
* history table like `support_documents` has.
|
||||
*/
|
||||
export class Publications3850000000000 implements MigrationInterface {
|
||||
name = 'Publications3850000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.publications (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title varchar(200) NOT NULL,
|
||||
description text,
|
||||
category varchar(60),
|
||||
file_key varchar(512) NOT NULL,
|
||||
file_name varchar(255) NOT NULL,
|
||||
file_mime_type varchar(120) NOT NULL,
|
||||
file_size_bytes bigint NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
published boolean NOT NULL DEFAULT true,
|
||||
published_at timestamptz,
|
||||
uploaded_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
// Serves the public list: published rows in display order.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_publications_published_sort
|
||||
ON freight.publications (published, sort_order)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`);
|
||||
}
|
||||
}
|
||||
@@ -70,4 +70,75 @@ describe('BookingsRepository', () => {
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS'));
|
||||
});
|
||||
|
||||
it('findManualConsolidationCandidates offers an odd-20ft partner booked on another day', async () => {
|
||||
const qb = mockQueryBuilder();
|
||||
// Same route/direction, odd 20ft count, but sitting on a different
|
||||
// scheduled_date than the booking being completed. GL completes both halves
|
||||
// onto the date chosen on the form, so this is still a legal partner.
|
||||
qb.getMany.mockResolvedValue([
|
||||
{
|
||||
id: 'partner',
|
||||
reference: 'BK-2026-000303',
|
||||
scheduledDate: new Date('2026-09-02T08:00:00.000Z'),
|
||||
bookingContainers: [{ quantity: 1, containerType: { sizeFt: 20 } }],
|
||||
},
|
||||
]);
|
||||
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||
|
||||
const result = await bookingsRepository.findManualConsolidationCandidates({
|
||||
id: 'own',
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
tradeDirection: 'IMPORT',
|
||||
scheduledDate: new Date('2026-09-01T08:00:00.000Z'),
|
||||
} as Booking);
|
||||
|
||||
expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-000303']);
|
||||
expect(result[0].ft20Quantity).toBe(1);
|
||||
// The booking day must not narrow this list at all.
|
||||
const dateFilters = qb.andWhere.mock.calls.filter(([clause]) =>
|
||||
String(clause).includes('scheduled_date'),
|
||||
);
|
||||
expect(dateFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('findManualConsolidationCandidates falls back to the requested lines before cargo is persisted', async () => {
|
||||
const qb = mockQueryBuilder();
|
||||
// The ordinary state of a CLEARANCE_READY customs booking: container lines
|
||||
// are written by completion, so there are none yet and the accepted booking
|
||||
// request is the only statement of what it will carry.
|
||||
qb.getMany.mockResolvedValue([
|
||||
{ id: 'b-odd', reference: 'BK-2026-001116', bookingContainers: [] },
|
||||
{ id: 'b-even', reference: 'BK-EVEN', bookingContainers: [] },
|
||||
// No request at all — count unknown, so not offerable.
|
||||
{ id: 'b-unknown', reference: 'BK-UNKNOWN', bookingContainers: [] },
|
||||
]);
|
||||
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||
dataSource.getRepository.mockReturnValue({
|
||||
createQueryBuilder: () => ({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
createdBookingId: 'b-odd',
|
||||
requestedLines: { containers: [{ containerSize: '20ft', quantity: 1 }] },
|
||||
},
|
||||
{
|
||||
createdBookingId: 'b-even',
|
||||
requestedLines: { containers: [{ containerSize: '20ft', quantity: 2 }] },
|
||||
},
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await bookingsRepository.findManualConsolidationCandidates({
|
||||
id: 'own',
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
tradeDirection: 'EXPORT',
|
||||
} as Booking);
|
||||
|
||||
expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']);
|
||||
expect(result[0].ft20Quantity).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingRequest } from '../contracts/entities/booking-request.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
@@ -336,19 +337,35 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, but
|
||||
* every row must still be a legal pick: another customs booking on the same
|
||||
* route/direction, riding the same booking day, that is itself carrying an odd
|
||||
* 20ft count. Two odd counts always sum to even, so any pick fills the shared
|
||||
* wagon.
|
||||
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
||||
* always sum to even, so any pick fills the shared wagon.
|
||||
*
|
||||
* A booking whose cargo is not entered yet is NOT a candidate: with no
|
||||
* container lines its 20ft count is unknown, so pairing with it cannot be
|
||||
* shown to fill the wagon. Same rule as
|
||||
* {@link findRebookConsolidationCandidates}.
|
||||
* Deliberately NOT filtered on booking day, unlike
|
||||
* {@link findComplementaryConsolidationPartner} and
|
||||
* {@link findRebookConsolidationCandidates}. Those pair bookings that keep the
|
||||
* dates they already hold, so a mismatched day means two different trains. Here
|
||||
* both halves are completed together by GL in one shot and
|
||||
* `completeConsolidatedPair` writes the SAME operator-chosen scheduled_date and
|
||||
* train to each — see CompleteConsolidatedPairDto — so a partner's stored date
|
||||
* is about to be overwritten and says nothing about whether it can share the
|
||||
* wagon. Filtering on it only hid legal partners whose customs clearance
|
||||
* happened to finish on another day.
|
||||
*
|
||||
* The 20ft count comes from the booking's persisted container lines when it
|
||||
* has them, and otherwise from the accepted booking request that created it.
|
||||
* That fallback is the normal case here, not an edge case: on a customs
|
||||
* contract the container lines are written BY completion, so a booking still
|
||||
* sitting in CLEARANCE_READY — exactly what this list is for — has none yet,
|
||||
* and its requested quantities are the only statement of what it will carry.
|
||||
* Reading only the persisted lines left the picker permanently empty.
|
||||
*
|
||||
* A booking with neither source is still NOT a candidate: its 20ft count is
|
||||
* unknown, so pairing with it cannot be shown to fill the wagon.
|
||||
*/
|
||||
async findManualConsolidationCandidates(
|
||||
booking: Booking,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
): Promise<Array<{ booking: Booking; ft20Quantity: number }>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
@@ -381,30 +398,65 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
],
|
||||
});
|
||||
|
||||
// Same EAT booking day — the pair shares one physical wagon, so it must
|
||||
// board one train. Applied only when this booking has a date of its own;
|
||||
// without one there is no day to match against and route/direction stand
|
||||
// alone, mirroring findComplementaryConsolidationPartner.
|
||||
if (booking.scheduledDate) {
|
||||
qb.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: booking.scheduledDate },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany();
|
||||
|
||||
// Odd-20ft test in memory. A booking with no container lines has an unknown
|
||||
// 20ft count, so it cannot be shown to complete the wagon and is not
|
||||
// offered.
|
||||
return rows.filter((row) => {
|
||||
// Requested 20ft quantities for the rows that carry no persisted cargo yet,
|
||||
// keyed by booking id. One query for the whole page rather than per row.
|
||||
const pendingIds = rows
|
||||
.filter((row) => (row.bookingContainers ?? []).length === 0)
|
||||
.map((row) => row.id);
|
||||
const requested = await this.findRequested20ftByBooking(pendingIds);
|
||||
|
||||
// Odd-20ft test in memory: two 20ft to a wagon, so odd + odd = whole wagons.
|
||||
// The resolved count rides along so callers render the same number this
|
||||
// decision was made on rather than re-deriving it from the empty lines.
|
||||
const candidates: Array<{ booking: Booking; ft20Quantity: number }> = [];
|
||||
for (const row of rows) {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
const ft20 =
|
||||
lines.length > 0
|
||||
? lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0)
|
||||
: requested.get(row.id);
|
||||
// Neither persisted nor requested cargo — the count is unknown, so this
|
||||
// booking cannot be shown to fill the wagon.
|
||||
if (ft20 === undefined) continue;
|
||||
if (ft20 % 2 !== 1) continue;
|
||||
candidates.push({ booking: row, ft20Quantity: ft20 });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 20ft quantity each of `bookingIds` was requested with, from the accepted
|
||||
* booking request that created it. Used to judge bookings whose container
|
||||
* lines are not written yet — on a customs contract that is every booking
|
||||
* before completion. Bookings with no request are absent from the map, which
|
||||
* the caller reads as "unknown", not zero.
|
||||
*/
|
||||
private async findRequested20ftByBooking(
|
||||
bookingIds: string[],
|
||||
): Promise<Map<string, number>> {
|
||||
const byBooking = new Map<string, number>();
|
||||
if (bookingIds.length === 0) return byBooking;
|
||||
|
||||
const requests = await this.dataSource
|
||||
.getRepository(BookingRequest)
|
||||
.createQueryBuilder('r')
|
||||
.where('r.createdBookingId IN (:...bookingIds)', { bookingIds })
|
||||
.getMany();
|
||||
|
||||
for (const request of requests) {
|
||||
if (!request.createdBookingId) continue;
|
||||
// containerSize is free text on the request ('20ft', '20FT'), so parse the
|
||||
// leading number rather than comparing strings.
|
||||
const ft20 = (request.requestedLines?.containers ?? [])
|
||||
.filter((line) => parseInt(String(line.containerSize), 10) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
return ft20 % 2 === 1;
|
||||
});
|
||||
byBooking.set(request.createdBookingId, ft20);
|
||||
}
|
||||
return byBooking;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,52 +165,34 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
).rejects.toThrow(/cannot be consolidated with itself/i);
|
||||
});
|
||||
|
||||
it('offers only bookings whose own 20ft count is odd', async () => {
|
||||
// Two odd counts always sum to even, so an odd partner is exactly what fills
|
||||
// the wagon; an even one would leave the pair partial again.
|
||||
const rows = [
|
||||
{
|
||||
id: 'odd',
|
||||
reference: 'BK-ODD',
|
||||
bookingContainers: [
|
||||
{ quantity: 3, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'even',
|
||||
reference: 'BK-EVEN',
|
||||
bookingContainers: [
|
||||
{ quantity: 4, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
},
|
||||
// Cargo not entered yet — its 20ft count is unknown, so it cannot be
|
||||
// shown to fill the wagon and is not offered.
|
||||
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
|
||||
];
|
||||
|
||||
// Which bookings qualify is the repository's decision (and its own spec's);
|
||||
// what matters here is that the odd 20ft count it resolved survives into the
|
||||
// response. A booking awaiting completion has no container lines of its own,
|
||||
// so re-deriving the count from bookingContainers would report 0 and the
|
||||
// picker would show every candidate as empty.
|
||||
it('reports the 20ft count the repository resolved, not the persisted lines', async () => {
|
||||
const { service } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
|
||||
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
|
||||
// Mirror the repository's in-memory odd filter.
|
||||
rows.filter((row) => {
|
||||
void booking;
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((l) => Number(l.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||
return ft20 % 2 === 1;
|
||||
}),
|
||||
),
|
||||
findManualConsolidationCandidates: jest.fn().mockResolvedValue([
|
||||
{
|
||||
// Cargo not persisted yet — the count came from its booking request.
|
||||
booking: {
|
||||
id: 'odd',
|
||||
reference: 'BK-2026-001116',
|
||||
bookingContainers: [],
|
||||
},
|
||||
ft20Quantity: 1,
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']);
|
||||
expect(candidates[0].ft20Quantity).toBe(3);
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-2026-001116']);
|
||||
expect(candidates[0].ft20Quantity).toBe(1);
|
||||
expect(candidates[0].hasCargo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -660,24 +660,25 @@ export class ContractBookingService {
|
||||
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
|
||||
booking,
|
||||
);
|
||||
return rows.map((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
contractId: row.contractId ?? null,
|
||||
companyName: row.company?.name ?? null,
|
||||
status: row.status,
|
||||
tradeDirection: row.tradeDirection ?? null,
|
||||
originYardId: row.originYardId ?? null,
|
||||
destinationYardId: row.destinationYardId ?? null,
|
||||
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
|
||||
ft20Quantity: lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
hasCargo: lines.length > 0,
|
||||
};
|
||||
});
|
||||
// ft20Quantity comes back from the repository already resolved — persisted
|
||||
// container lines when the booking has them, otherwise the quantities its
|
||||
// booking request was accepted with. Recomputing it here from
|
||||
// bookingContainers would report 0 for every not-yet-completed booking.
|
||||
return rows.map(({ booking: row, ft20Quantity }) => ({
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
contractId: row.contractId ?? null,
|
||||
companyName: row.company?.name ?? null,
|
||||
status: row.status,
|
||||
tradeDirection: row.tradeDirection ?? null,
|
||||
originYardId: row.originYardId ?? null,
|
||||
destinationYardId: row.destinationYardId ?? null,
|
||||
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
|
||||
ft20Quantity,
|
||||
// Cargo is known — from either source — since a candidate with an unknown
|
||||
// count is never offered.
|
||||
hasCargo: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* Metadata fields for `POST /publications`, sent alongside the file as
|
||||
* multipart/form-data — every field arrives as a string, so numeric/boolean
|
||||
* fields need an explicit `@Transform` (global `enableImplicitConversion` is
|
||||
* off, see main.ts).
|
||||
*/
|
||||
export class CreatePublicationDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === undefined || value === "true" || value === true)
|
||||
published?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
|
||||
import { CreatePublicationDto } from "./create-publication.dto";
|
||||
|
||||
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* One document in the freight portal's public library (/publications) — a
|
||||
* PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and
|
||||
* curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs
|
||||
* edited in place, this is a real table of many rows and each upload is a
|
||||
* whole new file — there is no version-history log here, a re-upload just
|
||||
* replaces the file columns (see `PublicationsService.replaceFile`).
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "publications" })
|
||||
@Index(["published", "sortOrder"])
|
||||
export class Publication extends BaseEntity {
|
||||
@Column({ name: "title", type: "varchar", length: 200 })
|
||||
title!: string;
|
||||
|
||||
@Column({ name: "description", type: "text", nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: "category", type: "varchar", length: 60, nullable: true })
|
||||
category?: string | null;
|
||||
|
||||
/** MinIO object key. Never a signed URL — those expire; sign on read instead. */
|
||||
@Column({ name: "file_key", type: "varchar", length: 512 })
|
||||
fileKey!: string;
|
||||
|
||||
/** Original filename, used for the download's Content-Disposition. */
|
||||
@Column({ name: "file_name", type: "varchar", length: 255 })
|
||||
fileName!: string;
|
||||
|
||||
@Column({ name: "file_mime_type", type: "varchar", length: 120 })
|
||||
fileMimeType!: string;
|
||||
|
||||
@Column({ name: "file_size_bytes", type: "bigint" })
|
||||
fileSizeBytes!: number;
|
||||
|
||||
/** Manual ordering in the backoffice list and the public grid. */
|
||||
@Column({ name: "sort_order", type: "integer", default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
/** Unpublish without deleting — hides it from the public list only. */
|
||||
@Column({ name: "published", type: "boolean", default: true })
|
||||
published!: boolean;
|
||||
|
||||
@Column({ name: "published_at", type: "timestamptz", nullable: true })
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Column({ name: "uploaded_by_id", type: "uuid", nullable: true })
|
||||
uploadedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PublicationsService } from "./publications.service";
|
||||
|
||||
/**
|
||||
* The portal's /publications page — a public library of PDFs, Markdown
|
||||
* write-ups and PowerPoint decks about the platform. No login required, same
|
||||
* as /help, /faq and the legal pages: prospects reach it before any account
|
||||
* exists.
|
||||
*/
|
||||
@ApiTags("publications")
|
||||
@Public()
|
||||
@Controller("publications")
|
||||
export class PublicPublicationsController {
|
||||
constructor(private readonly service: PublicationsService) {}
|
||||
|
||||
@Get()
|
||||
// Cheap to serve stale for a few minutes; every anonymous page view hits it.
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
@ApiOperation({ summary: "List published publications for the public library" })
|
||||
list() {
|
||||
return this.service.listPublic();
|
||||
}
|
||||
|
||||
@Get(":id/file")
|
||||
@ApiQuery({
|
||||
name: "download",
|
||||
required: false,
|
||||
description: "Set to 1/true to force a download instead of inline preview.",
|
||||
})
|
||||
@ApiOperation({ summary: "Stream a published publication's file" })
|
||||
async getFile(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.service.getPublishedFileStream(id);
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
|
||||
res.setHeader("Content-Type", record.fileMimeType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`,
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=300");
|
||||
stream.pipe(res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { documentUploadMulterOptions } from "../../common/document-upload.options";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||
import { PublicationsService } from "./publications.service";
|
||||
|
||||
const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||
const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||
|
||||
@ApiTags("publications")
|
||||
@ApiBearerAuth()
|
||||
@Controller("publications")
|
||||
export class PublicationsController {
|
||||
constructor(private readonly service: PublicationsService) {}
|
||||
|
||||
@Get("admin")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "List every publication, published or not" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload a new publication" })
|
||||
create(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: CreatePublicationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.create(file, dto, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({ summary: "Update a publication's title, description, category, order or published state" })
|
||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(":id/file")
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Replace a publication's file" })
|
||||
replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.service.replaceFile(id, file);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({ summary: "Remove a publication" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
import { PublicationsController } from "./publications.controller";
|
||||
import { PublicationsRepository } from "./publications.repository";
|
||||
import { PublicationsService } from "./publications.service";
|
||||
import { PublicPublicationsController } from "./public-publications.controller";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Publication]), MinioModule],
|
||||
controllers: [PublicPublicationsController, PublicationsController],
|
||||
providers: [PublicationsRepository, PublicationsService],
|
||||
exports: [PublicationsService],
|
||||
})
|
||||
export class PublicationsModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
|
||||
@Injectable()
|
||||
export class PublicationsRepository extends BaseRepository<Publication> {
|
||||
constructor(
|
||||
@InjectRepository(Publication)
|
||||
repository: Repository<Publication>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Public list: published rows only, in display order. */
|
||||
findPublished(): Promise<Publication[]> {
|
||||
return this.repository.find({
|
||||
where: { published: true },
|
||||
order: { sortOrder: "ASC", publishedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin list: every row, published or not. */
|
||||
override findAll(): Promise<Publication[]> {
|
||||
return this.repository.find({ order: { sortOrder: "ASC" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
PublicationSummary,
|
||||
PUBLICATION_ALLOWED_MIME_TYPES,
|
||||
PUBLICATION_FILE_PREFIX,
|
||||
} from "@edr/types";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { extname } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
import { PublicationsRepository } from "./publications.repository";
|
||||
|
||||
@Injectable()
|
||||
export class PublicationsService {
|
||||
constructor(
|
||||
private readonly repository: PublicationsRepository,
|
||||
private readonly minio: MinioService,
|
||||
) {}
|
||||
|
||||
private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File {
|
||||
if (!file) throw new BadRequestException("No file uploaded");
|
||||
if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
file: Express.Multer.File | undefined,
|
||||
dto: CreatePublicationDto,
|
||||
actorId: string | null,
|
||||
): Promise<Publication> {
|
||||
this.assertAllowedFile(file);
|
||||
|
||||
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
const published = dto.published ?? true;
|
||||
return this.repository.create({
|
||||
title: dto.title,
|
||||
description: dto.description ?? null,
|
||||
category: dto.category ?? null,
|
||||
fileKey: key,
|
||||
fileName: file.originalname,
|
||||
fileMimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
published,
|
||||
publishedAt: published ? new Date() : null,
|
||||
uploadedById: actorId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePublicationDto): Promise<Publication> {
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
const patch: Partial<Publication> = {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.category !== undefined && { category: dto.category }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
};
|
||||
|
||||
if (dto.published !== undefined && dto.published !== existing.published) {
|
||||
patch.published = dto.published;
|
||||
patch.publishedAt = dto.published ? new Date() : null;
|
||||
}
|
||||
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Publication ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */
|
||||
async replaceFile(id: string, file?: Express.Multer.File): Promise<Publication> {
|
||||
this.assertAllowedFile(file);
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
const updated = await this.repository.update(id, {
|
||||
fileKey: key,
|
||||
fileName: file.originalname,
|
||||
fileMimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
});
|
||||
|
||||
await this.minio.deleteFile(existing.fileKey);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.getByIdOrThrow(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Admin list — every row, published or not. */
|
||||
list(): Promise<Publication[]> {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public list — published rows only. No file URL here: a presigned MinIO
|
||||
* URL isn't reachable from the browser (see `fileViewUrl` in the portal's
|
||||
* `apiConfig.ts`); the portal builds each file's URL itself from `id` via
|
||||
* `GET /publications/:id/file`.
|
||||
*/
|
||||
async listPublic(): Promise<PublicationSummary[]> {
|
||||
const rows = await this.repository.findPublished();
|
||||
return rows.map((row) => this.toSummary(row));
|
||||
}
|
||||
|
||||
private toSummary(row: Publication): PublicationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
category: row.category ?? null,
|
||||
fileName: row.fileName,
|
||||
fileMimeType: row.fileMimeType,
|
||||
fileSizeBytes: Number(row.fileSizeBytes),
|
||||
sortOrder: row.sortOrder,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** For the public/staff file route: streams a published row's bytes. */
|
||||
async getPublishedFileStream(
|
||||
id: string,
|
||||
): Promise<{ stream: Readable; record: Publication }> {
|
||||
const record = await this.repository.findById(id);
|
||||
if (!record || !record.published) {
|
||||
throw new NotFoundException(`Publication ${id} not found`);
|
||||
}
|
||||
return { stream: await this.minio.getFileStream(record.fileKey), record };
|
||||
}
|
||||
|
||||
private async getByIdOrThrow(id: string): Promise<Publication> {
|
||||
const record = await this.repository.findById(id);
|
||||
if (!record) throw new NotFoundException(`Publication ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => {
|
||||
rate?: Rate;
|
||||
pending?: RateChangeRequest | null;
|
||||
applyThrows?: Error;
|
||||
/** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */
|
||||
derived?: Partial<Rate>;
|
||||
previewThrows?: Error;
|
||||
} = {}) => {
|
||||
const rate = opts.rate ?? liveRate();
|
||||
const saved: RateChangeRequest[] = [];
|
||||
@@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => {
|
||||
const rates = {
|
||||
findById: jest.fn(async () => rate),
|
||||
assertUpdateValid: jest.fn(async () => undefined),
|
||||
// Stands in for buildUpdate: it resolves a patch into the full column
|
||||
// set, including columns the form never posts (rateType and friends).
|
||||
previewUpdate: jest.fn(async (_id: string, dto: Record<string, unknown>) => {
|
||||
if (opts.previewThrows) throw opts.previewThrows;
|
||||
return { ...dto, ...(opts.derived ?? {}) } as Partial<Rate>;
|
||||
}),
|
||||
applyApprovedUpdate: jest.fn(async () => {
|
||||
if (opts.applyThrows) throw opts.applyThrows;
|
||||
return rate;
|
||||
@@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => {
|
||||
});
|
||||
|
||||
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
|
||||
const { service, rates } = build();
|
||||
rates.assertUpdateValid.mockRejectedValueOnce(
|
||||
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
|
||||
);
|
||||
// Resolving the patch IS the validation — buildUpdate throws on a bad
|
||||
// unit, so previewUpdate surfaces it at submit time.
|
||||
const { service } = build({
|
||||
previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
|
||||
});
|
||||
await expect(
|
||||
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
|
||||
).rejects.toThrow(/not valid for this rate/);
|
||||
});
|
||||
|
||||
it('shows the approver a bulk switch, which only exists as a derived column', async () => {
|
||||
// The form posts intercityKind: BULK — never stored. The real edit lands
|
||||
// on rateType (+ the cargo/container swap), so that is what the approver
|
||||
// must see. Diffing the raw patch showed an empty change list.
|
||||
const { service } = build({
|
||||
rate: liveRate({
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
appliesTo: 'INTERCITY',
|
||||
containerTypeId: 'ct-1',
|
||||
}),
|
||||
derived: {
|
||||
rateType: 'INTERCITY_BULK',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: 'cargo-9',
|
||||
} as Partial<Rate>,
|
||||
});
|
||||
|
||||
const request = await service.submit({
|
||||
rateId: 'rate-1',
|
||||
update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never,
|
||||
});
|
||||
|
||||
expect(request.payload).toMatchObject({
|
||||
rateType: 'INTERCITY_BULK',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: 'cargo-9',
|
||||
});
|
||||
expect(request.previousValues).toMatchObject({
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
containerTypeId: 'ct-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
|
||||
@@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
/** Backoffice page where both the queue and the rates live. */
|
||||
const RATES_LINK = '/dashboard/rules/rates';
|
||||
|
||||
/** Fields a change request may carry — anything else in the patch is ignored. */
|
||||
/**
|
||||
* Persisted columns an approver is shown a before→after for.
|
||||
*
|
||||
* These are RESOLVED entity columns, not raw form fields: the diff runs
|
||||
* against `RatesService.previewUpdate`, so a change the form expresses through
|
||||
* a non-stored selector still shows up here as the column it actually moves
|
||||
* (a flip to bulk lands on `rateType` + the container/cargo swap).
|
||||
*/
|
||||
const DIFFABLE_FIELDS = [
|
||||
'rateValue',
|
||||
'currency',
|
||||
@@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [
|
||||
'tradeDirection',
|
||||
'containerTypeId',
|
||||
'cargoTypeId',
|
||||
// The container-vs-bulk shape of the rate. Missing here, switching a LIVE
|
||||
// rate to bulk showed the approver an empty change list — the only column
|
||||
// that records the kind is rateType, and the form never posts it directly.
|
||||
'rateType',
|
||||
// Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping
|
||||
// line diffed to nothing.
|
||||
'shippingLineCompanyId',
|
||||
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
|
||||
// diffed to nothing and the submit was refused as "nothing changed".
|
||||
'originYardId',
|
||||
@@ -82,7 +96,11 @@ export class RateChangeRequestsService {
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.changedFieldsOnly(rate, dto.update);
|
||||
// Diff the RESOLVED columns, not the raw patch: the form's cargoKind /
|
||||
// intercityKind selectors are never stored, so a bulk switch only shows up
|
||||
// once the patch is resolved into the columns it moves.
|
||||
const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto);
|
||||
const payload = this.changedFieldsOnly(rate, resolved);
|
||||
if (Object.keys(payload).length === 0) {
|
||||
throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
|
||||
}
|
||||
@@ -98,7 +116,9 @@ export class RateChangeRequestsService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);
|
||||
// previewUpdate above already ran the full validation (it IS buildUpdate),
|
||||
// so re-validating here would only repeat it — and the trimmed payload is
|
||||
// resolved columns, not a form patch, so it is not the right input for it.
|
||||
|
||||
const request = await this.repo.save(
|
||||
this.repo.create({
|
||||
@@ -186,13 +206,14 @@ export class RateChangeRequestsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only fields the requester actually changed. A form posts every field
|
||||
* back, so without this the diff would list untouched values as changes.
|
||||
* Keep only columns the edit actually moves. `buildUpdate` returns a full
|
||||
* resolved column set (it re-derives scope on every patch), so without this
|
||||
* the diff would list every untouched column as a change.
|
||||
*/
|
||||
private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
|
||||
private changedFieldsOnly(rate: Rate, resolved: Partial<Rate>): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const field of DIFFABLE_FIELDS) {
|
||||
const proposed = (update as Record<string, unknown>)[field];
|
||||
const proposed = (resolved as Record<string, unknown>)[field];
|
||||
if (proposed === undefined) continue;
|
||||
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
|
||||
patch[field] = proposed;
|
||||
|
||||
@@ -753,6 +753,19 @@ export class RatesService {
|
||||
await this.buildUpdate(await this.findById(id), dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact column changes applying this patch would make, without writing.
|
||||
*
|
||||
* A change request diffs against THIS rather than the raw patch: the form
|
||||
* posts selectors that are never stored (`cargoKind`, `intercityKind`), and
|
||||
* the real edit they encode lands on derived columns — flipping a rate to
|
||||
* bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing
|
||||
* the raw patch missed all of it, so the approver saw an empty change list.
|
||||
*/
|
||||
async previewUpdate(id: string, dto: UpdateRateDto): Promise<Partial<Rate>> {
|
||||
return this.buildUpdate(await this.findById(id), dto);
|
||||
}
|
||||
|
||||
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
|
||||
const updates = await this.buildUpdate(existing, dto);
|
||||
const updated = await this.repository.update(existing.id, updates);
|
||||
|
||||
@@ -867,6 +867,27 @@ export class TrainSchedulingController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/wagons/export")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
|
||||
})
|
||||
async scheduleWagonListExport(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } =
|
||||
await this.trainSchedulingService.scheduleWagonListWorkbook(id);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/export/load-list/document")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })
|
||||
|
||||
@@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
||||
import { TabularExportService } from '../../exports/tabular-export.service';
|
||||
|
||||
/** One line of the schedule wagon-list export (raw SQL projection). */
|
||||
interface ScheduleWagonListRow {
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
containerNumber: string | null;
|
||||
containerSizeFt: number | null;
|
||||
loadType: string | null;
|
||||
status: string | null;
|
||||
bulkCargoDescription: string | null;
|
||||
/** numeric columns arrive as strings from pg. */
|
||||
vgmTons: string | null;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||
@@ -427,6 +446,9 @@ export class TrainSchedulingService {
|
||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||
// spec constructors working; production always has it.
|
||||
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
||||
// Trailing + @Optional so the positional constructors in the existing specs
|
||||
// keep working; production always resolves it from ExportsModule.
|
||||
@Optional() private readonly tabularExport?: TabularExportService,
|
||||
) {}
|
||||
|
||||
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
||||
@@ -3747,6 +3769,132 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule detail page's wagon-list Excel export.
|
||||
*
|
||||
* One row per container (a wagon carrying two boxes yields two rows, repeating
|
||||
* the wagon number) so each container's own VGM is present and totals footable.
|
||||
* Bulk wagons, having no containers, yield a single row carrying the bulk
|
||||
* description and the allocated tonnage as the VGM figure.
|
||||
*
|
||||
* Only wagon slots that actually carry an allocation are listed — empty slots
|
||||
* on the consist are omitted.
|
||||
*/
|
||||
async scheduleWagonListWorkbook(
|
||||
scheduleId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.tabularExport) {
|
||||
throw new BadRequestException('Tabular export service is unavailable');
|
||||
}
|
||||
|
||||
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
|
||||
// container-less) allocation as one row. `booking_container_units` is joined
|
||||
// on BOTH container number and its booking_container line — container
|
||||
// numbers repeat across bookings, so number alone would multiply rows.
|
||||
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
||||
ci.container_number AS "containerNumber",
|
||||
cit.size_ft AS "containerSizeFt",
|
||||
a.load_type AS "loadType",
|
||||
a.status AS "status",
|
||||
bl.cargo_description AS "bulkCargoDescription",
|
||||
COALESCE(
|
||||
ci.gross_weight_tons,
|
||||
bcu.vgm_tons,
|
||||
bc.vgm_per_unit_tons,
|
||||
a.allocated_weight_tons
|
||||
) AS "vgmTons",
|
||||
COALESCE(by_.label, so.label) AS "originLabel",
|
||||
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
||||
b.reference AS "bookingReference",
|
||||
COALESCE(
|
||||
slc.name,
|
||||
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
||||
c.name
|
||||
) AS "customerName"
|
||||
FROM freight.train_schedules s
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.wagon_booking_allocations a
|
||||
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.bookings b ON b.id = a.booking_id
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
|
||||
LEFT JOIN freight.booking_container bc
|
||||
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container_units bcu
|
||||
ON bcu.container_number = ci.container_number
|
||||
AND bcu.booking_container_id = bc.id
|
||||
AND bcu.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
|
||||
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL
|
||||
ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
// "number" is the printed line number of the sheet, not the wagon sequence —
|
||||
// a two-container wagon occupies two lines, and the reader counts lines.
|
||||
const sheetRows = rows.map((row, index) => ({
|
||||
number: index + 1,
|
||||
wagonNumber: row.wagonNumber ?? '—',
|
||||
containerNumber:
|
||||
row.containerNumber ??
|
||||
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
|
||||
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
|
||||
originLabel: row.originLabel ?? '—',
|
||||
destinationLabel: row.destinationLabel ?? '—',
|
||||
customerName: row.customerName ?? '—',
|
||||
}));
|
||||
|
||||
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
|
||||
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
||||
|
||||
const buffer = await this.tabularExport.toXlsx({
|
||||
title: `Wagons ${reference}`.slice(0, 31),
|
||||
description: `Wagon list for train ${reference}`,
|
||||
label: 'train-schedule:wagon-list',
|
||||
kpis: [
|
||||
{ label: 'Lines', value: sheetRows.length },
|
||||
{
|
||||
label: 'Wagons',
|
||||
value: new Set(rows.map((r) => r.sequenceNo)).size,
|
||||
},
|
||||
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'number', label: 'No.', type: 'number' },
|
||||
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
|
||||
{ key: 'containerNumber', label: 'Container number', type: 'string' },
|
||||
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
|
||||
{ key: 'originLabel', label: 'Origin', type: 'string' },
|
||||
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
|
||||
{ key: 'customerName', label: 'Customer', type: 'string' },
|
||||
],
|
||||
rows: sheetRows,
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { ExportsModule } from '../exports/exports.module';
|
||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FacilityHandlingService } from './facility-handling.service';
|
||||
@@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
UserTradeAccessModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
ExportsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
|
||||
@@ -105,4 +105,18 @@ export class ListWagonsQueryDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
maintenanceTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Window (days) the per-row load/move counts are counted over. Does not filter rows.',
|
||||
default: 90,
|
||||
minimum: 1,
|
||||
maximum: 3650,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
statsWindowDays?: number;
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ export class WagonsService {
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
||||
await this.attachStatusDates(page.items);
|
||||
await this.attachMovementStats(page.items, query.statsWindowDays ?? 90);
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -216,6 +217,56 @@ export class WagonsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-wagon movement rollups for the wagon performance report: when the
|
||||
* wagon last arrived anywhere (the idle clock), and how many loaded / total
|
||||
* moves it made inside `windowDays`. One grouped query per page, in the same
|
||||
* shape as `attachStatusDates` above — never one request per row.
|
||||
*/
|
||||
private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise<void> {
|
||||
if (!wagons.length) return;
|
||||
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
|
||||
const rows: Array<{
|
||||
wagonId: string;
|
||||
lastMovedAt: Date | null;
|
||||
loadsInWindow: string;
|
||||
movesInWindow: string;
|
||||
emptyMovesInWindow: string;
|
||||
}> = await this.dataSource
|
||||
.getRepository(WagonMovement)
|
||||
.createQueryBuilder('m')
|
||||
.select('m.wagon_id', 'wagonId')
|
||||
.addSelect('MAX(m.occurred_at)', 'lastMovedAt')
|
||||
.addSelect(
|
||||
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)',
|
||||
'loadsInWindow',
|
||||
)
|
||||
.addSelect(
|
||||
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)',
|
||||
'emptyMovesInWindow',
|
||||
)
|
||||
.addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow')
|
||||
.where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
|
||||
.setParameters({
|
||||
since,
|
||||
loaded: WagonMovementKind.Loaded,
|
||||
empty: WagonMovementKind.EmptyReposition,
|
||||
})
|
||||
.groupBy('m.wagon_id')
|
||||
.getRawMany();
|
||||
|
||||
const byId = new Map(rows.map((r) => [r.wagonId, r]));
|
||||
for (const w of wagons) {
|
||||
const r = byId.get(w.id);
|
||||
Object.assign(w, {
|
||||
lastMovedAt: r?.lastMovedAt ?? null,
|
||||
loadsInWindow: Number(r?.loadsInWindow ?? 0),
|
||||
movesInWindow: Number(r?.movesInWindow ?? 0),
|
||||
emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Wagon> {
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
@@ -328,11 +379,35 @@ export class WagonsService {
|
||||
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
|
||||
async listMovements(wagonId: string): Promise<WagonMovement[]> {
|
||||
await this.findById(wagonId); // 404 on unknown wagon
|
||||
return this.dataSource.getRepository(WagonMovement).find({
|
||||
const movements = await this.dataSource.getRepository(WagonMovement).find({
|
||||
where: { wagonId },
|
||||
relations: { fromYard: true, toYard: true },
|
||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
await this.attachBookingReferences(movements);
|
||||
return movements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each loaded move's booking to its human reference, so the UI can
|
||||
* show (and link to) "BKG-11284" rather than a raw uuid. One query for the
|
||||
* whole ledger; `wagon_movements` deliberately has no FK to bookings, so
|
||||
* this is a read-time join on primary keys, exactly like the labels in
|
||||
* `wagon-history.service`.
|
||||
*/
|
||||
private async attachBookingReferences(movements: WagonMovement[]): Promise<void> {
|
||||
const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))];
|
||||
if (!ids.length) return;
|
||||
const rows: Array<{ id: string; reference: string }> = await this.dataSource.query(
|
||||
`SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`,
|
||||
[ids],
|
||||
);
|
||||
const byId = new Map(rows.map((r) => [r.id, r.reference]));
|
||||
for (const m of movements) {
|
||||
Object.assign(m, {
|
||||
bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string, userId?: string | null): Promise<void> {
|
||||
|
||||
@@ -2498,6 +2498,11 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:support_content:view",
|
||||
manage: "edr_freight_app:settings:support_content:manage",
|
||||
},
|
||||
// Public /publications library (PDFs, Markdown, PowerPoint), edited from the backoffice.
|
||||
publications: {
|
||||
view: "edr_freight_app:settings:publications:view",
|
||||
manage: "edr_freight_app:settings:publications:manage",
|
||||
},
|
||||
},
|
||||
support: {
|
||||
agentView: "edr_freight_app:support:agent_view",
|
||||
|
||||
@@ -60,8 +60,11 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"
|
||||
import LogoSettingsPage from "./pages/settings/LogoSettingsPage";
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||
import PublicationsPage from "./pages/publications/PublicationsPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage";
|
||||
import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerformanceDetailPage";
|
||||
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||
@@ -232,6 +235,24 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Wagon performance — a read-only executive report beside Overview.
|
||||
Separate from the Fleet Management wagons desk, which owns CRUD. */}
|
||||
<Route
|
||||
path="wagon-performance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
|
||||
<WagonPerformancePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagon-performance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
|
||||
<WagonPerformanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* One drill-down route per overview domain — the old per-tab charts,
|
||||
now each on its own page. Single source of truth for the
|
||||
permission gate is OVERVIEW_DOMAINS, shared with the summary
|
||||
@@ -1173,6 +1194,19 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="publications"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.settings.publications.view,
|
||||
FREIGHT_PERMS.settings.publications.manage,
|
||||
]}
|
||||
>
|
||||
<PublicationsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
|
||||
@@ -2007,7 +2007,7 @@ export default function GlCreateBookingForm() {
|
||||
shared wagon — each booking is still priced and invoiced
|
||||
separately.
|
||||
</Text>
|
||||
<Switch
|
||||
{/* <Switch
|
||||
checked={consolidateOdd}
|
||||
color="edr-green"
|
||||
label="Share a wagon with another booking"
|
||||
@@ -2015,7 +2015,7 @@ export default function GlCreateBookingForm() {
|
||||
consolidateTouchedRef.current = true;
|
||||
setConsolidateOdd(e.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
{consolidateOdd ? (
|
||||
<Group gap={10} align="center" wrap="wrap">
|
||||
<Button
|
||||
|
||||
@@ -18,8 +18,11 @@ import type { ConsolidationCandidate } from "@/services/contracts.service";
|
||||
/**
|
||||
* Picker for the booking that shares this booking's wagon. The server has
|
||||
* already narrowed the list to bookings that can legally pair — same route and
|
||||
* direction, same booking day, customs clearing, an odd 20ft count of their own
|
||||
* and not already linked to someone else — so every row here is a valid choice.
|
||||
* direction, customs clearing, an odd 20ft count of their own and not already
|
||||
* linked to someone else — so every row here is a valid choice. Booking day is
|
||||
* deliberately not part of that filter: both halves are completed together onto
|
||||
* the departure date chosen on this form, so the partner's current date is
|
||||
* overwritten either way.
|
||||
*/
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
@@ -55,8 +58,9 @@ export function ConsolidationPartnerPicker({
|
||||
Pick the parent booking
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Customs bookings on the same route and booking day that also carry
|
||||
an odd number of 20ft containers.
|
||||
Customs bookings on the same route that also carry an odd number
|
||||
of 20ft containers. Both halves ride the departure date you pick on
|
||||
this form.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -84,10 +88,9 @@ export function ConsolidationPartnerPicker({
|
||||
title="No booking available to share this wagon"
|
||||
>
|
||||
<Text fz="sm">
|
||||
No other customs booking on this route and booking day currently
|
||||
carries an odd number of 20ft containers. Either wait for one, or
|
||||
switch the shared-wagon option off and book an even number of 20ft
|
||||
containers.
|
||||
No other customs booking on this route currently carries an odd
|
||||
number of 20ft containers. Either wait for one, or switch the
|
||||
shared-wagon option off and book an even number of 20ft containers.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -116,6 +119,14 @@ export function ConsolidationPartnerPicker({
|
||||
{" · "}
|
||||
{`${candidate.ft20Quantity} × 20ft`}
|
||||
</Text>
|
||||
{/* The partner's current date, shown because completing the
|
||||
pair moves it onto the date chosen on this form. */}
|
||||
{candidate.scheduledDate ? (
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
Currently booked for{" "}
|
||||
{new Date(candidate.scheduledDate).toLocaleDateString()}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
<Button
|
||||
size="xs"
|
||||
|
||||
@@ -69,6 +69,12 @@ export const buildSidebarSections = (
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.overview.view,
|
||||
},
|
||||
{
|
||||
label: "Wagon Performance",
|
||||
href: "/dashboard/wagon-performance",
|
||||
icon: <TrainFront />,
|
||||
permission: FREIGHT_PERMS.wagons.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
@@ -564,6 +570,15 @@ export const buildSidebarSections = (
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Publications",
|
||||
href: "/dashboard/publications",
|
||||
icon: <FileText />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.settings.publications.view,
|
||||
FREIGHT_PERMS.settings.publications.manage,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Audit logs",
|
||||
href: "/dashboard/audit-logs",
|
||||
|
||||
@@ -537,6 +537,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
SCHEDULE_WAGONS_EXPORT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/wagons/export`,
|
||||
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
MARSHALLING_STOPS: (id: string) =>
|
||||
|
||||
@@ -426,6 +426,10 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:support_content:view",
|
||||
manage: "edr_freight_app:settings:support_content:manage",
|
||||
},
|
||||
publications: {
|
||||
view: "edr_freight_app:settings:publications:view",
|
||||
manage: "edr_freight_app:settings:publications:manage",
|
||||
},
|
||||
},
|
||||
staff: {
|
||||
roles: {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export interface DeletePublicationDialogProps {
|
||||
title: string;
|
||||
onConfirm?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function DeletePublicationDialog({
|
||||
title,
|
||||
onConfirm,
|
||||
children,
|
||||
}: DeletePublicationDialogProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">Delete publication?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove{" "}
|
||||
<span className="font-semibold text-slate-900">{title}</span> from the
|
||||
public library. It stops being downloadable immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onConfirm} className="bg-red-600 text-white hover:bg-red-700">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { Publication } from "@edr/types";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Loader2, UploadCloud } from "lucide-react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
export interface EditPublicationDialogProps {
|
||||
mode?: "create" | "edit";
|
||||
publication?: Publication;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ACCEPT =
|
||||
".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
|
||||
export default function EditPublicationDialog({
|
||||
mode = "create",
|
||||
publication,
|
||||
children,
|
||||
}: EditPublicationDialogProps) {
|
||||
const isEdit = mode === "edit";
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState(publication?.title ?? "");
|
||||
const [description, setDescription] = useState(publication?.description ?? "");
|
||||
const [category, setCategory] = useState(publication?.category ?? "");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation(api.publications.create.mutationOptions());
|
||||
const updateMutation = useMutation(api.publications.update.mutationOptions());
|
||||
const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions());
|
||||
const pending =
|
||||
createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending;
|
||||
|
||||
const reset = () => {
|
||||
setTitle(publication?.title ?? "");
|
||||
setDescription(publication?.description ?? "");
|
||||
setCategory(publication?.category ?? "");
|
||||
setFile(null);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
if (!title.trim()) {
|
||||
setError("Title is required.");
|
||||
return;
|
||||
}
|
||||
if (!isEdit && !file) {
|
||||
setError("Choose a file to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
category: category.trim() || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit && publication) {
|
||||
await updateMutation.mutateAsync({ id: publication.id, dto: meta });
|
||||
if (file) {
|
||||
await replaceFileMutation.mutateAsync({
|
||||
id: publication.id,
|
||||
file,
|
||||
onProgress: setProgress,
|
||||
});
|
||||
}
|
||||
} else if (file) {
|
||||
await createMutation.mutateAsync({ file, meta, onProgress: setProgress });
|
||||
}
|
||||
setOpen(false);
|
||||
if (!isEdit) reset();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong. Try again.");
|
||||
} finally {
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{isEdit ? "Edit publication" : "New publication"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update this document's title, description or category, or replace its file."
|
||||
: "Upload a PDF, Markdown or PowerPoint file for the public library."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title *</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. EDR Freight Platform Guide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g. Guides, Reports"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What this document covers…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{isEdit ? "Replace file (optional)" : "File *"}</Label>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="cursor-pointer rounded-xl border-2 border-dashed border-slate-300 px-4 py-6 text-center hover:bg-slate-50"
|
||||
>
|
||||
{progress !== null ? (
|
||||
<p className="text-sm text-slate-500">Uploading… {progress}%</p>
|
||||
) : file ? (
|
||||
<p className="text-sm font-medium text-slate-700">{file.name}</p>
|
||||
) : isEdit && publication ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Currently <span className="font-medium">{publication.fileName}</span> —
|
||||
click to replace
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-slate-500">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
<span className="text-sm">Click to choose a PDF, Markdown or PowerPoint file</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isEdit ? (
|
||||
"Save changes"
|
||||
) : (
|
||||
"Upload"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Publication } from "@edr/types";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { FileText, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { formatBytes, formatDate } from "@/lib/format";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import DeletePublicationDialog from "./DeletePublicationDialog";
|
||||
import EditPublicationDialog from "./EditPublicationDialog";
|
||||
|
||||
/** Short label from a mime type, for the file-type badge. */
|
||||
function fileKindLabel(mime: string): string {
|
||||
if (mime === "application/pdf") return "PDF";
|
||||
if (mime.includes("markdown")) return "Markdown";
|
||||
if (mime.includes("powerpoint") || mime.includes("presentationml")) return "PowerPoint";
|
||||
return "File";
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice admin for the freight portal's public /publications page —
|
||||
* upload, edit, reorder-by-hand and unpublish PDFs, Markdown write-ups and
|
||||
* PowerPoint decks about the platform.
|
||||
*/
|
||||
export default function PublicationsPage() {
|
||||
const { data, isLoading, isError } = useQuery(api.publications.list.queryOptions());
|
||||
const updateMutation = useMutation(api.publications.update.mutationOptions());
|
||||
const removeMutation = useMutation(api.publications.remove.mutationOptions());
|
||||
|
||||
const publications = [...(data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>Publications</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
PDFs, Markdown write-ups and PowerPoint decks shown on the public
|
||||
/publications page — no login required to view them.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<EditPublicationDialog>
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green">
|
||||
New publication
|
||||
</Button>
|
||||
</EditPublicationDialog>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Text c="dimmed">Could not load publications.</Text>
|
||||
) : publications.length === 0 ? (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<FileText size={32} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">No publications yet.</Text>
|
||||
<EditPublicationDialog>
|
||||
<Button variant="light" color="edr-green">
|
||||
Upload the first one
|
||||
</Button>
|
||||
</EditPublicationDialog>
|
||||
</Stack>
|
||||
) : (
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Title</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Published</Table.Th>
|
||||
<Table.Th>Updated</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{publications.map((pub: Publication) => (
|
||||
<Table.Tr key={pub.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{pub.title}
|
||||
</Text>
|
||||
{pub.description ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{pub.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{pub.category ? (
|
||||
<Badge variant="light" color="gray">
|
||||
{pub.category}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="edr-green">
|
||||
{fileKindLabel(pub.fileMimeType)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatBytes(pub.fileSizeBytes)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={pub.published ? "Visible on the public page" : "Hidden from the public page"}>
|
||||
<Switch
|
||||
checked={pub.published}
|
||||
color="edr-green"
|
||||
onChange={(e) =>
|
||||
updateMutation.mutate({
|
||||
id: pub.id,
|
||||
dto: { published: e.currentTarget.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(pub.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<EditPublicationDialog mode="edit" publication={pub}>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</EditPublicationDialog>
|
||||
<DeletePublicationDialog
|
||||
title={pub.title}
|
||||
onConfirm={() => removeMutation.mutate({ id: pub.id })}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</DeletePublicationDialog>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
@@ -27,8 +24,24 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
cargoTypeId: "Cargo type",
|
||||
originYardId: "Origin yard",
|
||||
destinationYardId: "Destination yard",
|
||||
minKm: "From km",
|
||||
maxKm: "To km",
|
||||
baseLiters: "Base liters",
|
||||
rateType: "Rate type",
|
||||
};
|
||||
|
||||
/**
|
||||
* A key the backend diffed but the UI has no label for still names a real
|
||||
* change, so turn "baseLiters" into "Base liters" rather than hiding it.
|
||||
*/
|
||||
const labelFor = (field: string): string =>
|
||||
FIELD_LABELS[field] ??
|
||||
field
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.replace(/\bId\b/, "")
|
||||
.trim();
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
@@ -43,13 +56,16 @@ const fmtValue = (
|
||||
value: unknown,
|
||||
labels?: Record<string, string>,
|
||||
): string => {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
// "Not set" reads as a real before-state; a bare em dash on both sides of the
|
||||
// arrow made a newly-set field look like no change at all.
|
||||
if (value === null || value === undefined || value === "") return "Not set";
|
||||
if (field === "rateValue") {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
||||
}
|
||||
// Yard ids are unreadable — an approver decides on the route, not a UUID.
|
||||
if (field === "originYardId" || field === "destinationYardId") {
|
||||
// Any id is unreadable — an approver decides on "Perishable → Truck", not on
|
||||
// a pair of uuids. Covers yards, cargo types, container types and lines.
|
||||
if (field.endsWith("Id")) {
|
||||
return labels?.[String(value)] ?? String(value);
|
||||
}
|
||||
return String(value).replace(/_/g, " ");
|
||||
@@ -66,13 +82,33 @@ const rateSummary = (r: RateChangeRequest): string => {
|
||||
return parts.join(" · ") || "Rate";
|
||||
};
|
||||
|
||||
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
|
||||
const headline = (r: RateChangeRequest): string | null => {
|
||||
if (!("rateValue" in r.payload)) return null;
|
||||
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
|
||||
const before = fmtValue("rateValue", r.previousValues.rateValue);
|
||||
const after = fmtValue("rateValue", r.payload.rateValue);
|
||||
return `${before} → ${after}${currency ? ` ${currency}` : ""}`;
|
||||
/**
|
||||
* Every change in the request, as readable before→after pairs. The queue must
|
||||
* be scannable without expanding: a cargo or direction change is just as much
|
||||
* the point as a repricing, so it gets the same one-line treatment as the rate.
|
||||
*/
|
||||
const summaryRows = (
|
||||
r: RateChangeRequest,
|
||||
labels?: Record<string, string>,
|
||||
): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => {
|
||||
const currency = String(
|
||||
r.payload.currency ??
|
||||
r.previousValues.currency ??
|
||||
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
||||
"",
|
||||
);
|
||||
// Rate first — it is what most changes are about — then the rest in a stable
|
||||
// order so the same edit always reads the same way.
|
||||
const fields = Object.keys(r.payload).sort((a, b) =>
|
||||
a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b),
|
||||
);
|
||||
return fields.map((field) => ({
|
||||
field,
|
||||
label: labelFor(field),
|
||||
before: fmtValue(field, r.previousValues[field], labels),
|
||||
after: fmtValue(field, r.payload[field], labels),
|
||||
suffix: field === "rateValue" && currency ? ` ${currency}` : "",
|
||||
}));
|
||||
};
|
||||
|
||||
type Decide = UseMutationResult<
|
||||
@@ -87,8 +123,9 @@ interface RateApprovalsSectionProps {
|
||||
canDecide: boolean;
|
||||
approve: Decide;
|
||||
reject: Decide;
|
||||
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
|
||||
yardLabels?: Record<string, string>;
|
||||
/** id → label for every reference a diff can name (yards, cargo/container
|
||||
* types, shipping lines), so a change reads as names, not UUIDs. */
|
||||
refLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,11 +138,8 @@ const RateApprovalsSection = ({
|
||||
canDecide,
|
||||
approve,
|
||||
reject,
|
||||
yardLabels,
|
||||
refLabels,
|
||||
}: RateApprovalsSectionProps) => {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
|
||||
@@ -125,9 +159,8 @@ const RateApprovalsSection = ({
|
||||
|
||||
<Stack gap={8}>
|
||||
{requests.map((r) => {
|
||||
const isOpen = openId === r.id;
|
||||
const fields = Object.keys(r.payload);
|
||||
const summaryLine = headline(r);
|
||||
const rows = summaryRows(r, refLabels);
|
||||
// Only the row being decided shows a spinner — the mutation's
|
||||
// isPending is shared across every row.
|
||||
const busy = decidingId === r.id;
|
||||
@@ -145,39 +178,26 @@ const RateApprovalsSection = ({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{summaryLine ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{rows.map((row) => (
|
||||
<Group key={row.field} gap={6} wrap="wrap" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue("rateValue", r.previousValues.rateValue)}
|
||||
{row.before}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<ArrowRight size={13} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={700} c="edr-green">
|
||||
{fmtValue("rateValue", r.payload.rateValue)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{String(
|
||||
r.payload.currency ??
|
||||
r.previousValues.currency ??
|
||||
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
||||
"",
|
||||
)}
|
||||
{row.after}
|
||||
{row.suffix}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
))}
|
||||
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
||||
{fields.length === 1 ? "field" : "fields"} changed
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() => setOpenId(isOpen ? null : r.id)}
|
||||
>
|
||||
{isOpen ? "Hide details" : "See all changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
||||
{fields.length === 1 ? "field" : "fields"} changed
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{canDecide ? (
|
||||
@@ -190,7 +210,7 @@ const RateApprovalsSection = ({
|
||||
loading={busy && reject.isPending}
|
||||
disabled={busy && approve.isPending}
|
||||
onClick={() =>
|
||||
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
reject.mutate({ id: r.id })
|
||||
}
|
||||
>
|
||||
Reject
|
||||
@@ -202,7 +222,7 @@ const RateApprovalsSection = ({
|
||||
loading={busy && approve.isPending}
|
||||
disabled={busy && reject.isPending}
|
||||
onClick={() =>
|
||||
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
approve.mutate({ id: r.id })
|
||||
}
|
||||
>
|
||||
Approve & apply
|
||||
@@ -217,38 +237,6 @@ const RateApprovalsSection = ({
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Collapse in={isOpen}>
|
||||
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
{fields.map((field) => (
|
||||
<Group key={field} gap={8} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
|
||||
{FIELD_LABELS[field] ?? field}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue(field, r.previousValues[field], yardLabels)}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtValue(field, r.payload[field], yardLabels)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{canDecide ? (
|
||||
<Textarea
|
||||
mt={4}
|
||||
size="xs"
|
||||
autosize
|
||||
minRows={2}
|
||||
label="Decision note (optional)"
|
||||
placeholder="Shown to the requester with your decision"
|
||||
value={notes[r.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -322,9 +322,22 @@ const RuleEngineResourcePage = () => {
|
||||
);
|
||||
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
||||
useYardOptions(usesYardField);
|
||||
const yardLabelById = useMemo(
|
||||
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
|
||||
[yardOptions],
|
||||
/**
|
||||
* Every id a rate diff can name, in one map. A pending change that swaps the
|
||||
* cargo type or the container size stores raw uuids, so without this the
|
||||
* approver reads "a1b2… → c3d4…" instead of "Perishable → Truck".
|
||||
*/
|
||||
const rateRefLabelById = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
...(yardOptions ?? []),
|
||||
...(cargoLeafOptions ?? []),
|
||||
...(containerTypeOptions ?? []),
|
||||
...(shippingLineOptions ?? []),
|
||||
].map((o) => [o.value, o.label]),
|
||||
),
|
||||
[yardOptions, cargoLeafOptions, containerTypeOptions, shippingLineOptions],
|
||||
);
|
||||
const usesApprovalRoleField = Boolean(
|
||||
config?.formFields.some(
|
||||
@@ -868,7 +881,7 @@ const RuleEngineResourcePage = () => {
|
||||
canDecide={canApproveRates}
|
||||
approve={rateChangeWorkflow.approve}
|
||||
reject={rateChangeWorkflow.reject}
|
||||
yardLabels={yardLabelById}
|
||||
refLabels={rateRefLabelById}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Merge,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
History as HistoryIcon,
|
||||
LayoutGrid,
|
||||
@@ -102,6 +103,7 @@ import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWind
|
||||
import { api } from "@/services/api";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
EligibleContainerBooking,
|
||||
@@ -125,6 +127,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const { user: authUser } = useAuth();
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
const [exportingWagons, setExportingWagons] = useState(false);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
||||
const [forceAssign, setForceAssign] = useState(false);
|
||||
@@ -314,6 +317,31 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
|
||||
/** Wagon list (one row per container) as an .xlsx download. */
|
||||
const handleExportWagons = useCallback(async () => {
|
||||
if (!scheduleId) return;
|
||||
setExportingWagons(true);
|
||||
try {
|
||||
const blob =
|
||||
await trainSchedulingService.downloadScheduleWagonsWorkbook(scheduleId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `wagon-list-${schedule?.reference ?? scheduleId}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
// Blob response: the JSON reason rides inside the Blob, so the sync
|
||||
// decoder would surface only "Request failed with status code 400".
|
||||
toast({
|
||||
title: await extractDownloadErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setExportingWagons(false);
|
||||
}
|
||||
}, [scheduleId, schedule?.reference, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
if (!operation) return;
|
||||
@@ -1323,6 +1351,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Load Empty Container
|
||||
</Button>
|
||||
) : null}
|
||||
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
leftSection={<FileSpreadsheet size={14} />}
|
||||
loading={exportingWagons}
|
||||
onClick={() => void handleExportWagons()}
|
||||
>
|
||||
Export wagons
|
||||
</Button>
|
||||
) : null}
|
||||
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||
<Button
|
||||
variant="gradient"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export interface SectionExportButtonProps {
|
||||
/** What this button downloads, e.g. "wagon list" — used in the tooltip and toast. */
|
||||
label: string;
|
||||
/** Runs the download; false means there was nothing to write. */
|
||||
onExport: () => boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel download for one section of the wagon performance report. Sits in the
|
||||
* section's own header, so what it exports is unambiguous — the block it is
|
||||
* attached to, exactly as filtered on screen.
|
||||
*/
|
||||
export function SectionExportButton({
|
||||
label,
|
||||
onExport,
|
||||
disabled,
|
||||
}: SectionExportButtonProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<Tooltip label={`Download ${label} as Excel`}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
aria-label={`Download ${label} as Excel`}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
// The row underneath may navigate; a download must not trigger it.
|
||||
e.stopPropagation();
|
||||
const wrote = onExport();
|
||||
if (!wrote) {
|
||||
toast({
|
||||
title: "Nothing to export",
|
||||
description: `There are no ${label} rows to download yet.`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
/**
|
||||
* Excel download for one section of the wagon performance report.
|
||||
*
|
||||
* Each section on the page exports exactly what is on screen — the same rows,
|
||||
* in the same order, honouring the same filters and date window — so a figure
|
||||
* in the spreadsheet always reconciles with the figure the CEO just read.
|
||||
*
|
||||
* Built client-side from data already in the browser: the report holds the
|
||||
* whole fleet in memory (see WagonPerformancePage), so there is nothing to
|
||||
* re-fetch and no server round-trip.
|
||||
*/
|
||||
|
||||
/** A sheet's worth of rows: ordered column headers plus plain-value records. */
|
||||
export interface SheetSpec {
|
||||
/** Sheet tab name. Excel caps these at 31 chars and forbids : \ / ? * [ ]. */
|
||||
name: string;
|
||||
rows: Array<Record<string, string | number | null>>;
|
||||
}
|
||||
|
||||
/** Excel rejects these in a sheet name, and silently truncates past 31 chars. */
|
||||
const safeSheetName = (name: string): string =>
|
||||
name.replace(/[:\\/?*[\]]/g, "-").slice(0, 31) || "Sheet1";
|
||||
|
||||
/** Widen each column to its longest cell, so nothing opens as ####. */
|
||||
function fitColumns(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
): Array<{ wch: number }> {
|
||||
const headers = Object.keys(rows[0] ?? {});
|
||||
return headers.map((h) => {
|
||||
const longest = rows.reduce((max, row) => {
|
||||
const cell = row[h];
|
||||
const len = cell == null ? 0 : String(cell).length;
|
||||
return len > max ? len : max;
|
||||
}, h.length);
|
||||
// Cap the width so one long note cannot push a column off the screen.
|
||||
return { wch: Math.min(Math.max(longest + 2, 10), 60) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Timestamp suffix so repeated downloads don't overwrite each other. */
|
||||
const stamp = (): string => {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download one or more sheets as a single .xlsx.
|
||||
*
|
||||
* `filenameBase` gets the timestamp and extension appended. Sheets with no
|
||||
* rows are skipped; if that leaves nothing, the download is skipped entirely
|
||||
* and the function returns false so the caller can say so.
|
||||
*/
|
||||
export function downloadSheets(
|
||||
filenameBase: string,
|
||||
sheets: SheetSpec[],
|
||||
): boolean {
|
||||
const populated = sheets.filter((s) => s.rows.length > 0);
|
||||
if (!populated.length) return false;
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
for (const spec of populated) {
|
||||
const sheet = XLSX.utils.json_to_sheet(spec.rows);
|
||||
sheet["!cols"] = fitColumns(spec.rows);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(spec.name));
|
||||
}
|
||||
XLSX.writeFile(workbook, `${filenameBase}-${stamp()}.xlsx`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Single-sheet convenience wrapper — the shape most sections need. */
|
||||
export function downloadSheet(
|
||||
filenameBase: string,
|
||||
sheetName: string,
|
||||
rows: Array<Record<string, string | number | null>>,
|
||||
): boolean {
|
||||
return downloadSheets(filenameBase, [{ name: sheetName, rows }]);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Derived wagon performance figures for the CEO's wagon report.
|
||||
*
|
||||
* Nothing here is stored: every number is computed in the browser from the
|
||||
* ledgers the API already returns — `wagon_movements` (relocations),
|
||||
* `wagon_status_logs` (roster flips) and `wagon_events` (unified history).
|
||||
* Keeping the derivation in one place means the report and the wagon record
|
||||
* can never disagree about what "idle" or "utilisation" means.
|
||||
*
|
||||
* This report is READ-ONLY and lives beside the Overview dashboard. It does
|
||||
* not replace the Fleet Management wagons desk, which owns wagon CRUD.
|
||||
*/
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import type {
|
||||
Wagon,
|
||||
WagonMovementRecord,
|
||||
WagonStatusLog,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Days past which a parked wagon is treated as stranded. */
|
||||
export const IDLE_THRESHOLD_DAYS = 21;
|
||||
|
||||
/** Days off the roster past which a repair is treated as overdue. */
|
||||
export const DOWN_THRESHOLD_DAYS = 30;
|
||||
|
||||
/** Statuses that take a wagon off the earning roster. */
|
||||
export const OFF_ROSTER_STATUSES: Freight.WagonStatus[] = [
|
||||
Freight.WagonStatus.Maintenance,
|
||||
Freight.WagonStatus.Detained,
|
||||
Freight.WagonStatus.OutOfService,
|
||||
];
|
||||
|
||||
export const isOffRoster = (status: Freight.WagonStatus): boolean =>
|
||||
OFF_ROSTER_STATUSES.includes(status);
|
||||
|
||||
/** Whole days between `iso` and now; null when the timestamp is missing. */
|
||||
export function daysSince(iso: string | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
return Math.max(0, Math.floor((Date.now() - t) / DAY_MS));
|
||||
}
|
||||
|
||||
/** Fractional days between two timestamps; `to` null means "still open". */
|
||||
export function daysBetween(
|
||||
from: string | null | undefined,
|
||||
to: string | null | undefined,
|
||||
): number | null {
|
||||
if (!from) return null;
|
||||
const a = new Date(from).getTime();
|
||||
if (Number.isNaN(a)) return null;
|
||||
const b = to ? new Date(to).getTime() : Date.now();
|
||||
if (Number.isNaN(b)) return null;
|
||||
return Math.max(0, (b - a) / DAY_MS);
|
||||
}
|
||||
|
||||
export interface WagonPerformance {
|
||||
/** Days since the wagon last arrived anywhere — the idle clock. */
|
||||
idleDays: number | null;
|
||||
/** Days in the current off-roster spell; null while in service. */
|
||||
downDays: number | null;
|
||||
loads: number;
|
||||
moves: number;
|
||||
emptyMoves: number;
|
||||
manualMoves: number;
|
||||
/** Share of moves that carried cargo, 0–100; null when nothing moved. */
|
||||
loadedShare: number | null;
|
||||
lastMovement: WagonMovementRecord | null;
|
||||
/** Off-roster spells overlapping the window. */
|
||||
spells: number;
|
||||
/** Days off roster inside the window. */
|
||||
downDaysInWindow: number;
|
||||
/** Share of the window spent on the roster, 0–100. */
|
||||
availability: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll one wagon's ledgers up into the figures the report shows.
|
||||
*
|
||||
* `windowDays` bounds loads, moves and downtime. Idle days and the current
|
||||
* down spell are "how long has this been true right now" — never windowed.
|
||||
*/
|
||||
export function computeWagonPerformance(
|
||||
wagon: Pick<Wagon, "status" | "lastMaintenanceAt" | "lastAvailableAt">,
|
||||
movements: WagonMovementRecord[],
|
||||
statusLogs: WagonStatusLog[],
|
||||
windowDays: number,
|
||||
): WagonPerformance {
|
||||
const since = Date.now() - windowDays * DAY_MS;
|
||||
|
||||
// Movements arrive newest-first from the API; don't rely on it.
|
||||
const ordered = [...movements].sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
);
|
||||
const lastMovement = ordered[0] ?? null;
|
||||
|
||||
const inWindow = ordered.filter((m) => {
|
||||
const t = new Date(m.occurredAt).getTime();
|
||||
return !Number.isNaN(t) && t >= since;
|
||||
});
|
||||
|
||||
const loads = inWindow.filter(
|
||||
(m) => m.kind === Freight.WagonMovementKind.Loaded,
|
||||
).length;
|
||||
const emptyMoves = inWindow.filter(
|
||||
(m) => m.kind === Freight.WagonMovementKind.EmptyReposition,
|
||||
).length;
|
||||
const manualMoves = inWindow.filter(
|
||||
(m) => m.kind === Freight.WagonMovementKind.Manual,
|
||||
).length;
|
||||
const moves = inWindow.length;
|
||||
|
||||
const idleDays = daysSince(lastMovement?.occurredAt ?? null);
|
||||
|
||||
// Newest first, so a flip's "until" is the log entry before it in the array.
|
||||
const logs = [...statusLogs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
let downDays: number | null = null;
|
||||
if (isOffRoster(wagon.status)) {
|
||||
const entered = logs.find((l) => l.toStatus === wagon.status);
|
||||
downDays = daysSince(entered?.createdAt ?? wagon.lastMaintenanceAt ?? null);
|
||||
}
|
||||
|
||||
// Downtime inside the window: walk each off-roster entry to the flip that
|
||||
// ended it, clamping both ends to the window.
|
||||
let downDaysInWindow = 0;
|
||||
let spells = 0;
|
||||
logs.forEach((log, i) => {
|
||||
if (!isOffRoster(log.toStatus)) return;
|
||||
const start = new Date(log.createdAt).getTime();
|
||||
if (Number.isNaN(start)) return;
|
||||
const closed = logs[i - 1];
|
||||
const end = closed ? new Date(closed.createdAt).getTime() : Date.now();
|
||||
const from = Math.max(start, since);
|
||||
const to = Math.min(end, Date.now());
|
||||
if (to <= from) return;
|
||||
downDaysInWindow += (to - from) / DAY_MS;
|
||||
spells += 1;
|
||||
});
|
||||
|
||||
const availability =
|
||||
windowDays > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Math.round(((windowDays - downDaysInWindow) / windowDays) * 100),
|
||||
),
|
||||
)
|
||||
: 100;
|
||||
|
||||
return {
|
||||
idleDays,
|
||||
downDays,
|
||||
loads,
|
||||
moves,
|
||||
emptyMoves,
|
||||
manualMoves,
|
||||
loadedShare: moves > 0 ? Math.round((loads / moves) * 100) : null,
|
||||
lastMovement,
|
||||
spells,
|
||||
downDaysInWindow: Math.round(downDaysInWindow),
|
||||
availability,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mantine colour per wagon status. */
|
||||
export function statusColor(status: Freight.WagonStatus): string {
|
||||
switch (status) {
|
||||
case Freight.WagonStatus.Available:
|
||||
return "edr-green";
|
||||
case Freight.WagonStatus.Assigned:
|
||||
case Freight.WagonStatus.ImportReady:
|
||||
return "blue";
|
||||
case Freight.WagonStatus.ExportReady:
|
||||
return "teal";
|
||||
case Freight.WagonStatus.Maintenance:
|
||||
return "yellow";
|
||||
case Freight.WagonStatus.Detained:
|
||||
return "red";
|
||||
case Freight.WagonStatus.OutOfService:
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
/** Mantine colour per movement kind. */
|
||||
export function movementKindColor(kind: Freight.WagonMovementKind): string {
|
||||
switch (kind) {
|
||||
case Freight.WagonMovementKind.Loaded:
|
||||
return "edr-green";
|
||||
case Freight.WagonMovementKind.EmptyReposition:
|
||||
return "teal";
|
||||
case Freight.WagonMovementKind.Maintenance:
|
||||
return "yellow";
|
||||
case Freight.WagonMovementKind.Manual:
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
/** Mantine colour per history-event category. */
|
||||
export function eventCategoryColor(
|
||||
category: Freight.WagonEventCategory,
|
||||
): string {
|
||||
switch (category) {
|
||||
case Freight.WagonEventCategory.Yard:
|
||||
return "yellow";
|
||||
case Freight.WagonEventCategory.Train:
|
||||
return "blue";
|
||||
case Freight.WagonEventCategory.Schedule:
|
||||
return "indigo";
|
||||
case Freight.WagonEventCategory.Cargo:
|
||||
return "edr-green";
|
||||
case Freight.WagonEventCategory.Status:
|
||||
return "orange";
|
||||
case Freight.WagonEventCategory.Lifecycle:
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
/** Idle banding shared by the table and the distribution chart. */
|
||||
export function idleBand(
|
||||
idleDays: number | null,
|
||||
): "ok" | "watch" | "stranded" | "unknown" {
|
||||
if (idleDays == null) return "unknown";
|
||||
if (idleDays > IDLE_THRESHOLD_DAYS) return "stranded";
|
||||
if (idleDays > Math.round(IDLE_THRESHOLD_DAYS / 2)) return "watch";
|
||||
return "ok";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
import type { Freight, PaginatedResponse, Publication } from "@edr/types";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
@@ -191,6 +191,7 @@ import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVer
|
||||
import { invoicesService } from "./invoices.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
import { publicationsService, type UpdatePublicationPayload } from "./publications.service";
|
||||
import {
|
||||
fleetService,
|
||||
type FleetListFilters,
|
||||
@@ -2934,6 +2935,52 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
publications: {
|
||||
list: endpoint<void, Publication[]>(
|
||||
"publications",
|
||||
"list",
|
||||
publicationsService.list,
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ file: File; meta: UpdatePublicationPayload & { title: string }; onProgress?: (percent: number | null) => void },
|
||||
Publication
|
||||
>(
|
||||
"publications",
|
||||
"create",
|
||||
({ file, meta, onProgress }) => publicationsService.create(file, meta, onProgress),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
|
||||
update: endpoint<{ id: string; dto: UpdatePublicationPayload }, Publication>(
|
||||
"publications",
|
||||
"update",
|
||||
({ id, dto }) => publicationsService.update(id, dto),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
|
||||
replaceFile: endpoint<
|
||||
{ id: string; file: File; onProgress?: (percent: number | null) => void },
|
||||
Publication
|
||||
>(
|
||||
"publications",
|
||||
"replaceFile",
|
||||
({ id, file, onProgress }) => publicationsService.replaceFile(id, file, onProgress),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>(
|
||||
"publications",
|
||||
"remove",
|
||||
({ id }) => publicationsService.remove(id),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
},
|
||||
|
||||
dropdownSettings: {
|
||||
list: endpoint<void, DropdownSetting[]>(
|
||||
"dropdown-settings",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Publication } from "@edr/types";
|
||||
|
||||
import { api as client } from "../auth/http";
|
||||
|
||||
const BASE = "/publications";
|
||||
|
||||
export interface UpdatePublicationPayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The freight portal's public document library (/publications), managed here.
|
||||
* Every write is multipart because create/replaceFile carry a real file — the
|
||||
* client's response interceptor already unwraps the `{ success, data }`
|
||||
* envelope, so each method stays a one-liner.
|
||||
*/
|
||||
export const publicationsService = {
|
||||
async list(): Promise<Publication[]> {
|
||||
const { data } = await client.get<Publication[]>(`${BASE}/admin`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(
|
||||
file: File,
|
||||
meta: UpdatePublicationPayload & { title: string },
|
||||
onProgress?: (percent: number | null) => void,
|
||||
): Promise<Publication> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
Object.entries(meta).forEach(([key, value]) => {
|
||||
if (value !== undefined) form.append(key, String(value));
|
||||
});
|
||||
|
||||
const { data } = await client.post<Publication>(BASE, form, {
|
||||
timeout: 2 * 60 * 1000,
|
||||
onUploadProgress: (event) =>
|
||||
onProgress?.(
|
||||
event.total ? Math.round((event.loaded / event.total) * 100) : null,
|
||||
),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(id: string, dto: UpdatePublicationPayload): Promise<Publication> {
|
||||
const { data } = await client.patch<Publication>(`${BASE}/${id}`, dto);
|
||||
return data;
|
||||
},
|
||||
|
||||
async replaceFile(
|
||||
id: string,
|
||||
file: File,
|
||||
onProgress?: (percent: number | null) => void,
|
||||
): Promise<Publication> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
const { data } = await client.post<Publication>(`${BASE}/${id}/file`, form, {
|
||||
timeout: 2 * 60 * 1000,
|
||||
onUploadProgress: (event) =>
|
||||
onProgress?.(
|
||||
event.total ? Math.round((event.loaded / event.total) * 100) : null,
|
||||
),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await client.delete(`${BASE}/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -756,6 +756,15 @@ export const trainSchedulingService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** The schedule detail page's wagon-list Excel export. */
|
||||
downloadScheduleWagonsWorkbook: async (scheduleId: string): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_WAGONS_EXPORT(scheduleId),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data as Blob;
|
||||
},
|
||||
|
||||
downloadIntercityMarshallingDocument: async (
|
||||
scheduleId: string,
|
||||
): Promise<Blob> => {
|
||||
|
||||
@@ -29,6 +29,12 @@ export interface Wagon {
|
||||
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
|
||||
lastMaintenanceAt?: string | null;
|
||||
lastAvailableAt?: string | null;
|
||||
/** Newest wagon_movements arrival — the idle clock's start (list endpoint only). */
|
||||
lastMovedAt?: string | null;
|
||||
/** Loaded / empty / total moves inside `statsWindowDays` (list endpoint only). */
|
||||
loadsInWindow?: number;
|
||||
movesInWindow?: number;
|
||||
emptyMovesInWindow?: number;
|
||||
currentYardId: string | null;
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
||||
@@ -55,6 +61,8 @@ export interface WagonListFilters {
|
||||
* the latest status-log flip to MAINTENANCE, not a stored column. */
|
||||
maintenanceFrom?: string;
|
||||
maintenanceTo?: string;
|
||||
/** Window (days) the per-row load/move counts cover. Does not filter rows. */
|
||||
statsWindowDays?: number;
|
||||
/** Only read by `getPaged`. */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -73,6 +81,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
|
||||
if (filters.createdTo) params.set('createdTo', filters.createdTo);
|
||||
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
|
||||
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
|
||||
if (filters.statsWindowDays)
|
||||
params.set('statsWindowDays', String(filters.statsWindowDays));
|
||||
if (filters.page) params.set('page', String(filters.page));
|
||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
||||
const qs = params.toString();
|
||||
@@ -101,6 +111,8 @@ export interface WagonMovementRecord {
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
wagon?: { id: string; wagonNumber?: string } | null;
|
||||
/** The booking's human reference, joined at read time. Null when unloaded. */
|
||||
bookingReference?: string | null;
|
||||
}
|
||||
|
||||
/** One row of the wagon status audit trail. Returned newest first by the API. */
|
||||
|
||||
@@ -78,6 +78,7 @@ import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
import TermsPage from "./pages/support/TermsPage";
|
||||
import PublicationsPage from "./pages/publications/PublicationsPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
|
||||
function FullScreenSpinner() {
|
||||
@@ -427,6 +428,7 @@ const App = () => {
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
<Route path="/publications" element={<PublicationsPage />} />
|
||||
|
||||
{/* Auth pages — inaccessible once logged in */}
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
|
||||
101
apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
Normal file
101
apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ArrowRight, Menu, TrainFront } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Top-level navigation for the public marketing pages. Entries beginning with
|
||||
* `#` scroll within the landing page; entries beginning with `/` are real
|
||||
* routes and need router navigation.
|
||||
*/
|
||||
export const navLinks = [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Live ops", href: "#showcase" },
|
||||
{ label: "Corridors", href: "#corridors" },
|
||||
{ label: "How it works", href: "#how" },
|
||||
{ label: "Publications", href: "/publications" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The dark navbar shared by every public page that is not behind the app
|
||||
* shell — the landing page and /publications. Extracted from the landing page
|
||||
* so the two cannot drift: a link added here shows up on both.
|
||||
*
|
||||
* The anchor entries only resolve on the landing page itself, so away from it
|
||||
* they are rendered as links back to the homepage's section instead of as
|
||||
* same-page anchors that would go nowhere.
|
||||
*/
|
||||
export function PublicNavbar({ onLanding = false }: { onLanding?: boolean }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/10 bg-edr-ink/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-500/25">
|
||||
<TrainFront className="size-5" />
|
||||
</span>
|
||||
|
||||
<span className="leading-tight">
|
||||
<span className="block text-lg font-bold text-white">EDR Freight</span>
|
||||
<span className="block text-[11px] tracking-wide text-slate-400">
|
||||
Rail Logistics Platform
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-9 lg:flex">
|
||||
{navLinks.map((link) => {
|
||||
const className =
|
||||
"text-sm font-medium text-slate-300 transition hover:text-white";
|
||||
|
||||
// A route always navigates. An anchor only works on the landing
|
||||
// page; elsewhere it has to go home first, or clicking it does
|
||||
// nothing at all.
|
||||
if (link.href.startsWith("/")) {
|
||||
return (
|
||||
<Link key={link.href} to={link.href} className={className}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return onLanding ? (
|
||||
<a key={link.href} href={link.href} className={className}>
|
||||
{link.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={link.href} to={`/${link.href}`} className={className}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden rounded-lg border border-white/20 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-white/10 md:block"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/signup"
|
||||
className="hidden items-center gap-2 rounded-lg bg-edr-primary px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-500/25 transition hover:-translate-y-0.5 hover:bg-edr-primary-dark md:flex"
|
||||
>
|
||||
Get started
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
className="rounded-lg border border-white/20 p-2 text-white lg:hidden"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicNavbar;
|
||||
@@ -229,6 +229,10 @@ export const URL_CONSTANTS = {
|
||||
PUBLIC: "/api/support-content",
|
||||
},
|
||||
|
||||
PUBLICATIONS: {
|
||||
PUBLIC: "/api/publications",
|
||||
},
|
||||
|
||||
EMPTY_RETURN_REQUESTS: {
|
||||
BASE: "/api/empty-return-requests",
|
||||
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
|
||||
|
||||
@@ -11,3 +11,13 @@ export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL that streams a public publication's file through the API by its UUID.
|
||||
* Same reasoning as `fileViewUrl`: a presigned MinIO URL is not reachable from
|
||||
* the browser here, so the bytes are streamed through the API instead.
|
||||
*/
|
||||
export function publicationFileUrl(id: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/publications/${id}/file`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { PublicationSummary } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
import { client } from "@/utils/api";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
|
||||
/**
|
||||
* The public /publications library — PDFs, Markdown write-ups and PowerPoint
|
||||
* decks about the platform. Unauthenticated, same as `usePortalContent`; the
|
||||
* shared axios client only attaches a token when the cookie exists.
|
||||
*/
|
||||
export function usePublications() {
|
||||
return useQuery({
|
||||
queryKey: ["publications"],
|
||||
queryFn: async (): Promise<PublicationSummary[]> => {
|
||||
const response = await client.get<ApiResponse<PublicationSummary[]>>(
|
||||
URL_CONSTANTS.PUBLICATIONS.PUBLIC,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Mail,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Menu,
|
||||
Package,
|
||||
PackageSearch,
|
||||
Phone,
|
||||
@@ -30,6 +29,8 @@ import {
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PublicNavbar } from "@/components/PublicNavbar";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Motion helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -137,14 +138,6 @@ function CountUp({
|
||||
/* Content */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Live ops", href: "#showcase" },
|
||||
{ label: "Corridors", href: "#corridors" },
|
||||
{ label: "How it works", href: "#how" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
];
|
||||
|
||||
const heroTrust = [
|
||||
"Telebirr & CBE Birr payments",
|
||||
"Fayda ID verified",
|
||||
@@ -517,60 +510,7 @@ export default function EDRFreightLandingPage() {
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<LandingStyles />
|
||||
|
||||
{/* Navbar */}
|
||||
<header className="sticky top-0 z-50 border-b border-white/10 bg-edr-ink/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-500/25">
|
||||
<TrainFront className="size-5" />
|
||||
</span>
|
||||
|
||||
<span className="leading-tight">
|
||||
<span className="block text-lg font-bold text-white">EDR Freight</span>
|
||||
<span className="block text-[11px] tracking-wide text-slate-400">
|
||||
Rail Logistics Platform
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-9 lg:flex">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden rounded-lg border border-white/20 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-white/10 md:block"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/signup"
|
||||
className="hidden items-center gap-2 rounded-lg bg-edr-primary px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-500/25 transition hover:-translate-y-0.5 hover:bg-edr-primary-dark md:flex"
|
||||
>
|
||||
Get started
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
className="rounded-lg border border-white/20 p-2 text-white lg:hidden"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<PublicNavbar onLanding />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="edr-hero relative overflow-hidden bg-edr-ink">
|
||||
@@ -1248,6 +1188,7 @@ export default function EDRFreightLandingPage() {
|
||||
links: [
|
||||
{ label: "Help & Support", to: "/help" },
|
||||
{ label: "FAQ", to: "/faq" },
|
||||
{ label: "Publications", to: "/publications" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { PublicationSummary } from "@edr/types";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Library,
|
||||
Presentation,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PublicNavbar } from "@/components/PublicNavbar";
|
||||
import { publicationFileUrl } from "@/constants/apiConfig";
|
||||
import { usePublications } from "@/hooks/usePublications";
|
||||
|
||||
import { Markdown } from "../support/Markdown";
|
||||
import { DocFooter } from "../support/DocShell";
|
||||
|
||||
const MARKDOWN_MIMES = new Set(["text/markdown", "text/x-markdown"]);
|
||||
|
||||
type PublicationKind = "pdf" | "markdown" | "slides" | "other";
|
||||
|
||||
function kindOf(pub: PublicationSummary): PublicationKind {
|
||||
if (MARKDOWN_MIMES.has(pub.fileMimeType) || pub.fileName.toLowerCase().endsWith(".md")) {
|
||||
return "markdown";
|
||||
}
|
||||
if (pub.fileMimeType === "application/pdf") return "pdf";
|
||||
if (
|
||||
pub.fileMimeType.includes("powerpoint") ||
|
||||
pub.fileMimeType.includes("presentationml")
|
||||
) {
|
||||
return "slides";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
const KIND_META: Record<
|
||||
PublicationKind,
|
||||
{ label: string; icon: typeof FileText; accent: string }
|
||||
> = {
|
||||
pdf: { label: "PDF", icon: FileText, accent: "from-rose-500/15 to-rose-500/5" },
|
||||
markdown: { label: "Markdown", icon: FileText, accent: "from-sky-500/15 to-sky-500/5" },
|
||||
slides: {
|
||||
label: "PowerPoint",
|
||||
icon: Presentation,
|
||||
accent: "from-amber-500/15 to-amber-500/5",
|
||||
},
|
||||
other: { label: "Document", icon: FileText, accent: "from-slate-500/15 to-slate-500/5" },
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return new Date(value).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** True once the element has scrolled into view — and stays true afterwards. */
|
||||
function useInView<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [seen, setSeen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node || seen) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setSeen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setSeen(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [seen]);
|
||||
|
||||
return { ref, seen };
|
||||
}
|
||||
|
||||
/**
|
||||
* The card's preview panel.
|
||||
*
|
||||
* A PDF renders its own first page in a muted, non-interactive iframe, and a
|
||||
* Markdown file shows the opening lines of its actual text. Both only load
|
||||
* once the card is near the viewport — a grid of publications would otherwise
|
||||
* pull every file on first paint.
|
||||
*
|
||||
* Slides get a drawn cover rather than a real thumbnail: the Office Online
|
||||
* viewer is the only thing that can rasterise a .pptx here, and embedding it
|
||||
* per card is far too heavy for a listing. Clicking through still opens the
|
||||
* real thing.
|
||||
*/
|
||||
function PublicationPreview({ pub }: { pub: PublicationSummary }) {
|
||||
const kind = kindOf(pub);
|
||||
const { ref, seen } = useInView<HTMLDivElement>();
|
||||
const [excerpt, setExcerpt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "markdown" || !seen || excerpt !== null) return;
|
||||
let cancelled = false;
|
||||
|
||||
void fetch(publicationFileUrl(pub.id))
|
||||
.then((response) => response.text())
|
||||
.then((text) => {
|
||||
if (!cancelled) setExcerpt(text.slice(0, 600));
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed preview is cosmetic — the card still opens and downloads.
|
||||
if (!cancelled) setExcerpt("");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [kind, seen, excerpt, pub.id]);
|
||||
|
||||
const { icon: Icon, accent } = KIND_META[kind];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative h-44 overflow-hidden border-b border-border bg-gradient-to-br ${accent}`}
|
||||
>
|
||||
{kind === "pdf" && seen ? (
|
||||
<iframe
|
||||
// Chrome's built-in PDF viewer honours these; the fragment keeps the
|
||||
// toolbar and scrollbars out of what is meant to read as a cover.
|
||||
src={`${publicationFileUrl(pub.id)}#page=1&toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
title={`${pub.title} preview`}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
// The iframe is decoration: clicks belong to the card's buttons.
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[220%] w-full origin-top scale-[0.62] border-0"
|
||||
/>
|
||||
) : kind === "markdown" ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 origin-top-left scale-[0.78] overflow-hidden p-4"
|
||||
>
|
||||
{excerpt ? (
|
||||
<Markdown>{excerpt}</Markdown>
|
||||
) : (
|
||||
<div className="space-y-2 pt-2">
|
||||
{[92, 78, 85, 60].map((width, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-2.5 rounded-full bg-foreground/10"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Icon className="size-14 text-foreground/25" strokeWidth={1.25} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fades the preview into the card body so a clipped page doesn't end
|
||||
on a hard edge. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-background to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicationCard({
|
||||
pub,
|
||||
onOpen,
|
||||
}: {
|
||||
pub: PublicationSummary;
|
||||
onOpen: (pub: PublicationSummary) => void;
|
||||
}) {
|
||||
const kind = kindOf(pub);
|
||||
const meta = KIND_META[kind];
|
||||
const published = formatDate(pub.publishedAt);
|
||||
|
||||
return (
|
||||
<article className="group flex flex-col overflow-hidden rounded-3xl border border-border bg-background shadow-sm transition hover:-translate-y-1 hover:border-primary/40 hover:shadow-xl">
|
||||
<PublicationPreview pub={pub} />
|
||||
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<meta.icon className="size-3" />
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{pub.category ? <Badge variant="secondary">{pub.category}</Badge> : null}
|
||||
</div>
|
||||
|
||||
<h3 className="mt-3 text-lg font-bold leading-snug tracking-tight">
|
||||
{pub.title}
|
||||
</h3>
|
||||
|
||||
{pub.description ? (
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
|
||||
{pub.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatSize(pub.fileSizeBytes)}</span>
|
||||
{published ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{published}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-2 pt-0">
|
||||
<Button className="flex-1" onClick={() => onOpen(pub)}>
|
||||
{kind === "markdown" ? "Read" : "Preview"}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href={publicationFileUrl(pub.id, true)}
|
||||
aria-label={`Download ${pub.title}`}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public library of platform documentation: PDFs, Markdown write-ups and
|
||||
* PowerPoint decks, curated from the backoffice. No login required.
|
||||
*
|
||||
* Carries the marketing navbar rather than {@link DocShell}'s plain doc
|
||||
* header — this page is something a prospect is pointed at, so it should sit
|
||||
* inside the same chrome as the landing page it is linked from.
|
||||
*
|
||||
* Markdown opens in an in-page reader using the same renderer the legal pages
|
||||
* use; everything else goes through the shared `FileViewerModal`, whose
|
||||
* "text" kind is a raw iframe with no markdown rendering.
|
||||
*/
|
||||
export default function PublicationsPage() {
|
||||
const { data: publications, isLoading } = usePublications();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [reading, setReading] = useState<PublicationSummary | null>(null);
|
||||
const [markdownText, setMarkdownText] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q || !publications) return publications ?? [];
|
||||
return publications.filter(
|
||||
(pub) =>
|
||||
pub.title.toLowerCase().includes(q) ||
|
||||
(pub.description ?? "").toLowerCase().includes(q) ||
|
||||
(pub.category ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [publications, query]);
|
||||
|
||||
const openMarkdown = async (pub: PublicationSummary) => {
|
||||
setReading(pub);
|
||||
setMarkdownText(null);
|
||||
try {
|
||||
const response = await fetch(publicationFileUrl(pub.id));
|
||||
setMarkdownText(await response.text());
|
||||
} catch {
|
||||
setMarkdownText("Sorry — this document could not be loaded. Try downloading it.");
|
||||
}
|
||||
};
|
||||
|
||||
const open = (pub: PublicationSummary) => {
|
||||
if (kindOf(pub) === "markdown") {
|
||||
void openMarkdown(pub);
|
||||
return;
|
||||
}
|
||||
view({
|
||||
name: pub.fileName,
|
||||
url: publicationFileUrl(pub.id),
|
||||
mimeType: pub.fileMimeType,
|
||||
});
|
||||
};
|
||||
|
||||
// Escape closes the markdown reader, like the shared viewer's modal.
|
||||
useEffect(() => {
|
||||
if (!reading) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setReading(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [reading]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<PublicNavbar />
|
||||
|
||||
{/* Hero, in the landing page's dark band so the navbar sits on the tone
|
||||
it was designed for. */}
|
||||
<section className="border-b border-white/10 bg-edr-ink">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 py-1.5 pl-2 pr-3 text-[13px] font-medium text-white">
|
||||
<Library className="size-4" />
|
||||
Resource library
|
||||
</span>
|
||||
|
||||
<h1 className="mt-5 text-4xl font-black tracking-tight text-white sm:text-5xl">
|
||||
Publications
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-8 text-slate-300">
|
||||
Guides, reports and presentations about the EDR Freight platform —
|
||||
read them here or download a copy.
|
||||
</p>
|
||||
|
||||
<div className="relative mt-8 max-w-md">
|
||||
<Search className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search publications…"
|
||||
aria-label="Search publications"
|
||||
className="w-full rounded-xl border border-white/15 bg-white/5 py-3 pl-11 pr-4 text-sm text-white placeholder:text-slate-400 focus:border-edr-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-96 animate-pulse rounded-3xl border border-border bg-accent/40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border py-20 text-center">
|
||||
<Library className="mx-auto size-10 text-muted-foreground/50" strokeWidth={1.25} />
|
||||
<p className="mt-4 font-semibold">
|
||||
{query.trim() ? "No publications match that search." : "Nothing published yet."}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{query.trim() ? "Try a different word." : "Check back soon."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((pub) => (
|
||||
<PublicationCard key={pub.id} pub={pub} onOpen={open} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DocFooter current="/publications" />
|
||||
|
||||
{/* PDF / slide preview for everything except Markdown. */}
|
||||
{viewer}
|
||||
|
||||
{reading ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={reading.title}
|
||||
onClick={() => setReading(null)}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-3xl bg-background shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
|
||||
<h2 className="truncate font-bold">{reading.title}</h2>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={publicationFileUrl(reading.id, true)}>
|
||||
<Download className="size-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReading(null)}
|
||||
aria-label="Close"
|
||||
className="rounded-full p-1.5 transition hover:bg-accent"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-8 py-6">
|
||||
{markdownText === null ? (
|
||||
<p className="text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<Markdown>{markdownText}</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
||||
const DOC_LINKS = [
|
||||
{ to: "/help", label: "Help & Support" },
|
||||
{ to: "/faq", label: "FAQ" },
|
||||
{ to: "/publications", label: "Publications" },
|
||||
{ to: "/privacy", label: "Privacy Policy" },
|
||||
{ to: "/terms", label: "Terms of Service" },
|
||||
];
|
||||
@@ -78,26 +79,37 @@ export function DocShell({
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border py-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
|
||||
<span>© 2026 EDR Freight. All rights reserved.</span>
|
||||
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
||||
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
<DocFooter current={current} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer shared by every public doc page, cross-linking the others. Exported
|
||||
* so /publications can carry it under the marketing navbar without also
|
||||
* inheriting {@link DocShell}'s own header.
|
||||
*/
|
||||
export function DocFooter({ current }: { current: string }) {
|
||||
return (
|
||||
<footer className="border-t border-border py-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
|
||||
<span>© 2026 EDR Freight. All rights reserved.</span>
|
||||
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
||||
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a legal document's numbered sections. Bodies are markdown, so the
|
||||
* paragraph and bullet arrays this used to walk are one string now — keyed by
|
||||
|
||||
Reference in New Issue
Block a user