Merge branch 'dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-04 16:31:21 +03:00
54 changed files with 5330 additions and 271 deletions

View File

@@ -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,

View File

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

View File

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

View File

@@ -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;
}
/**

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreatePublicationDto } from "./create-publication.dto";
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}

View File

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

View File

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

View File

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

View File

@@ -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 {}

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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,

View File

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

View File

@@ -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> {

View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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",

View File

@@ -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) =>

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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, 0100; 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, 0100. */
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";
}

View File

@@ -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",

View File

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

View File

@@ -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> => {

View File

@@ -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. */

View File

@@ -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 />}>

View 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;

View File

@@ -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}`,

View File

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

View 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,
});
}

View File

@@ -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" },
],
},
{

View File

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

View File

@@ -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

View File

@@ -17,6 +17,7 @@ export * from "./booking-window-ws";
export * from "./support-chat";
export * from "./portal-content";
export * from "./portal-content.defaults";
export * from "./publications";
export enum TradeDirection {
IMPORT = "IMPORT",

View File

@@ -0,0 +1,43 @@
/**
* Public document library for the freight portal (/publications): PDFs,
* Markdown write-ups and PowerPoint decks about the platform, uploaded and
* curated from the backoffice. Unlike `support-content`'s five fixed slugs,
* this is a real table of many rows — each row is one whole file, replaced
* wholesale rather than edited in place, so there is no version history here.
*/
/** MinIO key prefix every uploaded publication file is stored under. */
export const PUBLICATION_FILE_PREFIX = "publications/";
/** Mime types accepted for a publication upload. */
export const PUBLICATION_ALLOWED_MIME_TYPES = [
"application/pdf",
"text/markdown",
"text/x-markdown",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
] as const;
/** One document in the public library. */
export interface Publication {
id: string;
title: string;
description: string | null;
category: string | null;
fileName: string;
fileMimeType: string;
fileSizeBytes: number;
sortOrder: number;
published: boolean;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
}
/**
* Shape the anonymous portal reads — no `fileKey` (that's a MinIO object
* name, not a browser-reachable URL — a presigned MinIO URL isn't reachable
* from the browser here, see `apiConfig.ts`'s `fileViewUrl`). The portal
* builds the file's URL itself from `id` via `GET /api/publications/:id/file`.
*/
export type PublicationSummary = Omit<Publication, "published">;