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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-20 13:52:55 +03:00
committed by GitHub
17 changed files with 104 additions and 50 deletions

View File

@@ -157,13 +157,13 @@ export class BookingLifecycleNotifierService {
}); });
} }
/** Clearance finalized → customer can proceed to request operation. */ /** Document approval finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void { clearanceReady(b: Booking): void {
const msg = const msg =
`Clearance for booking ${b.reference} is complete. ` + `Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`; `You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE READY'); void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
this.inApp(b, 'Clearance complete', msg, { this.inApp(b, 'Document approval finalized', msg, {
type: NotificationType.CLEARANCE_DECISION, type: NotificationType.CLEARANCE_DECISION,
}); });
} }

View File

@@ -456,7 +456,7 @@ export class ContractClearanceService {
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
if (!allowed.includes(contract.status)) { if (!allowed.includes(contract.status)) {
throw new ConflictException( throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`, `Cannot finalize document approval on status "${contract.status}".`,
); );
} }
} }

View File

@@ -374,23 +374,18 @@ export class ContractsService {
if (companyProfileId) { if (companyProfileId) {
// Business-license files are FileRecords (resource "company_profiles"); // Business-license files are FileRecords (resource "company_profiles");
// carry the live ones by reference. Staged/pending uploads are excluded by // carry the live ones by reference. Staged/pending uploads are excluded by
// code. Codes are slugged from each document name so they group under // code. The `business_license` prefix is preserved so the portal groups
// "Profile documents" on the contract detail page. // them under "Business license" instead of the clearance catch-all — the
// index suffix keeps multiple licences distinct.
const records = await this.filesService.findByResource( const records = await this.filesService.findByResource(
companyProfileId, companyProfileId,
'company_profiles', 'company_profiles',
); );
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
records records
.filter((r) => r.code === 'business_license') .filter((r) => r.code === 'business_license')
.forEach((r, i) => { .forEach((r, i) => {
const code = `${slug(r.name)}_${i + 1}`; const code = `business_license_${i + 1}`;
if (existingCodes.has(code)) return; if (existingCodes.has(code)) return;
docs.push({ docs.push({
code, code,

View File

@@ -3444,19 +3444,22 @@ export class BookingBatchService implements OnModuleInit {
/** /**
* Physical wagons marshalled in the schedule's built train, or null when the * Physical wagons marshalled in the schedule's built train, or null when the
* schedule has no built train (or the consist is still empty) and the legacy * schedule has NO built train and the legacy locomotive-derived capacity must
* locomotive-derived capacity must apply. This count is what caps a built * apply. This count is what caps a built train's bookings: 50 wagons coupled
* train's bookings: 50 wagons coupled → 50 wagon slots, no more. * → 50 wagon slots, no more.
*
* A built train with an EMPTY consist returns 0, NOT null: zero coupled
* wagons means zero capacity. Folding that case into null used to hand an
* un-consisted train the abstract locomotive budget, so an empty train
* advertised its full maxWagons as free space and accepted bookings the
* allocator could never place.
*/ */
private async builtTrainWagonCount( private async builtTrainWagonCount(
schedule: TrainSchedule, schedule: TrainSchedule,
): Promise<number | null> { ): Promise<number | null> {
const trainId = schedule.trainSet?.train?.id; const trainId = schedule.trainSet?.train?.id;
if (!trainId) return null; if (!trainId) return null;
const count = await this.dataSource return this.dataSource.getRepository(Wagon).count({ where: { trainId } });
.getRepository(Wagon)
.count({ where: { trainId } });
return count > 0 ? count : null;
} }
/** /**

View File

@@ -7131,9 +7131,19 @@ export class TrainSchedulingService {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSetWagon).delete(trainSetWagonId); await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
// Recount from the slot rows rather than decrementing the cached counter.
// A blind `wagonCount - 1` desyncs the moment two removals race or the
// in-memory schedule graph is stale, and the counter is what the schedule
// capacity math reads.
const remaining = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
select: { id: true, lengthMeters: true },
});
await manager.getRepository(TrainSet).update(schedule.trainSetId, { await manager.getRepository(TrainSet).update(schedule.trainSetId, {
wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1), wagonCount: remaining.length,
totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 0)), totalLengthMeters: roundTons(
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
),
}); });
}); });

View File

@@ -135,12 +135,14 @@ export function ClearanceReviewSection({
const finalizeMutation = useMutation({ const finalizeMutation = useMutation({
mutationFn: () => bookingsService.finalizeClearance(bookingId), mutationFn: () => bookingsService.finalizeClearance(bookingId),
onSuccess: () => { onSuccess: () => {
toast.success("Clearance finalized"); toast.success("Document approval finalized");
refresh(); refresh();
}, },
onError: (e) => onError: (e) =>
toast.error( toast.error(
e instanceof Error ? e.message : "Could not finalize clearance", e instanceof Error
? e.message
: "Could not finalize document approval",
), ),
}); });
@@ -366,7 +368,7 @@ export function ClearanceReviewSection({
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}> <Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeMutation.error instanceof Error {finalizeMutation.error instanceof Error
? finalizeMutation.error.message ? finalizeMutation.error.message
: "Could not finalize clearance."} : "Could not finalize document approval."}
</Alert> </Alert>
)} )}
@@ -445,7 +447,7 @@ export function ClearanceReviewSection({
loading={finalizeMutation.isPending} loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()} onClick={() => finalizeMutation.mutate()}
> >
Finalize clearance Finalize document approval
</Button> </Button>
</Group> </Group>
</Paper> </Paper>

View File

@@ -68,7 +68,7 @@ export interface ContractClearanceReviewSectionProps {
queriesLocked?: boolean; queriesLocked?: boolean;
/** /**
* ONE_TIME customs contracts use the phased milestone workflow. Hides the * ONE_TIME customs contracts use the phased milestone workflow. Hides the
* legacy "Finalize clearance" shortcut; booking readiness follows delivery * legacy "Finalize document approval" shortcut; booking readiness follows delivery
* order (import) or export release. * order (import) or export release.
*/ */
phasedCustoms?: boolean; phasedCustoms?: boolean;
@@ -393,7 +393,7 @@ export function ContractClearanceReviewSection({
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}> <Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeClearance.error instanceof Error {finalizeClearance.error instanceof Error
? finalizeClearance.error.message ? finalizeClearance.error.message
: "Could not finalize clearance."} : "Could not finalize document approval."}
</Alert> </Alert>
)} )}
@@ -482,7 +482,7 @@ export function ContractClearanceReviewSection({
}) })
} }
> >
Finalize clearance Finalize document approval
</Button> </Button>
</Group> </Group>
</Paper> </Paper>

