mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge pull request #1173 from Tria-plc/freight_feature/usermanagement
feat(contracts): per-cargo bulk contract templates
This commit is contained in:
@@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
contract.freightType,
|
contract.freightType,
|
||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
);
|
);
|
||||||
dynamicTemplate = dynamicSource
|
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,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
|||||||
import { ContractTemplatesService } from "./contract-templates.service";
|
import { ContractTemplatesService } from "./contract-templates.service";
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
|
CreateContractTemplateDto,
|
||||||
PreviewContractTemplateDto,
|
PreviewContractTemplateDto,
|
||||||
ReplaceArticlesDto,
|
ReplaceArticlesDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
UpdateContractTemplateDto,
|
UpdateContractTemplateDto,
|
||||||
} from "./dto/contract-template.dto";
|
} 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")
|
@ApiTags("contract-templates")
|
||||||
@Controller("contract-templates")
|
@Controller("contract-templates")
|
||||||
export class ContractTemplatesController {
|
export class ContractTemplatesController {
|
||||||
constructor(private readonly service: ContractTemplatesService) {}
|
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()
|
@Get()
|
||||||
@BookingStaff([
|
@BookingStaff(TEMPLATE_READ)
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
@ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
|
||||||
FREIGHT_PERMS.admin,
|
|
||||||
])
|
|
||||||
@ApiOperation({ summary: "List the six contract document templates" })
|
|
||||||
list() {
|
list() {
|
||||||
return this.service.list();
|
return this.service.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":code")
|
@Post()
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
FREIGHT_PERMS.settings.contractTemplates.create,
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||||
FREIGHT_PERMS.admin,
|
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" })
|
@ApiOperation({ summary: "Get one contract template by code" })
|
||||||
getByCode(@Param("code") code: string) {
|
getByCode(@Param("code") code: string) {
|
||||||
return this.service.getByCode(code);
|
return this.service.getByCode(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":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)" })
|
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
|
||||||
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
||||||
return this.service.update(code, dto);
|
return this.service.update(code, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":code/preview")
|
@Delete(":code")
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
FREIGHT_PERMS.settings.contractTemplates.delete,
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
|
||||||
FREIGHT_PERMS.admin,
|
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({
|
@ApiOperation({
|
||||||
summary: "Render an HTML preview of the template against mock contract data",
|
summary: "Render an HTML preview of the template against mock contract data",
|
||||||
})
|
})
|
||||||
@@ -77,21 +109,21 @@ export class ContractTemplatesController {
|
|||||||
/* ------------------------- article routes ------------------------- */
|
/* ------------------------- article routes ------------------------- */
|
||||||
|
|
||||||
@Put(":code/articles")
|
@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)" })
|
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
|
||||||
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
||||||
return this.service.replaceArticles(code, dto.articles);
|
return this.service.replaceArticles(code, dto.articles);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":code/articles")
|
@Post(":code/articles")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Add an article to the template" })
|
@ApiOperation({ summary: "Add an article to the template" })
|
||||||
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
||||||
return this.service.addArticle(code, dto);
|
return this.service.addArticle(code, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":code/articles/:articleId")
|
@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" })
|
@ApiOperation({ summary: "Update an article's title or body" })
|
||||||
updateArticle(
|
updateArticle(
|
||||||
@Param("code") code: string,
|
@Param("code") code: string,
|
||||||
@@ -102,7 +134,7 @@ export class ContractTemplatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":code/articles/:articleId")
|
@Delete(":code/articles/:articleId")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Remove an article from the template" })
|
@ApiOperation({ summary: "Remove an article from the template" })
|
||||||
removeArticle(
|
removeArticle(
|
||||||
@Param("code") code: string,
|
@Param("code") code: string,
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import {
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
ContractTemplate,
|
import { ContractTemplate } from "./entities/contract-template.entity";
|
||||||
ContractTemplateCode,
|
|
||||||
} from "./entities/contract-template.entity";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||||
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
|||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
|
findByCode(code: string): Promise<ContractTemplate | null> {
|
||||||
return this.repository.findOne({ where: { code } });
|
return this.repository.findOne({ where: { code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
override findAll(): Promise<ContractTemplate[]> {
|
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> {
|
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 { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||||
@@ -11,6 +16,7 @@ import {
|
|||||||
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
|
CreateContractTemplateDto,
|
||||||
PreviewContractTemplateDto,
|
PreviewContractTemplateDto,
|
||||||
ReplaceArticleDto,
|
ReplaceArticleDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
|
|||||||
async list(): Promise<ContractTemplate[]> {
|
async list(): Promise<ContractTemplate[]> {
|
||||||
const templates = await this.repository.findAll();
|
const templates = await this.repository.findAll();
|
||||||
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
||||||
return templates.sort(
|
// Seeded container templates first in canonical order, then staff-created
|
||||||
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
|
// 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> {
|
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) {
|
if (!template) {
|
||||||
throw new NotFoundException(`Contract template ${code} not found`);
|
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
|
* Staff-created bulk template for one (cargo type, customs option) pair.
|
||||||
* direction/freight/customs triple; null when missing or deactivated (the
|
* The cargo type must have hasContractTemplate enabled and the combination
|
||||||
* renderer then falls back to the built-in generic layout).
|
* 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(
|
async findActiveForContract(
|
||||||
tradeDirection?: string | null,
|
tradeDirection?: string | null,
|
||||||
freightType?: string | null,
|
freightType?: string | null,
|
||||||
customsClearingEnabled?: boolean | null,
|
customsClearingEnabled?: boolean | null,
|
||||||
|
cargoTypeId?: string | null,
|
||||||
): Promise<ContractTemplate | 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(
|
const code = contractTemplateCodeFor(
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
freightType,
|
freightType,
|
||||||
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
|
|||||||
: this.sorted(template.articles),
|
: this.sorted(template.articles),
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = this.buildMockView(template.code, dynamicTemplate);
|
const view = this.buildMockView(template, dynamicTemplate);
|
||||||
return { html: this.renderer.render(view) };
|
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(
|
private buildMockView(
|
||||||
code: ContractTemplateCode,
|
template: ContractTemplate,
|
||||||
dynamicTemplate: ContractDynamicTemplateView,
|
dynamicTemplate: ContractDynamicTemplateView,
|
||||||
): ContractViewModel {
|
): ContractViewModel {
|
||||||
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
|
const code = template.code;
|
||||||
const isBulk = code.endsWith("_BULK");
|
const previewKey = this.previewKeyFor(template);
|
||||||
|
const meta = getTemplateMeta(previewKey);
|
||||||
|
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Representative rate schedule so the admin preview shows the live-rate
|
// Representative rate schedule so the admin preview shows the live-rate
|
||||||
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
|
|||||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||||
reference: "EDR/CT/2026/0042",
|
reference: "EDR/CT/2026/0042",
|
||||||
status: "CONTRACT_READY",
|
status: "CONTRACT_READY",
|
||||||
templateKey: PREVIEW_TEMPLATE_KEYS[code],
|
templateKey: previewKey,
|
||||||
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
||||||
contractDate: now.toLocaleDateString("en-GB", {
|
contractDate: now.toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Static, representative rate schedule for the admin preview only. */
|
/** 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")
|
const dir = code.startsWith("IMPORT")
|
||||||
? "import"
|
? "import"
|
||||||
: code.startsWith("EXPORT")
|
: 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[] {
|
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
|
||||||
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,41 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} 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 {
|
export class UpdateContractTemplateDto {
|
||||||
@ApiPropertyOptional({ description: "Display name of the template" })
|
@ApiPropertyOptional({ description: "Display name of the template" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
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
|
* The five seeded container templates (import/export split by customs
|
||||||
* customs clearing (× freight type = 8); intercity does not, because it is a
|
* clearing; intercity is domestic, crosses no border, so it has a single
|
||||||
* purely domestic Ethiopian movement that crosses no border and therefore has
|
* template). These are system rows: always present, never deletable.
|
||||||
* no customs leg at all (× freight type = 2).
|
*
|
||||||
|
* 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
|
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||||
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "contract_templates" })
|
@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 {
|
export class ContractTemplate extends BaseEntity {
|
||||||
@Column({ name: "code", type: "varchar", length: 40, unique: true })
|
@Column({ name: "code", type: "varchar", length: 80 })
|
||||||
code!: ContractTemplateCode;
|
code!: string;
|
||||||
|
|
||||||
@Column({ name: "name", type: "varchar", length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "is_active", type: "boolean", default: true })
|
@Column({ name: "is_active", type: "boolean", default: true })
|
||||||
isActive!: boolean;
|
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.tradeDirection,
|
||||||
contract.freightType,
|
contract.freightType,
|
||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
);
|
);
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export class CreateCargoTypeDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasLashing?: boolean;
|
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 })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||||
hasLashing!: boolean;
|
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 })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,35 @@ export class CargoTypesService {
|
|||||||
return map;
|
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. */
|
/** Create a new cargo type. */
|
||||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||||
const code = generateCode(dto.cargoTypeName);
|
const code = generateCode(dto.cargoTypeName);
|
||||||
@@ -144,6 +173,9 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
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', {
|
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
|
||||||
explicitOrder: dto.displayOrder,
|
explicitOrder: dto.displayOrder,
|
||||||
@@ -160,6 +192,7 @@ export class CargoTypesService {
|
|||||||
code,
|
code,
|
||||||
cargoTypeName: dto.cargoTypeName,
|
cargoTypeName: dto.cargoTypeName,
|
||||||
parentGroupId: dto.parentGroupId ?? null,
|
parentGroupId: dto.parentGroupId ?? null,
|
||||||
|
hasContractTemplate: dto.hasContractTemplate ?? false,
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||||
@@ -183,6 +216,18 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
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 {
|
const {
|
||||||
wagonTypeIds,
|
wagonTypeIds,
|
||||||
itemsPerWagonMap,
|
itemsPerWagonMap,
|
||||||
|
|||||||
@@ -1273,6 +1273,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:settings:contract_templates:manage",
|
"edr_freight_app:settings:contract_templates:manage",
|
||||||
"Edit contract templates & articles",
|
"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,
|
// N. Previously-ungated staff surfaces (support inbox, procurement, compliance,
|
||||||
@@ -1816,6 +1838,10 @@ export const FREIGHT_PERMS = {
|
|||||||
contractTemplates: {
|
contractTemplates: {
|
||||||
view: "edr_freight_app:settings:contract_templates:view",
|
view: "edr_freight_app:settings:contract_templates:view",
|
||||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
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: {
|
audit: {
|
||||||
|
|||||||
@@ -835,7 +835,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="contract-templates"
|
path="contract-templates"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ContractTemplatesPage />
|
<ContractTemplatesPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -843,7 +848,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="contract-templates/:code"
|
path="contract-templates/:code"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ContractTemplateEditorPage />
|
<ContractTemplateEditorPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,7 +488,11 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
label: "Contract templates",
|
label: "Contract templates",
|
||||||
href: "/dashboard/contract-templates",
|
href: "/dashboard/contract-templates",
|
||||||
icon: <ScrollText />,
|
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",
|
label: "Audit logs",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
|
|||||||
import {
|
import {
|
||||||
contractTemplatesService,
|
contractTemplatesService,
|
||||||
type ArticlePayload,
|
type ArticlePayload,
|
||||||
|
type CreateContractTemplatePayload,
|
||||||
type UpdateContractTemplatePayload,
|
type UpdateContractTemplatePayload,
|
||||||
} from "@/services/contract-templates.service";
|
} 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) {
|
export function useUpdateContractTemplate(code: string) {
|
||||||
return useTemplateMutation(
|
return useTemplateMutation(
|
||||||
(payload: UpdateContractTemplatePayload) =>
|
(payload: UpdateContractTemplatePayload) =>
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ export const FREIGHT_PERMS = {
|
|||||||
contractTemplates: {
|
contractTemplates: {
|
||||||
view: "edr_freight_app:settings:contract_templates:view",
|
view: "edr_freight_app:settings:contract_templates:view",
|
||||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
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: {
|
audit: {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -19,11 +23,21 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
|
Lock,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
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 type { ContractTemplate } from "@/services/contract-templates.service";
|
||||||
import TemplatePreviewModal from "./TemplatePreviewModal";
|
import TemplatePreviewModal from "./TemplatePreviewModal";
|
||||||
|
|
||||||
@@ -39,22 +53,18 @@ const DIRECTION_DOT: Record<string, string> = {
|
|||||||
INTERCITY: "var(--mantine-color-orange-5)",
|
INTERCITY: "var(--mantine-color-orange-5)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function templateDirection(code: ContractTemplate["code"]): string {
|
function isBulk(template: ContractTemplate): boolean {
|
||||||
return code.split("_")[0];
|
return Boolean(template.cargoTypeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
|
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
|
||||||
// the second one — never the suffix.
|
// domestic and crosses no border, so it has no customs variant at all — hence
|
||||||
function isBulk(code: ContractTemplate["code"]): boolean {
|
// null rather than false, which would wrongly read as a deliberate "client
|
||||||
return code.split("_")[1] === "BULK";
|
// clears its own customs" choice.
|
||||||
}
|
function customsVariant(template: ContractTemplate): boolean | null {
|
||||||
|
if (isBulk(template)) return template.withCustoms ?? null;
|
||||||
// Intercity is domestic and crosses no border, so it has no customs variant at
|
if (template.code.endsWith("_NO_CUSTOMS")) return false;
|
||||||
// all — hence null rather than false, which would wrongly read as a deliberate
|
if (template.code.endsWith("_CUSTOMS")) return true;
|
||||||
// "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;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +76,28 @@ function formatUpdated(value: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CargoTypeOption {
|
||||||
|
id: string;
|
||||||
|
cargoTypeName?: string;
|
||||||
|
hasContractTemplate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractTemplatesPage() {
|
export default function ContractTemplatesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { data: templates, isLoading } = useContractTemplates();
|
const { data: templates, isLoading } = useContractTemplates();
|
||||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
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);
|
const previewTemplate = templates?.find((t) => t.code === previewCode);
|
||||||
|
|
||||||
@@ -77,20 +105,34 @@ export default function ContractTemplatesPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Contract templates"
|
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">
|
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||||
{isLoading
|
{isLoading
|
||||||
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||||
: (templates ?? []).map((template) => (
|
: (templates ?? []).map((template) => (
|
||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={template.code}
|
key={template.code}
|
||||||
template={template}
|
template={template}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
onPreview={() => setPreviewCode(template.code)}
|
onPreview={() => setPreviewCode(template.code)}
|
||||||
onEdit={() =>
|
onEdit={() =>
|
||||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||||
}
|
}
|
||||||
|
onDelete={() => setDeleteTarget(template)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -100,22 +142,174 @@ export default function ContractTemplatesPage() {
|
|||||||
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
|
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
|
||||||
onClose={() => setPreviewCode(null)}
|
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>
|
</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({
|
function TemplateCard({
|
||||||
template,
|
template,
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
onPreview,
|
onPreview,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
template: ContractTemplate;
|
template: ContractTemplate;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const direction = templateDirection(template.code);
|
const bulk = isBulk(template);
|
||||||
const bulk = isBulk(template.code);
|
const direction = template.code.split("_")[0];
|
||||||
const customs = customsVariant(template.code);
|
const customs = customsVariant(template);
|
||||||
|
const kicker = bulk
|
||||||
|
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
|
||||||
|
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -142,12 +336,13 @@ function TemplateCard({
|
|||||||
style={{
|
style={{
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
flexShrink: 0,
|
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">
|
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||||
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
|
{kicker}
|
||||||
{bulk ? "Bulk" : "Container"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -166,6 +361,18 @@ function TemplateCard({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</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 && (
|
{!template.isActive && (
|
||||||
<Tooltip label="Not used for new contracts" withArrow>
|
<Tooltip label="Not used for new contracts" withArrow>
|
||||||
<Badge size="sm" variant="light" color="red">
|
<Badge size="sm" variant="light" color="red">
|
||||||
@@ -221,16 +428,34 @@ function TemplateCard({
|
|||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Group gap={6} wrap="nowrap">
|
||||||
variant="light"
|
{canDelete && !template.isSystem && (
|
||||||
color="edr-green"
|
<Tooltip label="Delete template" withArrow>
|
||||||
size="compact-sm"
|
<Button
|
||||||
radius="md"
|
variant="subtle"
|
||||||
leftSection={<Pencil size={14} />}
|
color="red"
|
||||||
onClick={onEdit}
|
size="compact-sm"
|
||||||
>
|
radius="md"
|
||||||
Edit articles
|
px={8}
|
||||||
</Button>
|
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>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
|
|||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
||||||
hasLashing?: boolean;
|
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. */
|
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||||
unitOfMeasure?: string | null;
|
unitOfMeasure?: string | null;
|
||||||
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
||||||
@@ -112,6 +114,10 @@ const FORM_FIELDS: FormFieldDef[] = [
|
|||||||
// When on, every booking of this cargo type is charged the flat LASHING
|
// When on, every booking of this cargo type is charged the flat LASHING
|
||||||
// surcharge (a rate with trigger = Lashing).
|
// surcharge (a rate with trigger = Lashing).
|
||||||
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
{ 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" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -576,6 +582,13 @@ function CargoRow({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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 ? (
|
{node.unitOfMeasure ? (
|
||||||
<Tooltip label="How bookings measure this cargo" withArrow>
|
<Tooltip label="How bookings measure this cargo" withArrow>
|
||||||
<Badge size="xs" variant="light" color="teal" radius="sm">
|
<Badge size="xs" variant="light" color="teal" radius="sm">
|
||||||
|
|||||||
@@ -11,29 +11,33 @@ export interface ContractTemplateArticle {
|
|||||||
|
|
||||||
export interface ContractTemplate {
|
export interface ContractTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
// Import/export split by customs clearing; intercity is domestic, crosses no
|
// System container templates use the fixed DIRECTION_CONTAINER(_CUSTOMS)
|
||||||
// border, and so has a single template.
|
// codes; staff-created bulk templates get generated BULK_<cargo>_* codes.
|
||||||
code:
|
code: string;
|
||||||
| "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";
|
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
whereasClauses: string[];
|
whereasClauses: string[];
|
||||||
articles: ContractTemplateArticle[];
|
articles: ContractTemplateArticle[];
|
||||||
isActive: boolean;
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateContractTemplatePayload {
|
||||||
|
cargoTypeId: string;
|
||||||
|
withCustoms: boolean;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateContractTemplatePayload {
|
export interface UpdateContractTemplatePayload {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -54,6 +58,15 @@ export const contractTemplatesService = {
|
|||||||
return data;
|
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> {
|
async getByCode(code: string): Promise<ContractTemplate> {
|
||||||
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
|
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
Reference in New Issue
Block a user