mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(contracts): per-cargo bulk contract templates
This commit is contained in:
@@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
const CONTAINER_CODES = [
|
||||
'IMPORT_CONTAINER_CUSTOMS',
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
'EXPORT_CONTAINER_CUSTOMS',
|
||||
'EXPORT_CONTAINER_NO_CUSTOMS',
|
||||
'INTERCITY_CONTAINER',
|
||||
];
|
||||
|
||||
const BULK_CODES = [
|
||||
'IMPORT_BULK_CUSTOMS',
|
||||
'IMPORT_BULK_NO_CUSTOMS',
|
||||
'EXPORT_BULK_CUSTOMS',
|
||||
'EXPORT_BULK_NO_CUSTOMS',
|
||||
'INTERCITY_BULK',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bulk contract templates become staff-created, keyed by (cargo type, customs
|
||||
* clearing) instead of the fixed direction codes. The five container templates
|
||||
* stay seeded and become undeletable system rows; the five seeded bulk rows are
|
||||
* retired (soft-deleted). cargo_types gains has_contract_template, marking
|
||||
* which bulk commodities may carry their own template.
|
||||
*/
|
||||
export class BulkContractTemplates3320000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id),
|
||||
ADD COLUMN IF NOT EXISTS with_customs boolean,
|
||||
ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
// Generated bulk codes (BULK_<cargo code>_NO_CUSTOMS) outgrow varchar(40).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ALTER COLUMN code TYPE varchar(80)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`,
|
||||
[CONTAINER_CODES],
|
||||
);
|
||||
|
||||
// Retire the fixed bulk templates; staff recreate them per cargo type.
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.contract_templates SET deleted_at = now()
|
||||
WHERE code = ANY($1) AND deleted_at IS NULL`,
|
||||
[BULK_CODES],
|
||||
);
|
||||
|
||||
// Code stays unique among live rows only, so a deleted combo can be
|
||||
// recreated under the same generated code.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code
|
||||
ON freight.contract_templates (code) WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// One template per (bulk cargo type, customs option) — the "same
|
||||
// combination" rule, enforced even under concurrent creates.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
|
||||
ON freight.contract_templates (cargo_type_id, with_customs)
|
||||
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD CONSTRAINT uq_contract_templates_code UNIQUE (code)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`,
|
||||
[BULK_CODES],
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
DROP COLUMN IF EXISTS cargo_type_id,
|
||||
DROP COLUMN IF EXISTS with_customs,
|
||||
DROP COLUMN IF EXISTS is_system
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { ContractTemplatesService } from "./contract-templates.service";
|
||||
import {
|
||||
CreateArticleDto,
|
||||
CreateContractTemplateDto,
|
||||
PreviewContractTemplateDto,
|
||||
ReplaceArticlesDto,
|
||||
UpdateArticleDto,
|
||||
UpdateContractTemplateDto,
|
||||
} from "./dto/contract-template.dto";
|
||||
|
||||
// `view` opens the Templates page; `read` is API-read-only for other pages
|
||||
// that show template data; create/update/delete gate each write. `manage` is
|
||||
// the legacy write key and keeps working for roles that already hold it.
|
||||
const TEMPLATE_READ = [
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.read,
|
||||
FREIGHT_PERMS.settings.contractTemplates.update,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
const TEMPLATE_UPDATE = [
|
||||
FREIGHT_PERMS.settings.contractTemplates.update,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
@ApiTags("contract-templates")
|
||||
@Controller("contract-templates")
|
||||
export class ContractTemplatesController {
|
||||
constructor(private readonly service: ContractTemplatesService) {}
|
||||
|
||||
// Reads are staff-only (the backoffice Templates tab is the only consumer);
|
||||
// writes are admin-guarded like other freight configuration resources.
|
||||
|
||||
@Get()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@ApiOperation({ summary: "List the six contract document templates" })
|
||||
@BookingStaff(TEMPLATE_READ)
|
||||
@ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Get(":code")
|
||||
@Post()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.create,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a bulk contract template for a (cargo type, customs option) pair",
|
||||
})
|
||||
create(@Body() dto: CreateContractTemplateDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Get(":code")
|
||||
@BookingStaff(TEMPLATE_READ)
|
||||
@ApiOperation({ summary: "Get one contract template by code" })
|
||||
getByCode(@Param("code") code: string) {
|
||||
return this.service.getByCode(code);
|
||||
}
|
||||
|
||||
@Patch(":code")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
|
||||
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
||||
return this.service.update(code, dto);
|
||||
}
|
||||
|
||||
@Post(":code/preview")
|
||||
@Delete(":code")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.settings.contractTemplates.delete,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: "Delete a staff-created bulk template (system templates refuse)",
|
||||
})
|
||||
remove(@Param("code") code: string) {
|
||||
return this.service.remove(code);
|
||||
}
|
||||
|
||||
@Post(":code/preview")
|
||||
@BookingStaff(TEMPLATE_READ)
|
||||
@ApiOperation({
|
||||
summary: "Render an HTML preview of the template against mock contract data",
|
||||
})
|
||||
@@ -77,21 +109,21 @@ export class ContractTemplatesController {
|
||||
/* ------------------------- article routes ------------------------- */
|
||||
|
||||
@Put(":code/articles")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
|
||||
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
||||
return this.service.replaceArticles(code, dto.articles);
|
||||
}
|
||||
|
||||
@Post(":code/articles")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Add an article to the template" })
|
||||
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
||||
return this.service.addArticle(code, dto);
|
||||
}
|
||||
|
||||
@Patch(":code/articles/:articleId")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Update an article's title or body" })
|
||||
updateArticle(
|
||||
@Param("code") code: string,
|
||||
@@ -102,7 +134,7 @@ export class ContractTemplatesController {
|
||||
}
|
||||
|
||||
@Delete(":code/articles/:articleId")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Remove an article from the template" })
|
||||
removeArticle(
|
||||
@Param("code") code: string,
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ContractTemplate,
|
||||
ContractTemplateCode,
|
||||
} from "./entities/contract-template.entity";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContractTemplate } from "./entities/contract-template.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
|
||||
findByCode(code: string): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({ where: { code } });
|
||||
}
|
||||
|
||||
override findAll(): Promise<ContractTemplate[]> {
|
||||
return this.repository.find({ order: { code: "ASC" } });
|
||||
return this.repository.find({
|
||||
relations: { cargoType: true },
|
||||
order: { code: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
findByCargoCombo(
|
||||
cargoTypeId: string,
|
||||
withCustoms: boolean,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The active bulk template covering this cargo type: written against the
|
||||
* cargo type itself or against its parent group (the two are mutually
|
||||
* exclusive, so at most one row matches).
|
||||
*/
|
||||
findActiveBulkTemplate(
|
||||
cargoTypeId: string,
|
||||
withCustoms: boolean,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
.where("t.is_active = true")
|
||||
.andWhere("t.with_customs = :withCustoms", { withCustoms })
|
||||
.andWhere(
|
||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||
WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL
|
||||
))`,
|
||||
{ cargoTypeId },
|
||||
)
|
||||
.getOne();
|
||||
}
|
||||
|
||||
findCargoType(id: string): Promise<CargoType | null> {
|
||||
return this.repository.manager
|
||||
.getRepository(CargoType)
|
||||
.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
@@ -11,6 +16,7 @@ import {
|
||||
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
||||
import {
|
||||
CreateArticleDto,
|
||||
CreateContractTemplateDto,
|
||||
PreviewContractTemplateDto,
|
||||
ReplaceArticleDto,
|
||||
UpdateArticleDto,
|
||||
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
|
||||
async list(): Promise<ContractTemplate[]> {
|
||||
const templates = await this.repository.findAll();
|
||||
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
||||
return templates.sort(
|
||||
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
|
||||
);
|
||||
// Seeded container templates first in canonical order, then staff-created
|
||||
// bulk templates alphabetically.
|
||||
return templates.sort((a, b) => {
|
||||
const ra = rank.get(a.code as ContractTemplateCode) ?? 99;
|
||||
const rb = rank.get(b.code as ContractTemplateCode) ?? 99;
|
||||
return ra !== rb ? ra - rb : a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
async getByCode(code: string): Promise<ContractTemplate> {
|
||||
const template = await this.repository.findByCode(this.assertCode(code));
|
||||
const template = await this.repository.findByCode(code?.toUpperCase() ?? "");
|
||||
if (!template) {
|
||||
throw new NotFoundException(`Contract template ${code} not found`);
|
||||
}
|
||||
@@ -67,15 +77,93 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The active template used when generating a contract document for the given
|
||||
* direction/freight/customs triple; null when missing or deactivated (the
|
||||
* renderer then falls back to the built-in generic layout).
|
||||
* Staff-created bulk template for one (cargo type, customs option) pair.
|
||||
* The cargo type must have hasContractTemplate enabled and the combination
|
||||
* must not already exist — the same commodity + customs pairing is edited,
|
||||
* never duplicated.
|
||||
*/
|
||||
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
|
||||
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
|
||||
}
|
||||
if (!cargoType.hasContractTemplate) {
|
||||
throw new BadRequestException(
|
||||
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
|
||||
);
|
||||
}
|
||||
const variant = dto.withCustoms ? "with" : "without";
|
||||
const existing = await this.repository.findByCargoCombo(
|
||||
dto.cargoTypeId,
|
||||
dto.withCustoms,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
||||
);
|
||||
}
|
||||
|
||||
const template = new ContractTemplate();
|
||||
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
|
||||
template.name =
|
||||
dto.name ??
|
||||
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
|
||||
template.description = dto.description ?? null;
|
||||
template.documentTitle = dto.withCustoms
|
||||
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||
template.whereasClauses = [];
|
||||
template.articles = [];
|
||||
template.isActive = true;
|
||||
template.cargoTypeId = cargoType.id;
|
||||
template.withCustoms = dto.withCustoms;
|
||||
template.isSystem = false;
|
||||
try {
|
||||
return await this.repository.saveTemplate(template);
|
||||
} catch (error) {
|
||||
// Partial unique index backstop for concurrent creates of the same combo.
|
||||
if ((error as { code?: string })?.code === "23505") {
|
||||
throw new ConflictException(
|
||||
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||
async remove(code: string): Promise<void> {
|
||||
const template = await this.getByCode(code);
|
||||
if (template.isSystem) {
|
||||
throw new BadRequestException(
|
||||
"System container templates cannot be deleted",
|
||||
);
|
||||
}
|
||||
await this.repository.softDelete(template.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active template used when generating a contract document. Container
|
||||
* contracts resolve through the fixed direction/customs codes; bulk contracts
|
||||
* resolve through the staff-created template for the contract's cargo type
|
||||
* (or its parent group) and customs option. Null when nothing matches or the
|
||||
* match is deactivated (the renderer then falls back to the built-in generic
|
||||
* layout).
|
||||
*/
|
||||
async findActiveForContract(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
cargoTypeId?: string | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
if (!cargoTypeId) return null;
|
||||
return this.repository.findActiveBulkTemplate(
|
||||
cargoTypeId,
|
||||
Boolean(customsClearingEnabled),
|
||||
);
|
||||
}
|
||||
const code = contractTemplateCodeFor(
|
||||
tradeDirection,
|
||||
freightType,
|
||||
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
|
||||
: this.sorted(template.articles),
|
||||
};
|
||||
|
||||
const view = this.buildMockView(template.code, dynamicTemplate);
|
||||
const view = this.buildMockView(template, dynamicTemplate);
|
||||
return { html: this.renderer.render(view) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry key the mock preview renders against. Staff-created bulk
|
||||
* templates aren't in the fixed code map — they preview against the
|
||||
* representative bulk import pack matching their customs option.
|
||||
*/
|
||||
private previewKeyFor(template: ContractTemplate): string {
|
||||
if (template.cargoTypeId) {
|
||||
return template.withCustoms
|
||||
? "IMP_BULK_USD_FORWARDING"
|
||||
: "IMP_BULK_USD_TRANSPORT_ONLY";
|
||||
}
|
||||
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
|
||||
}
|
||||
|
||||
private buildMockView(
|
||||
code: ContractTemplateCode,
|
||||
template: ContractTemplate,
|
||||
dynamicTemplate: ContractDynamicTemplateView,
|
||||
): ContractViewModel {
|
||||
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
|
||||
const isBulk = code.endsWith("_BULK");
|
||||
const code = template.code;
|
||||
const previewKey = this.previewKeyFor(template);
|
||||
const meta = getTemplateMeta(previewKey);
|
||||
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||
const now = new Date();
|
||||
|
||||
// Representative rate schedule so the admin preview shows the live-rate
|
||||
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
|
||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||
reference: "EDR/CT/2026/0042",
|
||||
status: "CONTRACT_READY",
|
||||
templateKey: PREVIEW_TEMPLATE_KEYS[code],
|
||||
templateKey: previewKey,
|
||||
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
||||
contractDate: now.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/** Static, representative rate schedule for the admin preview only. */
|
||||
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule {
|
||||
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
|
||||
const dir = code.startsWith("IMPORT")
|
||||
? "import"
|
||||
: code.startsWith("EXPORT")
|
||||
@@ -311,16 +415,6 @@ export class ContractTemplatesService {
|
||||
};
|
||||
}
|
||||
|
||||
private assertCode(code: string): ContractTemplateCode {
|
||||
const upper = code?.toUpperCase() as ContractTemplateCode;
|
||||
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
|
||||
throw new BadRequestException(
|
||||
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
|
||||
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
@@ -6,12 +6,41 @@ import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateContractTemplateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Bulk cargo type this template is written for (must have hasContractTemplate enabled)",
|
||||
format: "uuid",
|
||||
})
|
||||
@IsUUID()
|
||||
cargoTypeId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Whether this is the with-customs-clearing variant",
|
||||
})
|
||||
@IsBoolean()
|
||||
withCustoms!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Short description shown on the template card" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class UpdateContractTemplateDto {
|
||||
@ApiPropertyOptional({ description: "Display name of the template" })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
|
||||
/**
|
||||
* The ten canonical contract document templates. Import and export split by
|
||||
* customs clearing (× freight type = 8); intercity does not, because it is a
|
||||
* purely domestic Ethiopian movement that crosses no border and therefore has
|
||||
* no customs leg at all (× freight type = 2).
|
||||
* The five seeded container templates (import/export split by customs
|
||||
* clearing; intercity is domestic, crosses no border, so it has a single
|
||||
* template). These are system rows: always present, never deletable.
|
||||
*
|
||||
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
|
||||
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
|
||||
* The retired direction-keyed bulk codes remain listed so old frozen document
|
||||
* snapshots still label correctly.
|
||||
*
|
||||
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "contract_templates" })
|
||||
@Index(["code"], { unique: true })
|
||||
// Uniqueness lives in partial DB indexes (live rows only): code, and
|
||||
// (cargo_type_id, with_customs) for staff-created bulk templates.
|
||||
@Index(["code"])
|
||||
export class ContractTemplate extends BaseEntity {
|
||||
@Column({ name: "code", type: "varchar", length: 40, unique: true })
|
||||
code!: ContractTemplateCode;
|
||||
@Column({ name: "code", type: "varchar", length: 80 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
|
||||
|
||||
@Column({ name: "is_active", type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/** Bulk templates only: the cargo type this template is written for. */
|
||||
@Column({ name: "cargo_type_id", type: "uuid", nullable: true })
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => CargoType, { nullable: true })
|
||||
@JoinColumn({ name: "cargo_type_id" })
|
||||
cargoType?: CargoType | null;
|
||||
|
||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||
withCustoms?: boolean | null;
|
||||
|
||||
/** The five seeded container templates — cannot be deleted. */
|
||||
@Column({ name: "is_system", type: "boolean", default: false })
|
||||
isSystem!: boolean;
|
||||
}
|
||||
|
||||
@@ -424,6 +424,8 @@ export class ContractTransitionService {
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -70,6 +70,16 @@ export class CreateCargoTypeDto {
|
||||
@IsBoolean()
|
||||
hasLashing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
'Allow staff to write bulk contract templates for this cargo type. ' +
|
||||
'Mutually exclusive with the parent group / children having it.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasContractTemplate?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||
hasLashing!: boolean;
|
||||
|
||||
/**
|
||||
* Whether staff may write bulk contract templates against this cargo type.
|
||||
* Mutually exclusive between a parent group and its children: if the parent
|
||||
* provides the template, no child may, and vice versa.
|
||||
*/
|
||||
@Column({ name: 'has_contract_template', type: 'boolean', default: false })
|
||||
hasContractTemplate!: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
|
||||
@@ -135,6 +135,35 @@ export class CargoTypesService {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cargo type and its parent group may not BOTH offer a contract template —
|
||||
* the template would be ambiguous for bookings of the child. To enable the
|
||||
* child, the parent must be turned off first (and vice versa).
|
||||
*/
|
||||
private async assertContractTemplateExclusive(input: {
|
||||
id?: string;
|
||||
parentGroupId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (input.parentGroupId) {
|
||||
const parent = await this.repository.findById(input.parentGroupId);
|
||||
if (parent?.hasContractTemplate) {
|
||||
throw new BadRequestException(
|
||||
`Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.id) {
|
||||
const children = await this.repository.findAll({
|
||||
where: { parentGroupId: input.id, hasContractTemplate: true },
|
||||
});
|
||||
if (children.length) {
|
||||
throw new BadRequestException(
|
||||
`Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new cargo type. */
|
||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||
const code = generateCode(dto.cargoTypeName);
|
||||
@@ -144,6 +173,9 @@ export class CargoTypesService {
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
if (dto.hasContractTemplate) {
|
||||
await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId });
|
||||
}
|
||||
|
||||
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
|
||||
explicitOrder: dto.displayOrder,
|
||||
@@ -160,6 +192,7 @@ export class CargoTypesService {
|
||||
code,
|
||||
cargoTypeName: dto.cargoTypeName,
|
||||
parentGroupId: dto.parentGroupId ?? null,
|
||||
hasContractTemplate: dto.hasContractTemplate ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
@@ -183,6 +216,18 @@ export class CargoTypesService {
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
// Re-check the parent/child template exclusivity whenever the flag or the
|
||||
// parent moves and the row ends up flagged.
|
||||
const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate;
|
||||
if (
|
||||
willHaveTemplate &&
|
||||
(dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined)
|
||||
) {
|
||||
await this.assertContractTemplateExclusive({
|
||||
id,
|
||||
parentGroupId: dto.parentGroupId ?? existing.parentGroupId,
|
||||
});
|
||||
}
|
||||
const {
|
||||
wagonTypeIds,
|
||||
itemsPerWagonMap,
|
||||
|
||||
@@ -1249,6 +1249,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:settings:contract_templates:manage",
|
||||
"Edit contract templates & articles",
|
||||
),
|
||||
// Granular split of contract-template access. `view` opens the sidebar page;
|
||||
// `read` is API-read-only for other pages that display template data.
|
||||
perm(
|
||||
"b4e00001-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:settings:contract_templates:create",
|
||||
"Create bulk contract templates",
|
||||
),
|
||||
perm(
|
||||
"b4e00001-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:settings:contract_templates:update",
|
||||
"Update contract templates & articles",
|
||||
),
|
||||
perm(
|
||||
"b4e00001-0001-4000-8000-000000000005",
|
||||
"edr_freight_app:settings:contract_templates:delete",
|
||||
"Delete bulk contract templates",
|
||||
),
|
||||
perm(
|
||||
"b4e00001-0001-4000-8000-000000000006",
|
||||
"edr_freight_app:settings:contract_templates:read",
|
||||
"Read contract template data (API only)",
|
||||
),
|
||||
];
|
||||
|
||||
// N. Previously-ungated staff surfaces (support inbox, procurement, compliance,
|
||||
@@ -1670,6 +1692,10 @@ export const FREIGHT_PERMS = {
|
||||
contractTemplates: {
|
||||
view: "edr_freight_app:settings:contract_templates:view",
|
||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
||||
create: "edr_freight_app:settings:contract_templates:create",
|
||||
update: "edr_freight_app:settings:contract_templates:update",
|
||||
delete: "edr_freight_app:settings:contract_templates:delete",
|
||||
read: "edr_freight_app:settings:contract_templates:read",
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
|
||||
@@ -590,7 +590,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Contract templates",
|
||||
href: "/dashboard/contract-templates",
|
||||
icon: <ScrollText />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
// `view` opens the page; `read` alone is API-only and shows no menu.
|
||||
permission: [
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.admin,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Audit logs",
|
||||
@@ -1470,7 +1474,12 @@ const App = () => {
|
||||
<Route
|
||||
path="contract-templates"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.admin,
|
||||
]}
|
||||
>
|
||||
<ContractTemplatesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1478,7 +1487,12 @@ const App = () => {
|
||||
<Route
|
||||
path="contract-templates/:code"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.admin,
|
||||
]}
|
||||
>
|
||||
<ContractTemplateEditorPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
|
||||
import {
|
||||
contractTemplatesService,
|
||||
type ArticlePayload,
|
||||
type CreateContractTemplatePayload,
|
||||
type UpdateContractTemplatePayload,
|
||||
} from "@/services/contract-templates.service";
|
||||
|
||||
@@ -59,6 +60,21 @@ function useTemplateMutation<TVariables>(
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateContractTemplate() {
|
||||
return useTemplateMutation(
|
||||
(payload: CreateContractTemplatePayload) =>
|
||||
contractTemplatesService.create(payload),
|
||||
"Template created",
|
||||
);
|
||||
}
|
||||
|
||||
export function useDeleteContractTemplate() {
|
||||
return useTemplateMutation(
|
||||
(code: string) => contractTemplatesService.remove(code),
|
||||
"Template deleted",
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdateContractTemplate(code: string) {
|
||||
return useTemplateMutation(
|
||||
(payload: UpdateContractTemplatePayload) =>
|
||||
|
||||
@@ -317,6 +317,10 @@ export const FREIGHT_PERMS = {
|
||||
contractTemplates: {
|
||||
view: "edr_freight_app:settings:contract_templates:view",
|
||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
||||
create: "edr_freight_app:settings:contract_templates:create",
|
||||
update: "edr_freight_app:settings:contract_templates:update",
|
||||
delete: "edr_freight_app:settings:contract_templates:delete",
|
||||
read: "edr_freight_app:settings:contract_templates:read",
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -19,11 +23,21 @@ import {
|
||||
Container,
|
||||
Eye,
|
||||
FileText,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
||||
import {
|
||||
useContractTemplates,
|
||||
useCreateContractTemplate,
|
||||
useDeleteContractTemplate,
|
||||
} from "@/hooks/contract-templates/useContractTemplates";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { cargoTypesService } from "@/services/cargo-types.service";
|
||||
import type { ContractTemplate } from "@/services/contract-templates.service";
|
||||
import TemplatePreviewModal from "./TemplatePreviewModal";
|
||||
|
||||
@@ -39,22 +53,18 @@ const DIRECTION_DOT: Record<string, string> = {
|
||||
INTERCITY: "var(--mantine-color-orange-5)",
|
||||
};
|
||||
|
||||
function templateDirection(code: ContractTemplate["code"]): string {
|
||||
return code.split("_")[0];
|
||||
function isBulk(template: ContractTemplate): boolean {
|
||||
return Boolean(template.cargoTypeId);
|
||||
}
|
||||
|
||||
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
|
||||
// the second one — never the suffix.
|
||||
function isBulk(code: ContractTemplate["code"]): boolean {
|
||||
return code.split("_")[1] === "BULK";
|
||||
}
|
||||
|
||||
// Intercity is domestic and crosses no border, so it has no customs variant at
|
||||
// all — hence null rather than false, which would wrongly read as a deliberate
|
||||
// "client clears its own customs" choice.
|
||||
function customsVariant(code: ContractTemplate["code"]): boolean | null {
|
||||
if (code.endsWith("_NO_CUSTOMS")) return false;
|
||||
if (code.endsWith("_CUSTOMS")) return true;
|
||||
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
|
||||
// domestic and crosses no border, so it has no customs variant at all — hence
|
||||
// null rather than false, which would wrongly read as a deliberate "client
|
||||
// clears its own customs" choice.
|
||||
function customsVariant(template: ContractTemplate): boolean | null {
|
||||
if (isBulk(template)) return template.withCustoms ?? null;
|
||||
if (template.code.endsWith("_NO_CUSTOMS")) return false;
|
||||
if (template.code.endsWith("_CUSTOMS")) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -66,10 +76,28 @@ function formatUpdated(value: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
interface CargoTypeOption {
|
||||
id: string;
|
||||
cargoTypeName?: string;
|
||||
hasContractTemplate?: boolean;
|
||||
}
|
||||
|
||||
export default function ContractTemplatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { data: templates, isLoading } = useContractTemplates();
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ContractTemplate | null>(null);
|
||||
|
||||
const perms = FREIGHT_PERMS.settings.contractTemplates;
|
||||
const isAdmin = hasPermission(user, FREIGHT_PERMS.admin);
|
||||
const canManage = isAdmin || hasPermission(user, perms.manage);
|
||||
const canCreate = canManage || hasPermission(user, perms.create);
|
||||
const canUpdate = canManage || hasPermission(user, perms.update);
|
||||
const canDelete = isAdmin || hasPermission(user, perms.delete);
|
||||
|
||||
const deleteTemplate = useDeleteContractTemplate();
|
||||
|
||||
const previewTemplate = templates?.find((t) => t.code === previewCode);
|
||||
|
||||
@@ -77,20 +105,34 @@ export default function ContractTemplatesPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract templates"
|
||||
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
|
||||
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
New bulk template
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||
{isLoading
|
||||
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
: (templates ?? []).map((template) => (
|
||||
<TemplateCard
|
||||
key={template.code}
|
||||
template={template}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
onPreview={() => setPreviewCode(template.code)}
|
||||
onEdit={() =>
|
||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||
}
|
||||
onDelete={() => setDeleteTarget(template)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -100,22 +142,174 @@ export default function ContractTemplatesPage() {
|
||||
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
|
||||
onClose={() => setPreviewCode(null)}
|
||||
/>
|
||||
|
||||
<CreateTemplateModal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={(code) => {
|
||||
setCreateOpen(false);
|
||||
navigate(`/dashboard/contract-templates/${code}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Delete confirm ─────────────────────────────────────── */}
|
||||
<Modal
|
||||
opened={Boolean(deleteTarget)}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete contract template?"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This will delete{" "}
|
||||
<Text span fw={600}>
|
||||
{deleteTarget?.name}
|
||||
</Text>{" "}
|
||||
and its articles. Contracts already generated keep their frozen
|
||||
document; new contracts for this combination fall back to the
|
||||
generic layout until a new template is created.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={deleteTemplate.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
deleteTemplate.mutate(deleteTarget.code, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff pick the customs option first, then a bulk cargo type that has
|
||||
* "has contract template" enabled. One template per combination — the API
|
||||
* rejects duplicates, so an existing pairing must be edited instead.
|
||||
*/
|
||||
function CreateTemplateModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (code: string) => void;
|
||||
}) {
|
||||
const [withCustoms, setWithCustoms] = useState<string>("true");
|
||||
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
|
||||
const create = useCreateContractTemplate();
|
||||
|
||||
const { data: cargoTypes, isLoading } = useQuery({
|
||||
queryKey: ["cargo-types", "contract-template-options"],
|
||||
queryFn: () => cargoTypesService.getCargoTypes(),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
((cargoTypes ?? []) as CargoTypeOption[])
|
||||
.filter((cargoType) => cargoType.hasContractTemplate)
|
||||
.map((cargoType) => ({
|
||||
value: cargoType.id,
|
||||
label: cargoType.cargoTypeName ?? "Untitled",
|
||||
})),
|
||||
[cargoTypes],
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
setCargoTypeId(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title="New bulk contract template" centered>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={6}>
|
||||
Customs clearing
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={withCustoms}
|
||||
onChange={setWithCustoms}
|
||||
data={[
|
||||
{ value: "true", label: "With customs clearing" },
|
||||
{ value: "false", label: "Without customs clearing" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Bulk cargo type"
|
||||
description="Only cargo types with “has contract template” enabled are listed"
|
||||
placeholder={isLoading ? "Loading…" : "Select a cargo type"}
|
||||
data={options}
|
||||
value={cargoTypeId}
|
||||
onChange={setCargoTypeId}
|
||||
searchable
|
||||
nothingFoundMessage="No cargo type allows contract templates yet — enable the flag on the cargo type first"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!cargoTypeId}
|
||||
loading={create.isPending}
|
||||
onClick={() => {
|
||||
if (!cargoTypeId) return;
|
||||
create.mutate(
|
||||
{ cargoTypeId, withCustoms: withCustoms === "true" },
|
||||
{
|
||||
onSuccess: (template) =>
|
||||
onCreated((template as ContractTemplate).code),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create template
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
onPreview,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
template: ContractTemplate;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
onPreview: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const direction = templateDirection(template.code);
|
||||
const bulk = isBulk(template.code);
|
||||
const customs = customsVariant(template.code);
|
||||
const bulk = isBulk(template);
|
||||
const direction = template.code.split("_")[0];
|
||||
const customs = customsVariant(template);
|
||||
const kicker = bulk
|
||||
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
|
||||
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -142,12 +336,13 @@ function TemplateCard({
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||
background: bulk
|
||||
? "var(--mantine-color-teal-5)"
|
||||
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
|
||||
{bulk ? "Bulk" : "Container"}
|
||||
{kicker}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -166,6 +361,18 @@ function TemplateCard({
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{template.isSystem && (
|
||||
<Tooltip label="Built-in template — cannot be deleted" withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Lock size={11} />}
|
||||
>
|
||||
System
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
@@ -221,16 +428,34 @@ function TemplateCard({
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<Pencil size={14} />}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit articles
|
||||
</Button>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canDelete && !template.isSystem && (
|
||||
<Tooltip label="Delete template" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
px={8}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<Pencil size={14} />}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit articles
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
@@ -54,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
||||
hasLashing?: boolean;
|
||||
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
|
||||
hasContractTemplate?: boolean;
|
||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||
unitOfMeasure?: string | null;
|
||||
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
||||
@@ -111,6 +113,10 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
// When on, every booking of this cargo type is charged the flat LASHING
|
||||
// surcharge (a rate with trigger = Lashing).
|
||||
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
||||
// Lets staff write bulk contract templates for this cargo type. The API
|
||||
// rejects the save when the parent group (or a child) already has it on —
|
||||
// the template must live on exactly one level.
|
||||
{ name: "hasContractTemplate", label: "Has contract template", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
];
|
||||
|
||||
@@ -575,6 +581,13 @@ function CargoRow({
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{node.hasContractTemplate ? (
|
||||
<Tooltip label="Bulk contract templates are written for this cargo type" withArrow>
|
||||
<Badge size="xs" variant="light" color="grape" radius="sm">
|
||||
Contract
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{node.unitOfMeasure ? (
|
||||
<Tooltip label="How bookings measure this cargo" withArrow>
|
||||
<Badge size="xs" variant="light" color="teal" radius="sm">
|
||||
|
||||
@@ -11,29 +11,33 @@ export interface ContractTemplateArticle {
|
||||
|
||||
export interface ContractTemplate {
|
||||
id: string;
|
||||
// Import/export split by customs clearing; intercity is domestic, crosses no
|
||||
// border, and so has a single template.
|
||||
code:
|
||||
| "IMPORT_BULK_CUSTOMS"
|
||||
| "IMPORT_BULK_NO_CUSTOMS"
|
||||
| "EXPORT_BULK_CUSTOMS"
|
||||
| "EXPORT_BULK_NO_CUSTOMS"
|
||||
| "INTERCITY_BULK"
|
||||
| "IMPORT_CONTAINER_CUSTOMS"
|
||||
| "IMPORT_CONTAINER_NO_CUSTOMS"
|
||||
| "EXPORT_CONTAINER_CUSTOMS"
|
||||
| "EXPORT_CONTAINER_NO_CUSTOMS"
|
||||
| "INTERCITY_CONTAINER";
|
||||
// System container templates use the fixed DIRECTION_CONTAINER(_CUSTOMS)
|
||||
// codes; staff-created bulk templates get generated BULK_<cargo>_* codes.
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
documentTitle: string;
|
||||
whereasClauses: string[];
|
||||
articles: ContractTemplateArticle[];
|
||||
isActive: boolean;
|
||||
/** Bulk templates only: the cargo type this template is written for. */
|
||||
cargoTypeId?: string | null;
|
||||
cargoType?: { id: string; cargoTypeName: string } | null;
|
||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
||||
withCustoms?: boolean | null;
|
||||
/** The five seeded container templates — cannot be deleted. */
|
||||
isSystem: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateContractTemplatePayload {
|
||||
cargoTypeId: string;
|
||||
withCustoms: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateContractTemplatePayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -54,6 +58,15 @@ export const contractTemplatesService = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(payload: CreateContractTemplatePayload): Promise<ContractTemplate> {
|
||||
const { data } = await client.post<ContractTemplate>(BASE, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async remove(code: string): Promise<void> {
|
||||
await client.delete(`${BASE}/${code}`);
|
||||
},
|
||||
|
||||
async getByCode(code: string): Promise<ContractTemplate> {
|
||||
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user