View File

@@ -160,7 +160,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
onChange={(value) => setImportTrainNumber(value ?? "")} onChange={(value) => setImportTrainNumber(value ?? "")}
searchable searchable
clearable clearable
nothingFoundMessage="No free run numbers — add more in Dropdown Settings" nothingFoundMessage={importNumbers.emptyMessage}
error={importNumbers.settingMissing ? importNumbers.emptyMessage : undefined}
/> />
</Group> </Group>
<Select <Select

View File

@@ -105,7 +105,8 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
}} }}
searchable searchable
clearable clearable
nothingFoundMessage="No free run numbers — add more in Dropdown Settings" nothingFoundMessage={importNumbers.emptyMessage}
error={importNumbers.settingMissing ? importNumbers.emptyMessage : undefined}
radius="md" radius="md"
/> />
<TextInput <TextInput

View File

@@ -348,14 +348,16 @@ export function useContractClearanceMutations(
onSuccess: () => { onSuccess: () => {
toast.success( toast.success(
selfClear selfClear
? "Clearance approved — customer can now book" ? "Document approval finalized — customer can now book"
: "Clearance finalized — ready for booking", : "Document approval finalized — ready for booking",
); );
refresh(); refresh();
}, },
onError: (e) => onError: (e) =>
toast.error( toast.error(
e instanceof Error ? e.message : "Could not finalize clearance", e instanceof Error
? e.message
: "Could not finalize document approval",
), ),
}); });

View File

