mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1055 from Tria-plc/freight_feature/usermanagement
enhance contract and train scheduling features
This commit is contained in:
@@ -14,10 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||
};
|
||||
|
||||
// Input set has two required docs. Non-customs bookings resolve to the
|
||||
// ONE_TIME self-clearance document set.
|
||||
// Input set has two required docs. Non-customs bookings resolve to their
|
||||
// own without-customs document set.
|
||||
const inputSetting = {
|
||||
code: 'contract_clearance_selfclear_import_container',
|
||||
code: 'clearance_import_container_without_customs',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', isRequired: true },
|
||||
@@ -201,7 +201,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
*/
|
||||
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
||||
const inputSetting = {
|
||||
code: 'contract_clearance_selfclear_import_container',
|
||||
code: 'clearance_import_container_without_customs',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
||||
|
||||
@@ -11,10 +11,8 @@ describe('clearance.util — clearanceSettingCode', () => {
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||
'clearance_import_container_with_customs',
|
||||
);
|
||||
// Non-customs bookings self-clear with the same document set a ONE_TIME
|
||||
// self-clear contract uses.
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
||||
'contract_clearance_selfclear_import_container',
|
||||
'clearance_import_container_without_customs',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,7 +21,7 @@ describe('clearance.util — clearanceSettingCode', () => {
|
||||
'clearance_export_bulk_with_customs',
|
||||
);
|
||||
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
||||
'contract_clearance_selfclear_export_bulk',
|
||||
'clearance_export_bulk_without_customs',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -39,12 +39,11 @@ export function clearanceSettingCode(
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
// Non-customs (Path A) bookings self-clear: the customer proves his own
|
||||
// clearance with the SAME smaller document set a ONE_TIME self-clear
|
||||
// contract uses (customs declaration, release permit, …) — not the
|
||||
// GL-oriented booking sets.
|
||||
// 4 import + 4 export cases (bulk/container × with/without customs) — each
|
||||
// booking resolves to its own clearance_{op}_{freight}_{with|without}_customs
|
||||
// set, independent of any contract-level clearance codes.
|
||||
if (!includesCustoms) {
|
||||
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||
return `clearance_${op}_${freight}_without_customs`;
|
||||
}
|
||||
return `clearance_${op}_${freight}_with_customs`;
|
||||
}
|
||||
|
||||
@@ -26,30 +26,22 @@ function freightFor(freightType: string): Freight {
|
||||
/**
|
||||
* The customer-input clearance setting code, or null when no gate applies.
|
||||
*
|
||||
* - Path B (customs bundled): the customer uploads the documents GL needs to do
|
||||
* the clearance work → `contract_clearance_{op}_{freight}`.
|
||||
* - Path A (no customs): the customer clears the cargo himself and uploads his
|
||||
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
||||
* reviewed by Operations rather than GL.
|
||||
* Contract-level IMPORT/EXPORT clearance has been removed — clearance is
|
||||
* collected per booking instead (see bookings/clearance.util.ts), so this
|
||||
* always returns null for IMPORT/EXPORT now.
|
||||
*
|
||||
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
|
||||
* collects the admin-configured intercity document set after both signatures
|
||||
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
|
||||
* gate and collect the same set per booking instead.
|
||||
* (ops-reviewed). GENERAL intercity contracts skip the contract gate and
|
||||
* collect the same set per booking instead.
|
||||
*/
|
||||
export function contractClearanceSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
_freightType: string,
|
||||
_includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
if (!includesCustoms) {
|
||||
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||
}
|
||||
return `contract_clearance_${op}_${freight}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The GL-output (customs output) setting code, keyed on op + freight. */
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('ContractsService duplicate guard', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ buildBreakdown: async () => ({ lineItems: [] }) } as never,
|
||||
);
|
||||
return (
|
||||
service as unknown as {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||
import { diffContractFields } from './contract-document-diff.util';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
@@ -113,6 +114,7 @@ export class ContractsService {
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
) {}
|
||||
|
||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||
@@ -331,6 +333,33 @@ export class ContractsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Price the contract BEFORE anything persists: a lane with no configured
|
||||
// rate 422s here and the wizard shows its blocking modal — with no orphan
|
||||
// DRAFT row left behind for the customer to trip over on retry. The probe
|
||||
// carries exactly the fields buildBreakdown prices from; relation-only
|
||||
// niceties (cargoType labels) are absent, which only affects display
|
||||
// lines, never the missing-rate gates.
|
||||
await this.pricingService.buildBreakdown({
|
||||
tradeDirection: dto.tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
paymentCurrency: 'USD',
|
||||
customsClearingEnabled: includesCustoms,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
equipmentReturn: dto.equipmentReturn ?? null,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
|
||||
routes: (dto.routes ?? []).map((r, i) => ({
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
sortOrder: r.sortOrder ?? i,
|
||||
})),
|
||||
cargoScope: (dto.cargoScope ?? []).map((c) => ({
|
||||
containerSize: c.containerSize ?? null,
|
||||
cargoTypeId: c.cargoTypeId ?? null,
|
||||
})),
|
||||
} as unknown as Contract);
|
||||
|
||||
// An explicit reference is caller-chosen — a collision there is a real
|
||||
// conflict and should surface. Auto-generated references retry past a
|
||||
// concurrent insert that grabbed the same sequence number.
|
||||
|
||||
@@ -7746,6 +7746,9 @@ export class TrainSchedulingService {
|
||||
weightTons: sb.booking
|
||||
? this.grossBookingWeightTons(sb.booking, tareDims)
|
||||
: 0,
|
||||
// Cargo only (VGM / bulk tons) — what the customer actually booked,
|
||||
// without the wagons' tare. The legs tab shows this per booking.
|
||||
cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0,
|
||||
status: sb.booking?.status ?? null,
|
||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||
freightType: sb.booking?.freightType ?? null,
|
||||
|
||||
@@ -115,43 +115,44 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
poaDelegationDefault(5),
|
||||
];
|
||||
|
||||
/** Legacy combined set, kept for the older per-company-type codes. */
|
||||
const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
{
|
||||
fileKey: "business_license",
|
||||
fileLabel: "Business License / Trade License",
|
||||
helpText:
|
||||
"Verified against the government trade system during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Verified against the TIN registry during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
fileKey: "national_id_passport",
|
||||
fileLabel: "National ID / Passport",
|
||||
helpText: "Verified against the National ID API during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 3,
|
||||
},
|
||||
];
|
||||
// Legacy combined set for the removed per-company-type codes
|
||||
// (company_onboarding_documents_customer/forwarder/transporter/forwarder_dj).
|
||||
// const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
// {
|
||||
// fileKey: "business_license",
|
||||
// fileLabel: "Business License / Trade License",
|
||||
// helpText:
|
||||
// "Verified against the government trade system during registration.",
|
||||
// isRequired: true,
|
||||
// isMultiple: false,
|
||||
// maxFiles: 1,
|
||||
// allowedExtensions: DOC_EXTENSIONS,
|
||||
// maxSizeMb: 10,
|
||||
// displayOrder: 1,
|
||||
// },
|
||||
// {
|
||||
// fileKey: "tin_certificate",
|
||||
// fileLabel: "TIN Certificate",
|
||||
// helpText: "Verified against the TIN registry during registration.",
|
||||
// isRequired: true,
|
||||
// isMultiple: false,
|
||||
// maxFiles: 1,
|
||||
// allowedExtensions: DOC_EXTENSIONS,
|
||||
// maxSizeMb: 10,
|
||||
// displayOrder: 2,
|
||||
// },
|
||||
// {
|
||||
// fileKey: "national_id_passport",
|
||||
// fileLabel: "National ID / Passport",
|
||||
// helpText: "Verified against the National ID API during registration.",
|
||||
// isRequired: true,
|
||||
// isMultiple: false,
|
||||
// maxFiles: 1,
|
||||
// allowedExtensions: DOC_EXTENSIONS,
|
||||
// maxSizeMb: 10,
|
||||
// displayOrder: 3,
|
||||
// },
|
||||
// ];
|
||||
|
||||
interface OnboardingDocumentSetting {
|
||||
code: string;
|
||||
@@ -175,31 +176,32 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
||||
entity: "customer",
|
||||
fields: FOREIGN_ONBOARDING_FIELDS,
|
||||
},
|
||||
// Legacy per-company-type codes (kept for back-compat; no longer used by the portal).
|
||||
{
|
||||
code: "company_onboarding_documents_customer",
|
||||
label: "Customer onboarding documents",
|
||||
entity: "customer",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_forwarder",
|
||||
label: "Forwarder onboarding documents",
|
||||
entity: "other",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_transporter",
|
||||
label: "Transporter onboarding documents",
|
||||
entity: "other",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_forwarder_dj",
|
||||
label: "Djibouti forwarder onboarding documents",
|
||||
entity: "other",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
// Legacy per-company-type codes — removed, unused by any resolver or portal
|
||||
// lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live).
|
||||
// {
|
||||
// code: "company_onboarding_documents_customer",
|
||||
// label: "Customer onboarding documents",
|
||||
// entity: "customer",
|
||||
// fields: LEGACY_ONBOARDING_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "company_onboarding_documents_forwarder",
|
||||
// label: "Forwarder onboarding documents",
|
||||
// entity: "other",
|
||||
// fields: LEGACY_ONBOARDING_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "company_onboarding_documents_transporter",
|
||||
// label: "Transporter onboarding documents",
|
||||
// entity: "other",
|
||||
// fields: LEGACY_ONBOARDING_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "company_onboarding_documents_forwarder_dj",
|
||||
// label: "Djibouti forwarder onboarding documents",
|
||||
// entity: "other",
|
||||
// fields: LEGACY_ONBOARDING_FIELDS,
|
||||
// },
|
||||
];
|
||||
|
||||
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||
@@ -212,7 +214,7 @@ const COMPANY_ONBOARDING_DESCRIPTION =
|
||||
// Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer
|
||||
// uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs).
|
||||
|
||||
const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"];
|
||||
// const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; // only used by the commented-out output field sets below
|
||||
const CLEARANCE_ENTITY = "booking_clearance";
|
||||
|
||||
/** Build a clearance field with sensible defaults; `critical` marks isRequired. */
|
||||
@@ -281,24 +283,23 @@ const EXPORT_BULK_FIELDS: OnboardingField[] = [
|
||||
clearanceField("port_invoice", "Port Invoice", 3),
|
||||
];
|
||||
|
||||
/** GL-uploaded customs output documents (import container). */
|
||||
const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||
clearanceField("im4", "IM4 — Permanent Import Document", 1),
|
||||
clearanceField("im5", "IM5 — Temporary Import Document", 2, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, {
|
||||
extensions: JPG_EXTENSIONS,
|
||||
}),
|
||||
];
|
||||
|
||||
/** GL-uploaded customs output documents (export container). */
|
||||
const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||
clearanceField("ex3", "EX3 — Permanent Export Document", 1),
|
||||
clearanceField("ex8", "EX8 — Export Transit Document", 2),
|
||||
clearanceField("export_release", "Export Release", 3),
|
||||
clearanceField("t1", "T1 — Transport Document", 4),
|
||||
];
|
||||
// GL-uploaded customs output documents — unused now that all
|
||||
// clearance_output_*/contract_clearance_output_* settings are commented out.
|
||||
// const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||
// clearanceField("im4", "IM4 — Permanent Import Document", 1),
|
||||
// clearanceField("im5", "IM5 — Temporary Import Document", 2, {
|
||||
// required: false,
|
||||
// }),
|
||||
// clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, {
|
||||
// extensions: JPG_EXTENSIONS,
|
||||
// }),
|
||||
// ];
|
||||
// const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||
// clearanceField("ex3", "EX3 — Permanent Export Document", 1),
|
||||
// clearanceField("ex8", "EX8 — Export Transit Document", 2),
|
||||
// clearanceField("export_release", "Export Release", 3),
|
||||
// clearanceField("t1", "T1 — Transport Document", 4),
|
||||
// ];
|
||||
|
||||
const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
// ── Customer-input sets ──
|
||||
@@ -351,181 +352,194 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
fields: EXPORT_BULK_FIELDS,
|
||||
},
|
||||
// ── GL-output sets (customs only) ──
|
||||
{
|
||||
code: "clearance_output_import_container",
|
||||
label: "Customs output documents (import container)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_output_export_container",
|
||||
label: "Customs output documents (export container)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
// Commented out per request. NOTE: these codes are still resolved and
|
||||
// looked up at runtime by clearanceOutputSettingCode() in clearance.util.ts,
|
||||
// called from booking-transition.service.ts (finalizeClearance,
|
||||
// uploadClearanceOutputDocuments) and booking-clearance.service.ts —
|
||||
// reachable from bookings.controller.ts's finalizeClearance/
|
||||
// uploadClearanceOutputDocuments endpoints. finalizeClearance does not
|
||||
// catch getByCode()'s NotFoundException, so finalizing a customs-enabled
|
||||
// booking will 500 once these rows are gone from the DB too.
|
||||
// {
|
||||
// code: "clearance_output_import_container",
|
||||
// label: "Customs output documents (import container)",
|
||||
// entity: CLEARANCE_ENTITY,
|
||||
// fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "clearance_output_export_container",
|
||||
// label: "Customs output documents (export container)",
|
||||
// entity: CLEARANCE_ENTITY,
|
||||
// fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
// Bulk output sets mirror the container output docs so customs+bulk bookings
|
||||
// can finalize (previously bulk had no output set and got stuck at finalize).
|
||||
{
|
||||
code: "clearance_output_import_bulk",
|
||||
label: "Customs output documents (import bulk)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_output_export_bulk",
|
||||
label: "Customs output documents (export bulk)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
// {
|
||||
// code: "clearance_output_import_bulk",
|
||||
// label: "Customs output documents (import bulk)",
|
||||
// entity: CLEARANCE_ENTITY,
|
||||
// fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "clearance_output_export_bulk",
|
||||
// label: "Customs output documents (export bulk)",
|
||||
// entity: CLEARANCE_ENTITY,
|
||||
// fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
];
|
||||
|
||||
// ── Contract pre-booking clearance settings (Path B) ────────────────────────
|
||||
// For customs-clearance contracts the customer uploads clearance documents on
|
||||
// the CONTRACT (before any booking exists). Same customer doc sets as the legacy
|
||||
// booking clearance, plus the GL output sets, keyed on the contract. Resolved by
|
||||
// contract-clearance.util.ts (codes: contract_clearance_{op}_{freight} and
|
||||
// contract_clearance_output_{op}_container).
|
||||
const CONTRACT_CLEARANCE_ENTITY = "contract_clearance";
|
||||
// ── Contract pre-booking clearance settings ─────────────────────────────────
|
||||
// Removed — clearance is now collected once, per booking, instead of also on
|
||||
// the contract. See bookings/clearance.util.ts + CLEARANCE_DOCUMENT_SETTINGS.
|
||||
// const CONTRACT_CLEARANCE_ENTITY = "contract_clearance";
|
||||
|
||||
const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "contract_clearance_import_container",
|
||||
label: "Contract clearance documents (import container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_export_container",
|
||||
label: "Contract clearance documents (export container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_import_bulk",
|
||||
label: "Contract clearance documents (import bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: IMPORT_BULK_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_export_bulk",
|
||||
label: "Contract clearance documents (export bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: EXPORT_BULK_FIELDS,
|
||||
},
|
||||
// GL ET output sets uploaded during pre-booking clearance.
|
||||
{
|
||||
code: "contract_clearance_output_import_container",
|
||||
label: "Contract customs output documents (import container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_output_export_container",
|
||||
label: "Contract customs output documents (export container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
// Bulk output sets mirror the container output docs so customs+bulk contracts
|
||||
// can finalize (previously bulk had no output set and got stuck at finalize).
|
||||
{
|
||||
code: "contract_clearance_output_import_bulk",
|
||||
label: "Contract customs output documents (import bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_output_export_bulk",
|
||||
label: "Contract customs output documents (export bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
];
|
||||
// Contract-level IMPORT/EXPORT clearance input codes — removed. Clearance is
|
||||
// now collected once, per booking, via bookings/clearance.util.ts's 4+4
|
||||
// clearance_{op}_{freight}_{with|without}_customs codes. Kept here as
|
||||
// reference in case a contract-level step is reinstated.
|
||||
// const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
// {
|
||||
// code: "contract_clearance_import_container_with_customs",
|
||||
// label: "Contract clearance documents (import container, with customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: IMPORT_CONTAINER_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_export_container_with_customs",
|
||||
// label: "Contract clearance documents (export container, with customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: EXPORT_CONTAINER_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_import_bulk_with_customs",
|
||||
// label: "Contract clearance documents (import bulk, with customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: IMPORT_BULK_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_export_bulk_with_customs",
|
||||
// label: "Contract clearance documents (export bulk, with customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: EXPORT_BULK_FIELDS,
|
||||
// },
|
||||
// ];
|
||||
|
||||
// ── Path A self-clearance settings (no EDR customs service) ──────────────────
|
||||
// When the contract does NOT bundle customs clearance, the customer clears the
|
||||
// cargo himself and uploads his OWN clearance proof on the contract. Operations
|
||||
// (not GL) reviews this smaller set before the customer may create the booking.
|
||||
// Resolved by contract-clearance.util.ts as contract_clearance_selfclear_{op}_{freight}.
|
||||
const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [
|
||||
clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1),
|
||||
clearanceField("import_release", "Import Release Permit", 2),
|
||||
clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField("delivery_order", "Delivery Order", 4, { required: false }),
|
||||
clearanceField("supporting_document", "Other Clearance Document", 5, {
|
||||
required: false,
|
||||
}),
|
||||
];
|
||||
// GL ET output sets uploaded during pre-booking clearance — commented out
|
||||
// per request. NOTE: still resolved/looked up at runtime by
|
||||
// contractClearanceOutputSettingCode() in contract-clearance.util.ts,
|
||||
// called from contract-clearance.service.ts (finalize, uploadOutputDocuments)
|
||||
// — reachable from contracts.controller.ts. `finalize` does not catch
|
||||
// getByCode()'s NotFoundException, so finalizing a customs-enabled contract
|
||||
// will 500 once these rows are gone from the DB too.
|
||||
// const CONTRACT_CLEARANCE_OUTPUT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
// {
|
||||
// code: "contract_clearance_output_import_container",
|
||||
// label: "Contract customs output documents (import container)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_output_export_container",
|
||||
// label: "Contract customs output documents (export container)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_output_import_bulk",
|
||||
// label: "Contract customs output documents (import bulk)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_output_export_bulk",
|
||||
// label: "Contract customs output documents (export bulk)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
// },
|
||||
// ];
|
||||
|
||||
const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [
|
||||
clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1),
|
||||
clearanceField("export_release", "Export Release", 2),
|
||||
clearanceField("transit_document", "Transit Document (T1)", 3, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField("supporting_document", "Other Clearance Document", 4, {
|
||||
required: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "contract_clearance_selfclear_import_container",
|
||||
label: "Self-clearance documents (import container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: SELF_CLEARANCE_IMPORT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_selfclear_export_container",
|
||||
label: "Self-clearance documents (export container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: SELF_CLEARANCE_EXPORT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_selfclear_import_bulk",
|
||||
label: "Self-clearance documents (import bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: SELF_CLEARANCE_IMPORT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_selfclear_export_bulk",
|
||||
label: "Self-clearance documents (export bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: SELF_CLEARANCE_EXPORT_FIELDS,
|
||||
},
|
||||
];
|
||||
// ── Path A self-clearance settings — removed (contract-level clearance input
|
||||
// codes no longer exist; see CONTRACT_CLEARANCE_SETTINGS above). Kept as
|
||||
// reference in case a contract-level step is reinstated.
|
||||
// const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [
|
||||
// clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1),
|
||||
// clearanceField("import_release", "Import Release Permit", 2),
|
||||
// clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, {
|
||||
// required: false,
|
||||
// }),
|
||||
// clearanceField("delivery_order", "Delivery Order", 4, { required: false }),
|
||||
// clearanceField("supporting_document", "Other Clearance Document", 5, {
|
||||
// required: false,
|
||||
// }),
|
||||
// ];
|
||||
// const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [
|
||||
// clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1),
|
||||
// clearanceField("export_release", "Export Release", 2),
|
||||
// clearanceField("transit_document", "Transit Document (T1)", 3, {
|
||||
// required: false,
|
||||
// }),
|
||||
// clearanceField("supporting_document", "Other Clearance Document", 4, {
|
||||
// required: false,
|
||||
// }),
|
||||
// ];
|
||||
// const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
// {
|
||||
// code: "contract_clearance_import_container_without_customs",
|
||||
// label: "Contract clearance documents (import container, without customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: SELF_CLEARANCE_IMPORT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_export_container_without_customs",
|
||||
// label: "Contract clearance documents (export container, without customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: SELF_CLEARANCE_EXPORT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_import_bulk_without_customs",
|
||||
// label: "Contract clearance documents (import bulk, without customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: SELF_CLEARANCE_IMPORT_FIELDS,
|
||||
// },
|
||||
// {
|
||||
// code: "contract_clearance_export_bulk_without_customs",
|
||||
// label: "Contract clearance documents (export bulk, without customs)",
|
||||
// entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
// fields: SELF_CLEARANCE_EXPORT_FIELDS,
|
||||
// },
|
||||
// ];
|
||||
|
||||
// ── Contract intake settings ────────────────────────────────────────────────
|
||||
// Commercial/framework documents attached at contract submission (wizard step 5),
|
||||
// distinct from the post-sign clearance docs above.
|
||||
const CONTRACT_INTAKE_ENTITY = "contract_intake";
|
||||
|
||||
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
|
||||
clearanceField(
|
||||
"commercial_framework",
|
||||
"Commercial Framework / Agreement",
|
||||
1,
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
),
|
||||
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField("supporting_document", "Supporting Document", 3, {
|
||||
required: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "contract_intake_documents",
|
||||
label: "Contract intake documents",
|
||||
entity: CONTRACT_INTAKE_ENTITY,
|
||||
fields: CONTRACT_INTAKE_FIELDS,
|
||||
},
|
||||
];
|
||||
// Removed — unused. Nothing resolves/looks up `contract_intake_documents` by
|
||||
// code anywhere in the API or web apps.
|
||||
// const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
|
||||
// clearanceField(
|
||||
// "commercial_framework",
|
||||
// "Commercial Framework / Agreement",
|
||||
// 1,
|
||||
// {
|
||||
// required: false,
|
||||
// },
|
||||
// ),
|
||||
// clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
|
||||
// required: false,
|
||||
// }),
|
||||
// clearanceField("supporting_document", "Supporting Document", 3, {
|
||||
// required: false,
|
||||
// }),
|
||||
// ];
|
||||
// const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
// {
|
||||
// code: "contract_intake_documents",
|
||||
// label: "Contract intake documents",
|
||||
// entity: CONTRACT_INTAKE_ENTITY,
|
||||
// fields: CONTRACT_INTAKE_FIELDS,
|
||||
// },
|
||||
// ];
|
||||
|
||||
const CLEARANCE_DESCRIPTION =
|
||||
"Operation/clearance documents collected after contract execution, by operation, freight type and customs.";
|
||||
@@ -614,21 +628,6 @@ export class FileUploadSettingsSeeder {
|
||||
...s,
|
||||
description: CLEARANCE_DESCRIPTION,
|
||||
})),
|
||||
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
||||
})),
|
||||
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
||||
})),
|
||||
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Commercial/framework documents attached at contract submission.",
|
||||
})),
|
||||
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
|
||||
@@ -105,7 +105,10 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
bookingId: b.id,
|
||||
reference: b.reference ?? b.id,
|
||||
wagons: Number(b.wagonsRequired) || 0,
|
||||
grossTons: round1(Number(b.weightTons) || 0),
|
||||
// The booking's OWN weight (cargo VGM/bulk tons) — no wagon tare, so
|
||||
// each row shows exactly what the customer booked and the leg total
|
||||
// is the plain sum of its rows.
|
||||
grossTons: round1(Number(b.cargoWeightTons ?? b.weightTons) || 0),
|
||||
route: b.origin && b.destination ? `${b.origin} → ${b.destination}` : null,
|
||||
}))
|
||||
.sort((a, b) => b.grossTons - a.grossTons);
|
||||
@@ -164,9 +167,10 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>Per-leg utilization</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Each adjacent leg lists every booking riding it — a through
|
||||
booking counts on all its legs. Totals are checked against the
|
||||
train limits{weightCap != null ? ` (${weightCap}T incl. tolerance` : ""}
|
||||
Each adjacent leg lists every booking riding it with its own booked
|
||||
cargo weight — a through booking counts on all its legs, and the
|
||||
leg total is the plain sum of its rows. Checked against the train
|
||||
limits{weightCap != null ? ` (${weightCap}T pull` : ""}
|
||||
{weightCap != null && wagonCap != null ? `, ${wagonCap} wagons` : ""}
|
||||
{weightCap != null ? ")" : ""}.
|
||||
</Text>
|
||||
@@ -178,7 +182,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
<Table.Th style={{ width: 28 }} />
|
||||
<Table.Th>Leg</Table.Th>
|
||||
<Table.Th>Wagons</Table.Th>
|
||||
<Table.Th>Gross weight</Table.Th>
|
||||
<Table.Th>Cargo weight</Table.Th>
|
||||
<Table.Th>Bookings</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -40,15 +40,21 @@ import {
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Status filter options (values = `statuses` param). */
|
||||
/**
|
||||
* Status filter options (values = `statuses` param). FULLY_EXECUTED is the
|
||||
* post-approval status of intercity (domestic) bookings — kept in the list as
|
||||
* history, otherwise an approved intercity row vanishes from the hub.
|
||||
*/
|
||||
const BOOKING_STATUS_OPTIONS = [
|
||||
{
|
||||
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
|
||||
value:
|
||||
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED",
|
||||
label: "All statuses",
|
||||
},
|
||||
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
|
||||
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
|
||||
{ value: "CLEARANCE_READY", label: "Clearance ready" },
|
||||
{ value: "FULLY_EXECUTED", label: "Approved (history)" },
|
||||
];
|
||||
|
||||
const TRADE_DIRECTION_OPTIONS = [
|
||||
|
||||
@@ -7,31 +7,46 @@ import {
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
FileUp,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
Inbox,
|
||||
Layers,
|
||||
Paperclip,
|
||||
Search,
|
||||
Settings,
|
||||
TrainFront,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { api } from "@/services/api";
|
||||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
|
||||
type DocCategory = "import" | "export" | "intercity" | "other";
|
||||
|
||||
function categorize(setting: FileUploadSetting): DocCategory {
|
||||
const code = setting.code.toLowerCase();
|
||||
if (code.includes("intercity")) return "intercity";
|
||||
if (code.includes("export")) return "export";
|
||||
if (code.includes("import")) return "import";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
@@ -60,6 +75,17 @@ export default function FileUploadSettingsPage() {
|
||||
);
|
||||
}, [fileUploadSettings, query]);
|
||||
|
||||
const byCategory = useMemo(() => {
|
||||
const groups: Record<DocCategory, FileUploadSetting[]> = {
|
||||
import: [],
|
||||
export: [],
|
||||
intercity: [],
|
||||
other: [],
|
||||
};
|
||||
for (const s of filtered) groups[categorize(s)].push(s);
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
const totalFields = fileUploadSettings.reduce(
|
||||
(sum, s) => sum + s.fields.length,
|
||||
0,
|
||||
@@ -73,128 +99,7 @@ export default function FileUploadSettingsPage() {
|
||||
0,
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<FileUploadSetting>[]>(() => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{
|
||||
id: "setting",
|
||||
header: "Setting",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const s = row.original;
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
|
||||
<FileUp size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0, maxWidth: 260 }}>
|
||||
<Text size="sm" fw={600} lh={1.2} truncate>
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{s.description ?? "No description"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
header: "Code",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <Code>{row.original.code}</Code>,
|
||||
},
|
||||
{
|
||||
id: "entity",
|
||||
header: "Entity",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color="gray" tt="capitalize">
|
||||
{row.original.entity ?? "—"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "fields",
|
||||
header: "Fields",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Paperclip size={15} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.fields.length}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "requiredMulti",
|
||||
header: "Required / Multi",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const required = row.original.fields.filter((f) => f.isRequired).length;
|
||||
const multi = row.original.fields.filter((f) => f.isMultiple).length;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="light" color="edr-green">
|
||||
{required} required
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray">
|
||||
{multi} multi
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maxSize",
|
||||
header: "Max size",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const maxSize = Math.max(
|
||||
0,
|
||||
...row.original.fields.map((f) => f.maxSizeMb),
|
||||
);
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<HardDrive size={15} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm">{maxSize ? `${maxSize} MB` : "—"}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
meta: {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
},
|
||||
// Settings are seeded/fixed — staff may only update a setting's fields,
|
||||
// not create, edit, or delete the settings themselves.
|
||||
cell: ({ row }) => {
|
||||
const setting = row.original;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<ManageFileUploadFieldsDialog setting={setting}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
leftSection={<Paperclip size={14} />}
|
||||
>
|
||||
Fields
|
||||
</Button>
|
||||
</ManageFileUploadFieldsDialog>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
const gridStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -214,54 +119,98 @@ export default function FileUploadSettingsPage() {
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<TextInput
|
||||
placeholder="Search by code, label, or file key…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ maxWidth: 420 }}
|
||||
/>
|
||||
<Tabs defaultValue="import" variant="outline" radius="md">
|
||||
<Box px="md" pt="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="import" leftSection={<ArrowDownToLine size={16} />}>
|
||||
Import
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export" leftSection={<ArrowUpFromLine size={16} />}>
|
||||
Export
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="intercity" leftSection={<TrainFront size={16} />}>
|
||||
Intercity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="other" leftSection={<FolderOpen size={16} />}>
|
||||
Other
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
status={tableStatus}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load settings.",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error.",
|
||||
onRetry: () => void refetch(),
|
||||
{(["import", "export", "intercity", "other"] as const).map((tab) => (
|
||||
<Tabs.Panel key={tab} value={tab}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<TextInput
|
||||
placeholder="Search by code, label, or file key…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={
|
||||
query.trim()
|
||||
? "No file upload settings match your search."
|
||||
: "No file upload settings configured."
|
||||
}
|
||||
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
style={{ maxWidth: 420 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box px="md" pb="md" w="100%">
|
||||
{gridStatus === "error" ? (
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="xs" align="center" ta="center">
|
||||
<Text fw={600} c="red">
|
||||
Failed to load settings.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : gridStatus === "loading" ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} withBorder p="md" h={168} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : byCategory[tab].length === 0 ? (
|
||||
<Card withBorder p="xl">
|
||||
<Stack gap={4} align="center" ta="center" c="dimmed">
|
||||
<Inbox size={28} />
|
||||
<Text size="sm">
|
||||
{query.trim()
|
||||
? "No file upload settings match your search."
|
||||
: "No file upload settings in this category."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{byCategory[tab].map((setting) => (
|
||||
<SettingCard key={setting.id} setting={setting} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<BehaviorReferenceCard />
|
||||
@@ -269,6 +218,72 @@ export default function FileUploadSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingCard({ setting }: { setting: FileUploadSetting }) {
|
||||
const required = setting.fields.filter((f) => f.isRequired).length;
|
||||
const multi = setting.fields.filter((f) => f.isMultiple).length;
|
||||
const maxSize = Math.max(0, ...setting.fields.map((f) => f.maxSizeMb));
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" h="100%">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
|
||||
<FileUp size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} lh={1.25}>
|
||||
{setting.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{setting.description ?? "No description"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Badge variant="light" color="gray" tt="capitalize">
|
||||
{setting.entity ?? "—"}
|
||||
</Badge>
|
||||
<Badge variant="light" color="edr-green">
|
||||
{setting.fields.length} field{setting.fields.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
{required > 0 && (
|
||||
<Badge variant="light" color="orange">
|
||||
{required} required
|
||||
</Badge>
|
||||
)}
|
||||
{multi > 0 && (
|
||||
<Badge variant="light" color="blue">
|
||||
{multi} multi
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Code style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text truncate span size="xs">
|
||||
{setting.code}
|
||||
</Text>
|
||||
</Code>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} c="dimmed">
|
||||
<HardDrive size={14} />
|
||||
<Text size="xs">{maxSize ? `Max ${maxSize} MB per file` : "No size limit set"}</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="auto">
|
||||
<ManageFileUploadFieldsDialog setting={setting}>
|
||||
<Button variant="default" size="xs" leftSection={<Paperclip size={14} />}>
|
||||
Fields
|
||||
</Button>
|
||||
</ManageFileUploadFieldsDialog>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static reference: how `min_files` / `max_files` are derived from the
|
||||
* Required × Multiple toggles. Documentation aid for whoever wires uploaders.
|
||||
|
||||
@@ -676,6 +676,8 @@ export interface TrainScheduleDetail {
|
||||
reference: string | null;
|
||||
customer: string | null;
|
||||
weightTons: number;
|
||||
/** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
|
||||
cargoWeightTons?: number;
|
||||
status: string | null;
|
||||
schedulingStatus?: SchedulingStatus | null;
|
||||
freightType?: FreightType | string | null;
|
||||
|
||||
Reference in New Issue
Block a user