mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add Excel import functionality for container bookings
This commit is contained in:
@@ -51,6 +51,11 @@ export class BookingRequestService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
||||
this.assertGeneralCustoms(contract);
|
||||
if (contract.status === 'CONTRACT_CLOSED') {
|
||||
throw new ConflictException(
|
||||
'This contract is completed — the full contracted quantity has been booked.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new ConflictException(
|
||||
'The contract must be active before requesting a shipment.',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* Contract auto-completion by quantity cap. Once a GENERAL contract's capped
|
||||
* scope is fully consumed (e.g. a split remainder rebooked), the contract moves
|
||||
* to CONTRACT_CLOSED even inside its validity window, and further bookings are
|
||||
* blocked — including while a booking window is open. Released capacity
|
||||
* (cancelled/expired booking) reopens the contract on the next attempt.
|
||||
*/
|
||||
describe('ContractBookingService — quantity-cap completion', () => {
|
||||
function makeService() {
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new ContractBookingService(
|
||||
contractsRepository as never,
|
||||
{} as never, // bookingsRepository
|
||||
{} as never, // bookingPricingService
|
||||
{} as never, // consolidationService
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // milestoneService
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
type WithPrivate = {
|
||||
maybeCompleteContract: (c: Contract) => Promise<void>;
|
||||
};
|
||||
|
||||
const generalContract = (status: string): Contract =>
|
||||
({
|
||||
id: 'c-1',
|
||||
reference: 'CTR-1',
|
||||
contractKind: 'GENERAL',
|
||||
status,
|
||||
}) as Contract;
|
||||
|
||||
it('closes a GENERAL contract when every capped line is exhausted', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
|
||||
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
|
||||
{ containerSize: '40FT', cap: 4, booked: 4, remaining: 0 },
|
||||
]);
|
||||
|
||||
await (service as never as WithPrivate).maybeCompleteContract(
|
||||
generalContract('CONTRACT_ACTIVE'),
|
||||
);
|
||||
|
||||
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
||||
status: 'CONTRACT_CLOSED',
|
||||
});
|
||||
});
|
||||
|
||||
it('absorbs bulk-ton float dust when judging exhaustion', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
jest
|
||||
.spyOn(service, 'computeCapacity')
|
||||
.mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]);
|
||||
|
||||
await (service as never as WithPrivate).maybeCompleteContract(
|
||||
generalContract('FULLY_EXECUTED'),
|
||||
);
|
||||
|
||||
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
||||
status: 'CONTRACT_CLOSED',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the contract open while any capped line has capacity left', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
|
||||
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
|
||||
{ containerSize: '40FT', cap: 4, booked: 3, remaining: 1 },
|
||||
]);
|
||||
|
||||
await (service as never as WithPrivate).maybeCompleteContract(
|
||||
generalContract('CONTRACT_ACTIVE'),
|
||||
);
|
||||
|
||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never closes an uncapped contract', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
jest.spyOn(service, 'computeCapacity').mockResolvedValue([]);
|
||||
|
||||
await (service as never as WithPrivate).maybeCompleteContract(
|
||||
generalContract('CONTRACT_ACTIVE'),
|
||||
);
|
||||
|
||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
const spy = jest.spyOn(service, 'computeCapacity');
|
||||
|
||||
await (service as never as WithPrivate).maybeCompleteContract({
|
||||
id: 'c-1',
|
||||
contractKind: 'ONE_TIME',
|
||||
status: 'FULLY_EXECUTED',
|
||||
} as Contract);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a new booking on a completed contract even inside an open window', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
contractsRepository.findByIdWithRelations.mockResolvedValue(
|
||||
generalContract('CONTRACT_CLOSED'),
|
||||
);
|
||||
jest
|
||||
.spyOn(service, 'computeCapacity')
|
||||
.mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]);
|
||||
|
||||
await expect(
|
||||
service.createUnderContract('c-1', {} as never, null, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reopens a completed contract when capacity was released', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
contractsRepository.findByIdWithRelations.mockResolvedValue(
|
||||
generalContract('CONTRACT_CLOSED'),
|
||||
);
|
||||
jest
|
||||
.spyOn(service, 'computeCapacity')
|
||||
.mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]);
|
||||
|
||||
// The create path continues past the gate and dies later on the bare mocks —
|
||||
// only the reopen transition is under test here.
|
||||
await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined);
|
||||
|
||||
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,24 @@ export class ContractBookingService {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
// A contract whose quantity cap was fully booked is completed — no further
|
||||
// bookings, even while contract validity and a booking window are still
|
||||
// open. Capacity released after closure (a cancelled/expired booking)
|
||||
// reopens the contract on the next booking attempt.
|
||||
if (contract.status === 'CONTRACT_CLOSED') {
|
||||
const capacity = await this.computeCapacity(contract);
|
||||
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
|
||||
if (!hasRoom) {
|
||||
throw new BadRequestException(
|
||||
'This contract is completed — the full contracted quantity has been booked.',
|
||||
);
|
||||
}
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
} as never);
|
||||
contract.status = 'CONTRACT_ACTIVE';
|
||||
}
|
||||
|
||||
// GL Ethiopia is identified by the dedicated contract create-booking permission
|
||||
// (granted to the edr_gl_ethiopia preset).
|
||||
const isGlActor =
|
||||
@@ -291,6 +309,9 @@ export class ContractBookingService {
|
||||
if (!parked.paired) {
|
||||
// Waiting for a partner — stop here. The booking sits in
|
||||
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
|
||||
// A parked booking still holds contract capacity, so the cap may
|
||||
// already be exhausted by it.
|
||||
await this.maybeCompleteContract(contract);
|
||||
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
@@ -304,6 +325,8 @@ export class ContractBookingService {
|
||||
generalCustoms,
|
||||
);
|
||||
|
||||
await this.maybeCompleteContract(contract);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
@@ -606,6 +629,44 @@ export class ContractBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the contract once its quantity cap is fully consumed. Runs after
|
||||
* every booking created under a GENERAL contract (including a split remainder
|
||||
* being rebooked): when no capped scope line has capacity left, the contract
|
||||
* moves to CONTRACT_CLOSED even though its validity window is still open —
|
||||
* blocking further bookings and shipment requests, including inside an open
|
||||
* booking window. Never throws: a status hiccup must not undo the booking
|
||||
* that was just created.
|
||||
*/
|
||||
private async maybeCompleteContract(contract: Contract): Promise<void> {
|
||||
try {
|
||||
// ONE_TIME contracts are governed by the single-active-booking slot (and
|
||||
// are promoted to GENERAL on split), so only GENERAL completes by cap.
|
||||
if (contract.contractKind !== 'GENERAL') return;
|
||||
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
|
||||
const capacity = await this.computeCapacity(contract);
|
||||
if (capacity.length === 0) return; // uncapped — completes only by expiry
|
||||
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
|
||||
// 3 decimals); container caps are integers and unaffected.
|
||||
const exhausted = capacity.every(
|
||||
(c) => c.remaining != null && c.remaining <= 0.001,
|
||||
);
|
||||
if (!exhausted) return;
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'CONTRACT_CLOSED',
|
||||
} as never);
|
||||
this.logger.log(
|
||||
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Could not evaluate completion for contract ${contract.id}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantities already booked under a contract that still hold capacity. Excludes
|
||||
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
|
||||
|
||||
@@ -265,10 +265,11 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the company profile's onboarding / business-license documents to the
|
||||
// contract by reference. The separate "Documents" intake step was removed —
|
||||
// the profile documents are simply carried onto every contract automatically.
|
||||
await this.attachProfileDocuments(contract.id, companyProfileId);
|
||||
// Attach the company's onboarding documents (TIN, licenses, IDs) and the
|
||||
// profile's business-license documents to the contract by reference. The
|
||||
// separate "Documents" intake step was removed — the profile documents are
|
||||
// simply carried onto every contract automatically.
|
||||
await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId);
|
||||
|
||||
return { contract: await this.findById(contract.id), warnings };
|
||||
}
|
||||
@@ -316,51 +317,95 @@ export class ContractsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a company profile's stored business-license / onboarding documents onto
|
||||
* a contract by reference (no byte re-upload). Codes are slugged from each
|
||||
* document name so they group under "Profile documents" on the contract detail
|
||||
* page. No-op when the contract has no profile or the profile has no documents.
|
||||
* Copy the company's onboarding documents (TIN certificate, commercial /
|
||||
* investment license, national ID, passport — resource "companies", coded by
|
||||
* the upload-setting fileKey) and the company profile's business-license
|
||||
* documents (resource "company_profiles") onto a contract by reference (no
|
||||
* byte re-upload). Idempotent: codes already present on the contract — user
|
||||
* uploads or an earlier carry — are never duplicated or overwritten, so it is
|
||||
* safe to run on every create and update. No-op when there is nothing to copy.
|
||||
*/
|
||||
private async attachProfileDocuments(
|
||||
contractId: string,
|
||||
companyId: string | null,
|
||||
companyProfileId: string | null,
|
||||
): Promise<void> {
|
||||
if (!companyProfileId) return;
|
||||
// Business-license files are FileRecords (resource "company_profiles"); carry
|
||||
// the live ones by reference. Staged/pending uploads are excluded by code.
|
||||
const records = await this.filesService.findByResource(
|
||||
companyProfileId,
|
||||
'company_profiles',
|
||||
if (!companyId && !companyProfileId) return;
|
||||
|
||||
const existingCodes = new Set(
|
||||
(await this.filesService.findByResource(contractId, 'contracts')).map(
|
||||
(r) => r.code,
|
||||
),
|
||||
);
|
||||
const docs = records
|
||||
.filter((r) => r.code === 'business_license')
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
}));
|
||||
const docs: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
}> = [];
|
||||
|
||||
if (companyId) {
|
||||
// Company onboarding documents keep their fileKey codes (tin_certificate,
|
||||
// commercial_license, …) so the portal can match them against the
|
||||
// onboarding upload-setting fields. Re-uploads append rows, so keep only
|
||||
// the newest record per code.
|
||||
const companyRecords = await this.filesService.findByResource(
|
||||
companyId,
|
||||
'companies',
|
||||
);
|
||||
const latestByCode = new Map<string, (typeof companyRecords)[number]>();
|
||||
for (const r of companyRecords) {
|
||||
const prev = latestByCode.get(r.code);
|
||||
if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r);
|
||||
}
|
||||
for (const r of latestByCode.values()) {
|
||||
if (existingCodes.has(r.code)) continue;
|
||||
docs.push({
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (companyProfileId) {
|
||||
// Business-license files are FileRecords (resource "company_profiles");
|
||||
// carry the live ones by reference. Staged/pending uploads are excluded by
|
||||
// code. Codes are slugged from each document name so they group under
|
||||
// "Profile documents" on the contract detail page.
|
||||
const records = await this.filesService.findByResource(
|
||||
companyProfileId,
|
||||
'company_profiles',
|
||||
);
|
||||
const slug = (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/\.[a-z0-9]+$/, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'profile_document';
|
||||
|
||||
records
|
||||
.filter((r) => r.code === 'business_license')
|
||||
.forEach((r, i) => {
|
||||
const code = `${slug(r.name)}_${i + 1}`;
|
||||
if (existingCodes.has(code)) return;
|
||||
docs.push({
|
||||
code,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (docs.length === 0) return;
|
||||
|
||||
const slug = (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/\.[a-z0-9]+$/, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'profile_document';
|
||||
|
||||
try {
|
||||
await this.filesService.attachExistingFiles(
|
||||
contractId,
|
||||
'contracts',
|
||||
docs.map((d, i) => ({
|
||||
code: `${slug(d.name)}_${i + 1}`,
|
||||
name: d.name,
|
||||
url: d.url,
|
||||
size: d.size,
|
||||
mimeType: d.mimeType,
|
||||
})),
|
||||
);
|
||||
await this.filesService.attachExistingFiles(contractId, 'contracts', docs);
|
||||
} catch {
|
||||
// Non-fatal — the contract is still valid without the carried documents.
|
||||
}
|
||||
@@ -481,6 +526,15 @@ export class ContractsService {
|
||||
await this.filesService.uploadMany(id, 'contracts', files);
|
||||
}
|
||||
|
||||
// Re-carry any company/profile document that is still missing from the
|
||||
// contract (runs after the upload so fresh replacements keep their slot).
|
||||
// Backfills contracts created before profile documents were carried over.
|
||||
await this.attachProfileDocuments(
|
||||
id,
|
||||
existing.companyId ?? null,
|
||||
existing.companyProfileId ?? null,
|
||||
);
|
||||
|
||||
return { contract: await this.findById(id), warnings };
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface SizedOffer {
|
||||
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||
* any later window within contract validity. A ONE_TIME contract is promoted to
|
||||
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
|
||||
* Once the remainder is rebooked and the cap hits zero, ContractBookingService
|
||||
* completes the contract (CONTRACT_CLOSED): no further bookings or shipment
|
||||
* requests, even while validity and a booking window are still open.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingSplitService {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tinymce": "^8.6.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
@@ -31,7 +32,9 @@ import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
FileDown,
|
||||
FileText,
|
||||
FileUp,
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
@@ -54,6 +57,10 @@ import {
|
||||
type GlShipmentQuantities,
|
||||
} from "./gl-booking-form/total";
|
||||
import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice";
|
||||
import {
|
||||
downloadContainerImportTemplate,
|
||||
parseContainerExcel,
|
||||
} from "./gl-booking-form/container-excel";
|
||||
import {
|
||||
fieldStyles,
|
||||
StepCard,
|
||||
@@ -392,6 +399,57 @@ export default function GlCreateBookingForm() {
|
||||
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
// Excel import: one row per container. All-or-nothing — a file with any bad
|
||||
// row is rejected with row-numbered errors so nothing is silently dropped.
|
||||
const [importErrors, setImportErrors] = useState<string[]>([]);
|
||||
const [importSummary, setImportSummary] = useState<string | null>(null);
|
||||
const importResetRef = useRef<(() => void) | null>(null);
|
||||
const excelOpts = {
|
||||
allowedSizes: containerSizes,
|
||||
includeHazardous: contract?.isHazardous ?? false,
|
||||
includeReefer: contract?.isReefer ?? false,
|
||||
};
|
||||
|
||||
const handleImportFile = async (file: File | null) => {
|
||||
// Reset the hidden input so re-picking the same (fixed) file re-fires.
|
||||
importResetRef.current?.();
|
||||
if (!file) return;
|
||||
const { rows, errors } = await parseContainerExcel(file, excelOpts);
|
||||
if (errors.length > 0) {
|
||||
setImportSummary(null);
|
||||
setImportErrors(errors);
|
||||
return;
|
||||
}
|
||||
// Replace only the lines for sizes present in the file; a contracted size
|
||||
// the file omits keeps whatever was already entered for it.
|
||||
setContainerLines((prev) =>
|
||||
containerSizes.map((size) => {
|
||||
const imported = rows.filter((r) => r.containerSize === size);
|
||||
if (imported.length === 0) {
|
||||
return (
|
||||
prev.find((l) => l.containerSize === size) ?? {
|
||||
containerSize: size,
|
||||
units: [emptyUnit()],
|
||||
}
|
||||
);
|
||||
}
|
||||
return {
|
||||
containerSize: size,
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
vgmTons: r.vgmTons,
|
||||
hazardous: r.hazardous,
|
||||
reefer: r.reefer,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setImportErrors([]);
|
||||
setShowErrors(false);
|
||||
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
|
||||
};
|
||||
|
||||
const unitErrors = useMemo<UnitErrors[][]>(() => {
|
||||
if (!isContainer) return [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
@@ -740,6 +798,83 @@ export default function GlCreateBookingForm() {
|
||||
description="Enter the quantity and per-container details for each size in the contract scope."
|
||||
/>
|
||||
<Stack gap={18}>
|
||||
{containerSizes.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Box>
|
||||
<Text fz={13} fw={600}>
|
||||
Import containers from Excel
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
One row per container. Importing fills the lines below
|
||||
for the sizes in the file.
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<FileDown size={14} />}
|
||||
onClick={() => downloadContainerImportTemplate(excelOpts)}
|
||||
>
|
||||
Download template
|
||||
</Button>
|
||||
<FileButton
|
||||
resetRef={importResetRef}
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleImportFile}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={<FileUp size={14} />}
|
||||
>
|
||||
Import Excel
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Group>
|
||||
{importErrors.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Import failed — fix the file and try again"
|
||||
mt="sm"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{importErrors.slice(0, 8).map((msg, i) => (
|
||||
<Text key={i} fz="xs">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
{importErrors.length > 8 && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
…and {importErrors.length - 8} more.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{importSummary && (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
mt="sm"
|
||||
>
|
||||
<Text fz="xs">{importSummary}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
{containerLines.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for container shipments: one spreadsheet row per physical
|
||||
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
||||
// hazardous/reefer flags when the contract allows them. The parser is
|
||||
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
||||
// partial import can never silently drop containers.
|
||||
|
||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ContainerExcelOptions {
|
||||
/** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */
|
||||
allowedSizes: string[];
|
||||
includeHazardous: boolean;
|
||||
includeReefer: boolean;
|
||||
}
|
||||
|
||||
export interface ImportedContainerRow {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: string;
|
||||
hazardous: boolean;
|
||||
reefer: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerExcelResult {
|
||||
rows: ImportedContainerRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerSize"
|
||||
| "containerNumber"
|
||||
| "sealNumber"
|
||||
| "vgmTons"
|
||||
| "hazardous"
|
||||
| "reefer";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("size")) return "containerSize";
|
||||
if (h.includes("seal")) return "sealNumber";
|
||||
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
||||
if (h.includes("hazard")) return "hazardous";
|
||||
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
||||
// After the more specific matches: "Container Number", "Container No", …
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */
|
||||
function normalizeSize(raw: string, allowed: string[]): string | null {
|
||||
const digits = raw.replace(/[^0-9]/g, "");
|
||||
if (!digits) return null;
|
||||
return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null;
|
||||
}
|
||||
|
||||
function parseFlag(raw: string): boolean {
|
||||
const v = raw.trim().toLowerCase();
|
||||
return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook into one row per container. Returns either the
|
||||
* full row set or the list of row-numbered problems (never both).
|
||||
*/
|
||||
export async function parseContainerExcel(
|
||||
file: File,
|
||||
opts: ContainerExcelOptions,
|
||||
): Promise<ContainerExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) {
|
||||
return { rows: [], errors: ["The file has no sheets."] };
|
||||
}
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
raw: false,
|
||||
defval: "",
|
||||
});
|
||||
|
||||
// First row with a recognizable column is the header; everything above
|
||||
// (titles, blank rows) is ignored.
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber") && mapped.includes("containerSize")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!columns.includes("vgmTons")) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ImportedContainerRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const size = normalizeSize(cell("containerSize"), opts.allowedSizes);
|
||||
if (!size) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase();
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const vgmRaw = cell("vgmTons");
|
||||
const vgm = Number(vgmRaw);
|
||||
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
||||
errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerSize: size ?? "",
|
||||
containerNumber,
|
||||
sealNumber: cell("sealNumber"),
|
||||
vgmTons: vgmRaw,
|
||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Generate and download the simple import template with one sample row per size. */
|
||||
export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
||||
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
||||
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
||||
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
||||
|
||||
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
||||
const sampleRows = sizes.map((size, i) => {
|
||||
const row: Array<string | number> = [
|
||||
size,
|
||||
`MSCU${String(1234567 + i).padStart(7, "0")}`,
|
||||
`SL${String(482910 + i)}`,
|
||||
size.startsWith("40") ? 28 : 24.5,
|
||||
];
|
||||
if (opts.includeHazardous) row.push("NO");
|
||||
if (opts.includeReefer) row.push("NO");
|
||||
return row;
|
||||
});
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Containers");
|
||||
XLSX.writeFile(workbook, "container-import-template.xlsx");
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
ContractDocsEditor,
|
||||
documentSettingCode,
|
||||
missingRequiredDocKeys,
|
||||
useCompanyDocuments,
|
||||
} from "./new-contract-form/ContractDocsEditor";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||
@@ -116,6 +117,9 @@ export default function NewContractPage({
|
||||
}),
|
||||
enabled: isEdit,
|
||||
});
|
||||
// Profile documents (TIN, licenses, IDs) satisfy requirements too — the API
|
||||
// carries them onto the contract on save.
|
||||
const companyDocs = useCompanyDocuments();
|
||||
|
||||
// Contract creation is gated on profile approval, same as bookings.
|
||||
if (!auth.isPending && auth.company && !auth.canBook) {
|
||||
@@ -526,6 +530,7 @@ export default function NewContractPage({
|
||||
editDocSettingQuery.data,
|
||||
editContract,
|
||||
editDocuments,
|
||||
companyDocs,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setShowDocErrors(true);
|
||||
@@ -643,6 +648,7 @@ export default function NewContractPage({
|
||||
editDocSettingQuery.data,
|
||||
editContract,
|
||||
editDocuments,
|
||||
companyDocs,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setShowDocErrors(true);
|
||||
@@ -846,6 +852,7 @@ export default function NewContractPage({
|
||||
editDocSettingQuery.data,
|
||||
editContract,
|
||||
editDocuments,
|
||||
companyDocs,
|
||||
).map((k) => [k, "Required"]),
|
||||
)
|
||||
: {}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
FileDown,
|
||||
FileUp,
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
@@ -51,6 +54,10 @@ import {
|
||||
initialShipmentFormValues,
|
||||
} from "./new-shipment-form/schema";
|
||||
import { computeShipmentTotal } from "./new-shipment-form/total";
|
||||
import {
|
||||
downloadContainerImportTemplate,
|
||||
parseContainerExcel,
|
||||
} from "./new-shipment-form/container-excel";
|
||||
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
|
||||
@@ -931,6 +938,60 @@ function CargoStep({
|
||||
|
||||
const lines = form.watch("containers") ?? [];
|
||||
|
||||
// Excel import: one row per container. All-or-nothing — a file with any bad
|
||||
// row is rejected with row-numbered errors so nothing is silently dropped.
|
||||
const [importErrors, setImportErrors] = useState<string[]>([]);
|
||||
const [importSummary, setImportSummary] = useState<string | null>(null);
|
||||
const importResetRef = useRef<(() => void) | null>(null);
|
||||
const excelOpts = {
|
||||
allowedSizes: sizes,
|
||||
includeHazardous: contract.isHazardous ?? false,
|
||||
includeReefer: contract.isReefer ?? false,
|
||||
};
|
||||
|
||||
const handleImportFile = async (file: File | null) => {
|
||||
// Reset the hidden input so re-picking the same (fixed) file re-fires.
|
||||
importResetRef.current?.();
|
||||
if (!file) return;
|
||||
const { rows, errors } = await parseContainerExcel(file, excelOpts);
|
||||
if (errors.length > 0) {
|
||||
setImportSummary(null);
|
||||
setImportErrors(errors);
|
||||
return;
|
||||
}
|
||||
// Replace only the lines for sizes present in the file; a contracted size
|
||||
// the file omits keeps whatever was already entered for it.
|
||||
const current = form.getValues("containers") ?? [];
|
||||
const next = sizes.map((size) => {
|
||||
const imported = rows.filter((r) => r.containerSize === size);
|
||||
if (imported.length === 0) {
|
||||
return (
|
||||
current.find((l) => l.containerSize === size) ?? {
|
||||
containerSize: size,
|
||||
quantity: "1",
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||
}
|
||||
);
|
||||
}
|
||||
return {
|
||||
containerSize: size,
|
||||
quantity: String(imported.length),
|
||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
vgmTons: r.vgmTons,
|
||||
})),
|
||||
};
|
||||
});
|
||||
form.setValue("containers", next, { shouldValidate: true, shouldDirty: true });
|
||||
setImportErrors([]);
|
||||
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
return (
|
||||
<StepCard>
|
||||
@@ -940,6 +1001,83 @@ function CargoStep({
|
||||
description="Enter the quantity and per-container details for each size in your contract scope."
|
||||
/>
|
||||
<Stack gap={18}>
|
||||
{sizes.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Box>
|
||||
<Text fz={13} fw={600} c="#10202F">
|
||||
Import containers from Excel
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
One row per container. Importing fills the lines below for
|
||||
the sizes in your file.
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<FileDown size={14} />}
|
||||
onClick={() => downloadContainerImportTemplate(excelOpts)}
|
||||
>
|
||||
Download template
|
||||
</Button>
|
||||
<FileButton
|
||||
resetRef={importResetRef}
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleImportFile}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={<FileUp size={14} />}
|
||||
>
|
||||
Import Excel
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Group>
|
||||
{importErrors.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Import failed — fix the file and try again"
|
||||
mt="sm"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{importErrors.slice(0, 8).map((msg, i) => (
|
||||
<Text key={i} fz="xs">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
{importErrors.length > 8 && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
…and {importErrors.length - 8} more.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{importSummary && (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
mt="sm"
|
||||
>
|
||||
<Text fz="xs">{importSummary}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
{lines.map((line, index) => (
|
||||
<ContainerLineEditor
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import type { CompanyDocument } from "@/services/companies.service";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
|
||||
import { BORDER, GREEN, INK } from "../contract-ui";
|
||||
@@ -38,6 +39,38 @@ function dedupeLatestByCode(files: ContractFile[]): ContractFile[] {
|
||||
return order.map((c) => latest.get(c)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's onboarding documents (TIN certificate, commercial license,
|
||||
* national ID, …) from the profile. Contracts carry these automatically on
|
||||
* save; the edit page also shows them directly so they are always visible even
|
||||
* on contracts created before the carry-over existed.
|
||||
*/
|
||||
export function useCompanyDocuments(): CompanyDocument[] {
|
||||
const auth = useAuth();
|
||||
const companyId = auth.company?.company?.id as string | undefined;
|
||||
const query = useQuery({
|
||||
...api.companies.documents.queryOptions({
|
||||
input: { companyId: companyId ?? "" },
|
||||
}),
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
/** Latest company document per code, excluding codes already on the contract. */
|
||||
function profileFallbackDocs(
|
||||
companyDocs: CompanyDocument[],
|
||||
contractFiles: ContractFile[],
|
||||
): CompanyDocument[] {
|
||||
const onContract = new Set(contractFiles.map((f) => f.code));
|
||||
const latest = new Map<string, CompanyDocument>();
|
||||
for (const d of companyDocs) {
|
||||
const prev = latest.get(d.code);
|
||||
if (!prev || d.uploadedAt > prev.uploadedAt) latest.set(d.code, d);
|
||||
}
|
||||
return [...latest.values()].filter((d) => !onContract.has(d.code));
|
||||
}
|
||||
|
||||
/**
|
||||
* Document replace/upload block for an existing contract. Lists the documents
|
||||
* already on file (latest upload per code) and renders the onboarding-driven
|
||||
@@ -72,8 +105,25 @@ export function ContractDocsEditor({
|
||||
}),
|
||||
);
|
||||
|
||||
const companyDocs = useCompanyDocuments();
|
||||
const files = contract.files ?? [];
|
||||
const onFile = useMemo(() => dedupeLatestByCode(files), [files]);
|
||||
const onFile = useMemo(() => {
|
||||
const contractRows = dedupeLatestByCode(files).map((f) => ({
|
||||
id: f.id,
|
||||
code: f.code,
|
||||
name: f.name,
|
||||
fromProfile: false,
|
||||
}));
|
||||
// Profile documents not yet carried onto the contract still show — they are
|
||||
// attached automatically on the next save.
|
||||
const profileRows = profileFallbackDocs(companyDocs, files).map((d) => ({
|
||||
id: d.id,
|
||||
code: d.code,
|
||||
name: d.name,
|
||||
fromProfile: true,
|
||||
}));
|
||||
return [...contractRows, ...profileRows];
|
||||
}, [files, companyDocs]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -115,12 +165,21 @@ export function ContractDocsEditor({
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Group gap={5} c={GREEN}>
|
||||
<CheckCircle2 size={14} />
|
||||
<Text fz="11.5px" fw={600} c={GREEN}>
|
||||
On file
|
||||
</Text>
|
||||
</Group>
|
||||
{file.fromProfile ? (
|
||||
<Group gap={5} style={{ color: "#2E5B96" }}>
|
||||
<CheckCircle2 size={14} />
|
||||
<Text fz="11.5px" fw={600} style={{ color: "#2E5B96" }}>
|
||||
From profile
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={5} c={GREEN}>
|
||||
<CheckCircle2 size={14} />
|
||||
<Text fz="11.5px" fw={600} c={GREEN}>
|
||||
On file
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Button
|
||||
component="a"
|
||||
href={fileViewUrl(file.id, true)}
|
||||
@@ -170,16 +229,21 @@ export function ContractDocsEditor({
|
||||
|
||||
/**
|
||||
* Keys of the documents the onboarding setting marks required that are neither
|
||||
* already on the contract nor freshly attached in `documents`. Empty means the
|
||||
* customer may resubmit.
|
||||
* already on the contract, nor on the company profile (the API carries those
|
||||
* onto the contract on save), nor freshly attached in `documents`. Empty means
|
||||
* the customer may resubmit.
|
||||
*/
|
||||
export function missingRequiredDocKeys(
|
||||
setting: Freight.IFileUploadSetting | undefined,
|
||||
contract: Freight.IContract,
|
||||
documents: DocumentsValue,
|
||||
companyDocs: CompanyDocument[] = [],
|
||||
): string[] {
|
||||
const fields = setting?.fields ?? [];
|
||||
const existingCodes = new Set((contract.files ?? []).map((f) => f.code));
|
||||
const existingCodes = new Set([
|
||||
...(contract.files ?? []).map((f) => f.code),
|
||||
...companyDocs.map((d) => d.code),
|
||||
]);
|
||||
return fields
|
||||
.filter((f) => f.isRequired)
|
||||
.filter(
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for container shipments: one spreadsheet row per physical
|
||||
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
||||
// hazardous/reefer flags when the contract allows them. The parser is
|
||||
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
||||
// partial import can never silently drop containers.
|
||||
|
||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ContainerExcelOptions {
|
||||
/** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */
|
||||
allowedSizes: string[];
|
||||
includeHazardous: boolean;
|
||||
includeReefer: boolean;
|
||||
}
|
||||
|
||||
export interface ImportedContainerRow {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: string;
|
||||
hazardous: boolean;
|
||||
reefer: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerExcelResult {
|
||||
rows: ImportedContainerRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerSize"
|
||||
| "containerNumber"
|
||||
| "sealNumber"
|
||||
| "vgmTons"
|
||||
| "hazardous"
|
||||
| "reefer";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("size")) return "containerSize";
|
||||
if (h.includes("seal")) return "sealNumber";
|
||||
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
||||
if (h.includes("hazard")) return "hazardous";
|
||||
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
||||
// After the more specific matches: "Container Number", "Container No", …
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */
|
||||
function normalizeSize(raw: string, allowed: string[]): string | null {
|
||||
const digits = raw.replace(/[^0-9]/g, "");
|
||||
if (!digits) return null;
|
||||
return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null;
|
||||
}
|
||||
|
||||
function parseFlag(raw: string): boolean {
|
||||
const v = raw.trim().toLowerCase();
|
||||
return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook into one row per container. Returns either the
|
||||
* full row set or the list of row-numbered problems (never both).
|
||||
*/
|
||||
export async function parseContainerExcel(
|
||||
file: File,
|
||||
opts: ContainerExcelOptions,
|
||||
): Promise<ContainerExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) {
|
||||
return { rows: [], errors: ["The file has no sheets."] };
|
||||
}
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
raw: false,
|
||||
defval: "",
|
||||
});
|
||||
|
||||
// First row with a recognizable column is the header; everything above
|
||||
// (titles, blank rows) is ignored.
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber") && mapped.includes("containerSize")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!columns.includes("vgmTons")) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ImportedContainerRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const size = normalizeSize(cell("containerSize"), opts.allowedSizes);
|
||||
if (!size) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase();
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const vgmRaw = cell("vgmTons");
|
||||
const vgm = Number(vgmRaw);
|
||||
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
||||
errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerSize: size ?? "",
|
||||
containerNumber,
|
||||
sealNumber: cell("sealNumber"),
|
||||
vgmTons: vgmRaw,
|
||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Generate and download the simple import template with one sample row per size. */
|
||||
export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
||||
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
||||
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
||||
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
||||
|
||||
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
||||
const sampleRows = sizes.map((size, i) => {
|
||||
const row: Array<string | number> = [
|
||||
size,
|
||||
`MSCU${String(1234567 + i).padStart(7, "0")}`,
|
||||
`SL${String(482910 + i)}`,
|
||||
size.startsWith("40") ? 28 : 24.5,
|
||||
];
|
||||
if (opts.includeHazardous) row.push("NO");
|
||||
if (opts.includeReefer) row.push("NO");
|
||||
return row;
|
||||
});
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Containers");
|
||||
XLSX.writeFile(workbook, "container-import-template.xlsx");
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -298,6 +298,9 @@ importers:
|
||||
tinymce:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zustand:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))
|
||||
@@ -419,6 +422,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
Reference in New Issue
Block a user