@@ -1,7 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { IMPORT_TRAIN_OPTIONS } from "@/constants/trainRuns";
import { api } from "@/services/api"; import { api } from "@/services/api";
/** Dropdown-settings code holding the admin-managed IMPORT run numbers. */ /** Dropdown-settings code holding the admin-managed IMPORT run numbers. */
@@ -14,10 +13,11 @@ export interface ImportTrainNumberOption {
} }
/** /**
* Selectable IMPORT run numbers for the Train Builder, sourced from the * Selectable IMPORT run numbers for the Train Builder, sourced solely from the
* admin-managed `import_train_numbers` dropdown setting (admins add new runs * admin-managed `import_train_numbers` dropdown setting admins add and remove
* from the Dropdown Settings editor). Falls back to the legacy hardcoded run * runs from /dashboard/dropdown-settings and the pickers follow. There is no
* list while the setting is missing or has no options. * hardcoded fallback on purpose: a missing setting must be visible (see
* `settingMissing`) rather than masked by stale defaults.
* *
* Numbers already claimed by an existing train are kept in the list but * Numbers already claimed by an existing train are kept in the list but
* disabled and tagged "in use". Pass `currentNumber` when editing a train so * disabled and tagged "in use". Pass `currentNumber` when editing a train so
@@ -37,14 +37,13 @@ export function useImportTrainNumberOptions(currentNumber?: string | null) {
); );
const options = useMemo<ImportTrainNumberOption[]>(() => { const options = useMemo<ImportTrainNumberOption[]>(() => {
const configured = [...(settingQuery.data?.children ?? [])] const base = [...(settingQuery.data?.children ?? [])]
.filter((option) => !option.disabled) .filter((option) => !option.disabled)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((option) => ({ .map((option) => ({
value: option.value, value: option.value,
label: option.label || option.value, label: option.label || option.value,
})); }));
const base = configured.length ? configured : IMPORT_TRAIN_OPTIONS;
const used = new Set(usedQuery.data?.importTrainNumbers ?? []); const used = new Set(usedQuery.data?.importTrainNumbers ?? []);
if (currentNumber) used.delete(currentNumber); if (currentNumber) used.delete(currentNumber);
@@ -60,8 +59,16 @@ export function useImportTrainNumberOptions(currentNumber?: string | null) {
return items; return items;
}, [settingQuery.data, usedQuery.data, currentNumber]); }, [settingQuery.data, usedQuery.data, currentNumber]);
const settingMissing = settingQuery.isError;
return { return {
options, options,
isLoading: settingQuery.isLoading || usedQuery.isLoading, isLoading: settingQuery.isLoading || usedQuery.isLoading,
/** True when the dropdown setting is absent — surfaced instead of silently
* falling back, so a broken config is visible rather than looking normal. */
settingMissing,
emptyMessage: settingMissing
? `Dropdown setting "${IMPORT_TRAIN_NUMBERS_CODE}" is missing — create it in Dropdown Settings`
: "No free run numbers — add more in Dropdown Settings",
}; };
} }

View File

