Merge pull request #453 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-04 08:00:26 +03:00
committed by GitHub
23 changed files with 537 additions and 101 deletions

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Replace load-type string matching with a real wagon-type foreign key.
*
* Before this migration, train scheduling picked a wagon type by matching
* strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
* and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
* `cargo_types` and `container_types` so scheduling resolves the wagon type
* through the relation instead.
*
* The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
* never ship in bulk have no wagon type, and forcing one onto them is
* meaningless. Scheduling enforces the requirement at run time (it throws when a
* scheduled bulk cargo type or a container type in the batch has no wagon type).
*
* Backfill reproduces the old hardcoded resolution one final time so existing
* bulk cargo + container rows are not left unset. After this, the runtime map is
* removed — the FK is the single source of truth.
*/
export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
implements MigrationInterface
{
name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Columns + FKs ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD CONSTRAINT fk_cargo_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD CONSTRAINT fk_container_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
ON freight.cargo_types (wagon_type_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
ON freight.container_types (wagon_type_id);
`);
// ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
// COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
// COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
const cargoCodeToWagon: Record<string, string> = {
COFFEE: "KW2",
GRAIN: "KW2",
WHEAT: "KW2",
SORGHUM: "KW2",
CORN: "KW2",
FERTILIZER: "PW2",
SUGAR: "PW2",
COAL: "KW3",
STEEL: "CW3",
ORE: "CW3",
};
for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
await queryRunner.query(
`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = $1
AND UPPER(TRIM(ct.code)) = $2
AND ct.wagon_type_id IS NULL;
`,
[wagonCode, cargoCode],
);
}
// Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
await queryRunner.query(`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'CW3'
AND ct.wagon_type_id IS NULL
AND ct.unit_of_measure = 'PER_TON';
`);
// All container types → the old container default wagon NW5.
await queryRunner.query(`
UPDATE freight.container_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'NW5'
AND ct.wagon_type_id IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
`);
}
}

View File

@@ -1082,8 +1082,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationYard: true,
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
bookingContainers: { containerType: true, units: true },
cargoType: true,
// containerType.wagonType + cargoType.wagonType drive wagon-type
// resolution during scheduling (FK, not the old load-type string map).
bookingContainers: { containerType: { wagonType: true }, units: true },
cargoType: { wagonType: true },
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});

View File

@@ -21,6 +21,14 @@ export class CreateCargoTypeDto {
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({
description:
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -30,6 +30,14 @@ export class CreateContainerTypeDto {
@IsBoolean()
isOpenTop?: boolean;
@ApiPropertyOptional({
description:
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -1,11 +1,13 @@
import { BaseEntity } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'cargo_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['parentGroupId'])
@Index(['wagonTypeId'])
@Index(['code'])
export class CargoType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
@@ -25,6 +27,19 @@ export class CargoType extends BaseEntity {
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
unitOfMeasure?: CargoUnitOfMeasure | null;
/**
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
* through this FK. Nullable — grouping rows and container/legacy cargo never
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -1,10 +1,12 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['code'])
@Index(['isActive'])
@Index(['wagonTypeId'])
export class ContainerType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity {
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
isOpenTop!: boolean;
/**
* Wagon type that carries this container. Replaces the former hardcoded
* container wagon-code default (NW5): train scheduling resolves the container
* wagon type through this FK. Nullable; scheduling throws if a scheduled
* container type leaves it unset.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
}
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
}
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -74,7 +74,7 @@ export class RatesRepository implements IRatesRepository {
}
async update(id: string, data: Partial<Rate>): Promise<Rate | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -65,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
}
async update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -82,6 +82,7 @@ export class CargoTypesService {
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}

View File

@@ -64,6 +64,7 @@ export class ContainerTypesService {
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}

View File

@@ -15,7 +15,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Not } from 'typeorm';
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
@@ -38,6 +38,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
@@ -87,10 +89,6 @@ import {
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
import {
getDefaultContainerWagonTypeCode,
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
@@ -2725,28 +2723,98 @@ export class TrainSchedulingService {
return violations;
}
/**
* Resolve the wagon type for a batch through the cargo-type / container-type
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
* when the relevant type has no wagon type configured — scheduling is blocked
* until an admin assigns one on the cargo-type / container-type config screen.
*/
private async resolveWagonType(
freightType: 'CONTAINER' | 'BULK',
bookingIds: string[],
): Promise<WagonType> {
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
if (freightType === 'CONTAINER') {
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
});
if (!wagonType) {
throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
// First container type present on the batch drives the container wagon
// type (matches the prior single-wagon-type-per-consist behavior).
const containerType = bookings
.flatMap((b) => b.bookingContainers ?? [])
.map((line) => line.containerType)
.find((ct): ct is NonNullable<typeof ct> => Boolean(ct));
if (!containerType) {
throw new BadRequestException('No container type found on the container booking(s)');
}
const wagonType = await this.loadWagonTypeForType(
containerType.wagonTypeId ?? null,
`Container type "${containerType.label ?? containerType.code}"`,
);
return wagonType;
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const cargoCode = bookings[0]?.cargoType?.code ?? null;
const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
const picked = pickBulkWagonType(wagonTypes, cargoCode);
if (!picked) {
throw new NotFoundException('No suitable bulk wagon type found');
const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct));
if (!cargoType) {
throw new BadRequestException('No cargo type found on the bulk booking(s)');
}
return picked;
return this.loadWagonTypeForType(
cargoType.wagonTypeId ?? null,
`Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`,
);
}
/**
* Load an active wagon type by FK id, throwing a clear error when the id is
* unset (type not configured) or points at a missing/inactive wagon type.
*/
private async loadWagonTypeForType(
wagonTypeId: string | null,
typeLabel: string,
): Promise<WagonType> {
if (!wagonTypeId) {
throw new BadRequestException(
`${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`,
);
}
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { id: wagonTypeId, isActive: true },
});
if (!wagonType) {
throw new NotFoundException(
`${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`,
);
}
return wagonType;
}
/**
* Soft wagon-type resolution for the customer-facing availability preview
* (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
* returns null (→ "no days") instead of throwing when nothing is configured,
* since this only estimates which days have wagons and creates no booking.
*/
private async resolveWagonTypeForPreview(
freightType: 'CONTAINER' | 'BULK',
cargoTypeCode: string | null,
): Promise<WagonType | null> {
if (freightType === 'BULK') {
if (!cargoTypeCode) return null;
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
where: { code: cargoTypeCode },
relations: { wagonType: true },
});
return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
}
// Container preview: the input carries no specific container type, so use the
// wagon type of the first configured (active) container type.
const containerType = await this.dataSource
.getRepository(ContainerType)
.findOne({
where: { isActive: true, wagonTypeId: Not(IsNull()) },
relations: { wagonType: true },
order: { displayOrder: 'ASC' },
});
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
}
private async persistTrainSetWagons(
@@ -3419,15 +3487,12 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
const wagonTypes = await this.dataSource.getRepository(WagonType).find();
// Resolve the wagon type this cargo needs.
const requiredType =
input.freightType === 'BULK'
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
: wagonTypes.find(
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
);
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
// Soft (customer availability preview): no days if unresolved, never throws.
const requiredType = await this.resolveWagonTypeForPreview(
input.freightType,
input.cargoTypeCode ?? null,
);
if (!requiredType) return { days: [] };
// How many wagons of that type the cargo needs.

View File

@@ -1,49 +0,0 @@
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
COFFEE: 'KW2',
GRAIN: 'KW2',
WHEAT: 'KW2',
SORGHUM: 'KW2',
CORN: 'KW2',
FERTILIZER: 'PW2',
SUGAR: 'PW2',
COAL: 'KW3',
STEEL: 'CW3',
ORE: 'CW3',
};
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
/**
* Resolve wagon type code from cargo type code for bulk freight.
*/
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
const normalized = cargoTypeCode.trim().toUpperCase();
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
}
/**
* Pick the best matching wagon type entity for bulk cargo.
*/
export function pickBulkWagonType(
wagonTypes: WagonType[],
cargoTypeCode?: string | null,
): WagonType | undefined {
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
if (direct) return direct;
return wagonTypes.find(
(wt) =>
wt.isActive &&
!wt.supportsContainer &&
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
);
}
export function getDefaultContainerWagonTypeCode(): string {
return DEFAULT_CONTAINER_WAGON_TYPE;
}

View File

@@ -21,6 +21,8 @@ interface AuthEmployeePosition {
isDelegate?: boolean;
parentPositionId?: string | null;
permissions?: AuthPermission[];
/** Some IAM payloads nest the position record instead of flattening its key. */
position?: { id?: string; key?: string; name?: LocaleText };
}
interface AuthEmployeeRecord {

View File

@@ -174,6 +174,23 @@ export const useContainerTypeOptions = (
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
});
/**
* Active wagon-type options for the cargo-type / container-type "Wagon type"
* picker. The FK the selection sets drives train-scheduling wagon resolution.
*/
export const useWagonTypeOptions = (enabled = true) =>
useQuery({
...api.wagonTypes.list.queryOptions(),
enabled,
select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) =>
rows
.filter((wt) => wt.isActive !== false)
.map((wt) => ({
label: wt.name ? `${wt.name} (${wt.code})` : wt.code,
value: wt.id,
})),
});
const LIVE_RATE_PAGE_SIZE = 500;
export const useLiveRateOptions = (enabled = true) =>

View File

@@ -73,15 +73,23 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
return [...keys];
}
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
/**
* Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl").
* Tolerates IAM payload shape variants: the key flat on the employee position,
* nested under `position.key`, or the GL modeled as a role instead.
*/
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const emp of user.employee ?? []) {
for (const pos of emp.positions ?? []) {
if (pos.key) keys.add(pos.key);
if (pos.position?.key) keys.add(pos.position.key);
}
}
for (const role of user.roles ?? []) {
if (role.key) keys.add(role.key);
}
return [...keys];
}

View File

@@ -596,21 +596,58 @@ export default function ContractRequestDetailPage() {
No cargo scope lines.
</Text>
) : (
<Stack gap="xs">
{(contract.cargoScope ?? []).map((s) => (
<Group key={s.id} gap={8} wrap="nowrap">
<BoxIcon
size={15}
color="var(--mantine-color-edr-green-6)"
/>
<Text size="sm">
{s.containerSize ??
s.cargoFreeText ??
s.cargoTypeId ??
"Cargo"}
</Text>
</Group>
))}
<Stack gap="sm">
{(contract.cargoScope ?? []).map((s) => {
const isContainer = Boolean(s.containerSize);
// Bulk lines carry their commodity detail (name + unit);
// container lines carry the size (20ft / 40ft).
const title = isContainer
? `${s.containerSize} container`
: (s.cargoType?.cargoTypeName ??
s.cargoFreeText ??
s.cargoType?.code ??
"Bulk cargo");
// quantityCap unit: containers for a size line, else the
// cargo type's unit of measure (tons / items / …), default tons.
const capUnit = isContainer
? "containers"
: (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons");
return (
<Group key={s.id} gap={8} wrap="nowrap" align="flex-start">
<BoxIcon
size={15}
color="var(--mantine-color-edr-green-6)"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div>
<Text size="sm" fw={500}>
{title}
</Text>
<Group gap={6} mt={2}>
<Badge
variant="light"
color={isContainer ? "blue" : "grape"}
radius="sm"
size="xs"
tt="uppercase"
>
{isContainer ? "Container" : "Bulk"}
</Badge>
{s.cargoType?.code ? (
<Text size="xs" c="dimmed">
Code: {s.cargoType.code}
</Text>
) : null}
<Text size="xs" c="dimmed">
{s.quantityCap != null
? `Cap: ${s.quantityCap} ${capUnit}`
: "Cap: uncapped"}
</Text>
</Group>
</div>
</Group>
);
})}
</Stack>
)}
</SectionCard>

View File

@@ -40,6 +40,7 @@ import {
import {
useRuleEngineList,
useRuleEngineMutations,
useWagonTypeOptions,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -53,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
wagonTypeId?: string | null;
isActive?: boolean;
displayOrder?: number;
}
@@ -78,6 +81,18 @@ const FORM_FIELDS: FormFieldDef[] = [
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
],
},
{
// Wagon type that carries this (bulk) commodity — drives train-scheduling
// wagon resolution. Optional: leave "None" for grouping categories and
// container/legacy cargo; set it on scheduled bulk commodities.
// Options injected at render from useWagonTypeOptions.
name: "wagonTypeId",
label: "Wagon type",
type: "select",
optional: true,
placeholder: "Select wagon type (bulk cargo)",
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
@@ -104,6 +119,24 @@ const CargoTypesPage = () => {
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
const formFields = useMemo<FormFieldDef[]>(
() =>
FORM_FIELDS.map((field) =>
field.name === "wagonTypeId"
? {
...field,
options: [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
...(wagonTypeOptions ?? []),
],
}
: field,
),
[wagonTypeOptions],
);
const [search, setSearch] = useState("");
const [formMode, setFormMode] = useState<FormMode | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
@@ -349,7 +382,7 @@ const CargoTypesPage = () => {
? "Create a top-level cargo category."
: "Create a cargo type inside this category. It's attached here automatically."
}
fields={FORM_FIELDS}
fields={formFields}
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
isSubmitting={create.isPending || update.isPending}
onSubmit={handleSubmit}

View File

@@ -33,6 +33,7 @@ import {
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
useWagonTypeOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => {
const usesLiveRateField = Boolean(
config?.formFields.some((f) => f.name === "rateId"),
);
const usesWagonTypeField = Boolean(
config?.formFields.some((f) => f.name === "wagonTypeId"),
);
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
@@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => {
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const formFields = useMemo(() => {
if (!config) return [];
@@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => {
options: liveRateOptions ?? [],
};
}
if (field.name === "wagonTypeId") {
return {
...field,
type: "select" as const,
options: wagonTypeOptions ?? [],
};
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -502,7 +515,8 @@ const RuleEngineResourcePage = () => {
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading)
(usesLiveRateField && liveRateOptionsLoading) ||
(usesWagonTypeField && wagonTypeOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}

View File

@@ -248,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
{
name: "wagonTypeId",
label: "Wagon type",
type: "select",
required: true,
description: "Wagon type used to carry this container during train scheduling.",
},
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
],

View File

@@ -16,6 +16,8 @@ import {
Group,
Loader,
Paper,
Progress,
RingProgress,
SimpleGrid,
Stack,
Tabs,
@@ -211,6 +213,17 @@ export default function ContractDetailPage() {
});
const bookingWindowOpen = hasOpenWindow(bookingWindows);
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
// releases its share and the tracker fills back up. Refetched on window focus so
// it reflects newly created / cancelled shipments.
const { data: capacityLines = [] } = useQuery({
queryKey: ["contract-capacity", id],
queryFn: () => contractsService.getCapacity(id!),
enabled: !!id && contract?.contractKind === "GENERAL",
refetchOnWindowFocus: true,
});
const contractBookings = useMemo(
() =>
(bookingsPage?.items ?? []).filter(
@@ -882,6 +895,88 @@ export default function ContractDetailPage() {
</Card>
</SimpleGrid>
{/* Draw-down capacity — GENERAL contracts with a per-line quantity cap.
Fills as shipments consume capacity; empties again when a shipment is
cancelled/rejected/expired (backend releases it). */}
{isGeneral && capacityLines.length > 0 && (
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<SectionLabel mb="md">Contract capacity</SectionLabel>
<Stack gap="lg">
{capacityLines.map((line, i) => {
const cap = line.cap ?? 0;
const booked = line.booked ?? 0;
const remaining = line.remaining ?? Math.max(0, cap - booked);
const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0;
const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0;
const unit = capacityUnitLabel(contract, line);
const label = isContainer
? `${line.containerSize ?? "Containers"}`
: (contract.cargoScope ?? []).find(
(s) => s.cargoTypeId === line.cargoTypeId,
)?.cargoType?.cargoTypeName ??
(contract.cargoScope ?? [])[0]?.cargoFreeText ??
"Bulk commodity";
return (
<Group
key={line.containerSize ?? line.cargoTypeId ?? i}
align="center"
wrap="nowrap"
gap="lg"
>
<RingProgress
size={72}
thickness={8}
roundCaps
sections={[
{
value: remainingPct,
color: remaining === 0 ? "red" : GREEN,
},
]}
label={
<Text ta="center" fz={13} fw={700} style={{ color: INK }}>
{remainingPct}%
</Text>
}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" mb={6} wrap="nowrap">
<Group gap={8} wrap="nowrap">
{isContainer ? (
<Package size={16} color={MUTED} />
) : (
<Weight size={16} color={MUTED} />
)}
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
</Group>
<Text fz={13} c="dimmed">
{booked} / {cap} {unit} booked
</Text>
</Group>
<Progress
value={usedPct}
size="md"
radius="xl"
color={remaining === 0 ? "red" : GREEN}
/>
<Text fz={12} c="dimmed" mt={6}>
{remaining} {unit} remaining
</Text>
</Box>
</Group>
);
})}
</Stack>
</Card>
)}
{/* Signatures */}
{(contract.signatures ?? []).length > 0 && (
<Card
@@ -1351,6 +1446,22 @@ function SectionLabel({
);
}
/**
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
*/
function capacityUnitLabel(
contract: Freight.IContract,
line: Freight.ContractCapacityLine,
): string {
if (contract.freightType === "CONTAINER") return "containers";
const scope =
(contract.cargoScope ?? []).find(
(s) => s.cargoTypeId === line.cargoTypeId,
) ?? (contract.cargoScope ?? [])[0];
return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons";
}
/**
* One document row in the Documents tab: the file's kind (passport, business
* license, contract, …) derived from its `code` as the primary label, the

View File

@@ -132,6 +132,13 @@ export interface IContractCargoScope {
/** "20ft" | "40ft"; null for bulk. */
containerSize?: string | null;
cargoTypeId?: string | null;
/** Bulk cargo type detail (name + unit), loaded on the contract detail. */
cargoType?: {
id: string;
code?: string | null;
cargoTypeName?: string | null;
unitOfMeasure?: string | null;
} | null;
cargoFreeText?: string | null;
/**
* GENERAL contracts: total quantity bookable across all shipments on this line