@@ -325,7 +325,7 @@ export default function DocumentClearanceListPage({
subtitle={ subtitle={
opsMode opsMode
? "Review the customer's own clearance documents per shipment booking, raise queries, and finalize." ? "Review the customer's own clearance documents per shipment booking, raise queries, and finalize."
: "Review customer documents, raise queries, and finalize clearance for each booking." : "Review customer documents, raise queries, and finalize document approval for each booking."
} }
meta={statusBadge} meta={statusBadge}
action={ action={

View File

@@ -256,7 +256,7 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
if (row.ready) { if (row.ready) {
return ( return (
<Tooltip <Tooltip
label="Clearance finalized — the customer creates the booking in the portal" label="Document approval finalized — the customer creates the booking in the portal"
withArrow withArrow
> >
<Badge <Badge
@@ -266,7 +266,7 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
radius="sm" radius="sm"
leftSection={<PackageCheck size={12} />} leftSection={<PackageCheck size={12} />}
> >
Clearance finalized Documents approved
</Badge> </Badge>
</Tooltip> </Tooltip>
); );

View File

@@ -400,7 +400,9 @@ export default function TrainScheduleV2ListPage() {
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" /> <MetricChip value={row.original.bookingsCount} label="bkg" />
<MetricChip value={row.original.wagonCount} label="wgn" /> {/* Wagon SLOTS this schedule's bookings occupy — not the coupled
consist. A built train shows 0 here until bookings are allocated. */}
<MetricChip value={row.original.wagonCount} label="wgn used" />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle /> <MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group> </Group>
), ),
@@ -950,7 +952,7 @@ function ScheduleCard({
</Group> </Group>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" /> <MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" /> <MetricChip value={schedule.wagonCount} label="wgn used" />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle /> <MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group> </Group>
</Group> </Group>

View File

@@ -158,7 +158,14 @@ const BUSINESS_LICENSE_DOC_CODES = new Set([
"business_license", "business_license",
"commercial_license", "commercial_license",
"investment_license", "investment_license",
"trade_license",
]); ]);
// Licences carried from the company profile are suffixed per file
// (`business_license_1`), so match on the stripped base code too.
const isBusinessLicenseCode = (code: string): boolean =>
BUSINESS_LICENSE_DOC_CODES.has(code) ||
BUSINESS_LICENSE_DOC_CODES.has(code.replace(/_\d+$/, ""));
const PROFILE_DOC_CODES = new Set([ const PROFILE_DOC_CODES = new Set([
"tin_certificate", "tin_certificate",
"national_id", "national_id",
@@ -183,6 +190,15 @@ const KNOWN_FILE_LABELS: Record<string, string> = {
function fileLabel(f: AnyFile) { function fileLabel(f: AnyFile) {
if (KNOWN_FILE_LABELS[f.code]) return KNOWN_FILE_LABELS[f.code]; if (KNOWN_FILE_LABELS[f.code]) return KNOWN_FILE_LABELS[f.code];
// Profile documents carried onto the contract are suffixed per file
// (`business_license_2`) — label them from the base code, numbered.
const base = f.code.replace(/_\d+$/, "");
if (KNOWN_FILE_LABELS[base]) {
const n = f.code.slice(base.length + 1);
return n && n !== "1"
? `${KNOWN_FILE_LABELS[base]} ${n}`
: KNOWN_FILE_LABELS[base];
}
// Ad-hoc uploads carry a generated code (custom_<ts>_<i>) — use the filename. // Ad-hoc uploads carry a generated code (custom_<ts>_<i>) — use the filename.
if (f.code.startsWith("custom_")) return f.name; if (f.code.startsWith("custom_")) return f.name;
return f.code return f.code
@@ -245,7 +261,7 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
const contractFiles = (contract?.files ?? []) as AnyFile[]; const contractFiles = (contract?.files ?? []) as AnyFile[];
const contractPdf = contractFiles.find((f) => f.code === "contract"); const contractPdf = contractFiles.find((f) => f.code === "contract");
const licenseFiles = contractFiles.filter((f) => const licenseFiles = contractFiles.filter((f) =>
BUSINESS_LICENSE_DOC_CODES.has(f.code), isBusinessLicenseCode(f.code),
); );
const profileFiles = contractFiles.filter((f) => PROFILE_DOC_CODES.has(f.code)); const profileFiles = contractFiles.filter((f) => PROFILE_DOC_CODES.has(f.code));
@@ -428,7 +444,7 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
title={fileLabel(f)} title={fileLabel(f)}
meta={ meta={
PROFILE_DOC_CODES.has(f.code) || PROFILE_DOC_CODES.has(f.code) ||
BUSINESS_LICENSE_DOC_CODES.has(f.code) isBusinessLicenseCode(f.code)
? "From your company profile" ? "From your company profile"
: f.name : f.name
} }

View File

@@ -26,6 +26,14 @@ const LABEL_BY_CODE = new Map<string, string>([
export function labelForDocCode(code: string): string { export function labelForDocCode(code: string): string {
const known = LABEL_BY_CODE.get(code); const known = LABEL_BY_CODE.get(code);
if (known) return known; if (known) return known;
// Profile documents carried onto a contract are suffixed per file
// ("business_license_2") — label from the base code, numbered past the first.
const base = code.replace(/_\d+$/, "");
const baseLabel = LABEL_BY_CODE.get(base);
if (baseLabel) {
const n = code.slice(base.length + 1);
return n && n !== "1" ? `${baseLabel} ${n}` : baseLabel;
}
return code return code
.replace(/^custom_\d+_\d+$/, "Additional document") .replace(/^custom_\d+_\d+$/, "Additional document")
.replace(/[_-]+/g, " ") .replace(/[_-]+/g, " ")

View File

@@ -134,8 +134,15 @@ const BUSINESS_LICENSE_DOC_CODES = new Set([
"business_license", "business_license",
"commercial_license", "commercial_license",
"investment_license", "investment_license",
"trade_license",
]); ]);
// Licences carried from the company profile are suffixed per file
// (`business_license_1`), so match on the stripped base code too.
const isBusinessLicenseCode = (code: string): boolean =>
BUSINESS_LICENSE_DOC_CODES.has(code) ||
BUSINESS_LICENSE_DOC_CODES.has(code.replace(/_\d+$/, ""));
// Onboarding / company-profile document codes seeded in file-upload-settings. // Onboarding / company-profile document codes seeded in file-upload-settings.
// These get attached to the contract at creation and belong under "Profile // These get attached to the contract at creation and belong under "Profile
// documents" rather than the clearance set. // documents" rather than the clearance set.
@@ -196,7 +203,7 @@ function groupContractDocuments(
// The generated contract PDF lives in the contract list / home rows, not // The generated contract PDF lives in the contract list / home rows, not
// here. Signature images are baked into that PDF — skip both. // here. Signature images are baked into that PDF — skip both.
if (f.code === "contract" || f.code.startsWith("signature_")) continue; if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f); else if (isBusinessLicenseCode(f.code)) businessLicense.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f); else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else if (includeClearance && isClearanceCode(f.code)) clearance.push(f); else if (includeClearance && isClearanceCode(f.code)) clearance.push(f);
else other.push(f); else other.push(f);