mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Handover SIGN notification, resending until signed no exit before sign
This commit is contained in:
@@ -57,6 +57,15 @@ export class CustomerTruckService {
|
|||||||
|
|
||||||
if (requested.length) {
|
if (requested.length) {
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
// Never assign more trucks than the booking has containers.
|
||||||
|
const existingTrucks = await this.dataSource
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.count({ where: { bookingId } });
|
||||||
|
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const n of requested) {
|
for (const n of requested) {
|
||||||
if (!bookingNumbers.includes(n)) {
|
if (!bookingNumbers.includes(n)) {
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||||
|
* company's phone/email and sends the message over both channels, swallowing
|
||||||
|
* per-channel failures so a missing provider never breaks the caller's flow.
|
||||||
|
*/
|
||||||
|
export async function sendCompanyChannels(
|
||||||
|
dataSource: DataSource,
|
||||||
|
notifications: NotificationsService,
|
||||||
|
companyId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||||
|
await dataSource.query(
|
||||||
|
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
||||||
|
FROM freight.companies
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[companyId],
|
||||||
|
);
|
||||||
|
if (contact?.phone) {
|
||||||
|
try {
|
||||||
|
await notifications.directSend('sms', contact.phone, message);
|
||||||
|
} catch {
|
||||||
|
/* best-effort: SMS provider unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contact?.email) {
|
||||||
|
try {
|
||||||
|
await notifications.directSend('email', contact.email, message);
|
||||||
|
} catch {
|
||||||
|
/* best-effort: email provider unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||||
|
|
||||||
export class CreateWarehouseDto {
|
export class CreateWarehouseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxVolume?: number;
|
maxVolume?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WAREHOUSE_STATUSES)
|
||||||
|
status?: WarehouseStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional explicit storage location. When warehouse/yard/zone are all provided,
|
||||||
|
* the item is stored there directly; otherwise store() falls back to the
|
||||||
|
* allocation-rule / capacity-balanced auto pick.
|
||||||
|
*/
|
||||||
|
export class StoreInventoryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
warehouseId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
yardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
zoneId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
|||||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
STORED: ['RESERVED'],
|
// Reserve is retired from the operator flow — a stored export item advances
|
||||||
|
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
||||||
|
STORED: ['RESERVED', 'READY_FOR_LOADING'],
|
||||||
RESERVED: ['READY_FOR_LOADING'],
|
RESERVED: ['READY_FOR_LOADING'],
|
||||||
READY_FOR_LOADING: ['LOADED'],
|
READY_FOR_LOADING: ['LOADED'],
|
||||||
LOADED: ['DISPATCHED'],
|
LOADED: ['DISPATCHED'],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
|
|||||||
|
|
||||||
import { BookingHandover } from './entities/booking-handover.entity';
|
import { BookingHandover } from './entities/booking-handover.entity';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||||
@@ -18,6 +20,7 @@ export class HandoverService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Tell the customer a handover is ready and needs their signature. */
|
/** Tell the customer a handover is ready and needs their signature. */
|
||||||
@@ -28,15 +31,17 @@ export class HandoverService {
|
|||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
if (!b?.companyId) return;
|
if (!b?.companyId) return;
|
||||||
|
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||||
await this.inbox.notify({
|
await this.inbox.notify({
|
||||||
recipients: { companyId: b.companyId },
|
recipients: { companyId: b.companyId },
|
||||||
audience: NotificationAudience.PORTAL,
|
audience: NotificationAudience.PORTAL,
|
||||||
type: NotificationType.DOCUMENT_ACTION,
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
title: 'Handover — signature needed',
|
title: 'Handover — signature needed',
|
||||||
body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`,
|
body,
|
||||||
link: `/bookings/${bookingId}`,
|
link: `/bookings/${bookingId}`,
|
||||||
data: { bookingId, reference },
|
data: { bookingId, reference },
|
||||||
});
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -49,6 +54,32 @@ export class HandoverService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the customer to sign the booking's handover. Ensures a handover exists
|
||||||
|
* (creates a booking-level self-haul one if none yet), then fires the
|
||||||
|
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
|
||||||
|
*/
|
||||||
|
async requestSignature(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingHandover);
|
||||||
|
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
// No handover yet (truck not arrived): create a booking-level one so the
|
||||||
|
// customer has something to sign. ensureForArrivedTruck notifies on create.
|
||||||
|
const created = await this.ensureForArrivedTruck(bookingId, {});
|
||||||
|
return { notified: true, reference: created.reference, alreadySigned: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned = existing.find((h) => !h.signedAt);
|
||||||
|
if (!unsigned) {
|
||||||
|
return { notified: false, reference: existing[0].reference, alreadySigned: true };
|
||||||
|
}
|
||||||
|
await this.notifySignNeeded(bookingId, unsigned.reference);
|
||||||
|
return { notified: true, reference: unsigned.reference, alreadySigned: false };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||||
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseInspectionService {
|
export class WarehouseInspectionService {
|
||||||
|
private readonly logger = new Logger(WarehouseInspectionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||||
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
|
|||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.booking_id AS "bookingId",
|
`SELECT inv.booking_id AS "bookingId",
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
|
b.company_id AS "companyId",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||||
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
|
|||||||
|
|
||||||
if (row.bookingReference && hasLastMile) {
|
if (row.bookingReference && hasLastMile) {
|
||||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||||
|
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
|
||||||
|
// Self-haul import: goods are pickup-ready but no collection truck is
|
||||||
|
// assigned yet — nudge the customer to assign one from the portal.
|
||||||
|
void this.notifyTruckAssignmentNeeded(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
|
||||||
|
private async notifyTruckAssignmentNeeded(row: {
|
||||||
|
bookingId?: string | null;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
companyId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!row.companyId || !row.bookingId) return;
|
||||||
|
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: row.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Assign a truck for pickup',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${row.bookingId}`,
|
||||||
|
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
|||||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
|
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/store')
|
@Post(':id/store')
|
||||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||||
return this.inventoryService.store(id, performedBy);
|
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/ready-for-loading')
|
@Post(':id/ready-for-loading')
|
||||||
@@ -354,12 +355,24 @@ export class WarehouseInventoryController {
|
|||||||
return this.handoverService.list(bookingId);
|
return this.handoverService.list(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/request-handover-signature')
|
||||||
|
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||||
|
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.handoverService.requestSignature(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/container-items')
|
@Get('bookings/:bookingId/container-items')
|
||||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.inventoryService.containerItems(bookingId);
|
return this.inventoryService.containerItems(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/container-weights')
|
||||||
|
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||||
|
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
|||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||||
@@ -358,12 +359,14 @@ export interface ImportUnloadedRow {
|
|||||||
customerTruckType: string | null;
|
customerTruckType: string | null;
|
||||||
customerTruckContainerNumber: string | null;
|
customerTruckContainerNumber: string | null;
|
||||||
customerTruckAssignedAt: string | null;
|
customerTruckAssignedAt: string | null;
|
||||||
|
hasAssignedTruck: boolean;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
handoverDocumentReference: string | null;
|
handoverDocumentReference: string | null;
|
||||||
handoverDocumentDate: string | null;
|
handoverDocumentDate: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -403,16 +406,18 @@ export class WarehouseInventoryService {
|
|||||||
if (!booking.companyId) return;
|
if (!booking.companyId) return;
|
||||||
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||||
if (booking.customerTruckAssignedAt) return; // already assigned
|
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||||
|
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
|
||||||
try {
|
try {
|
||||||
await this.inbox.notify({
|
await this.inbox.notify({
|
||||||
recipients: { companyId: booking.companyId },
|
recipients: { companyId: booking.companyId },
|
||||||
audience: NotificationAudience.PORTAL,
|
audience: NotificationAudience.PORTAL,
|
||||||
type: NotificationType.BOOKING_STATUS,
|
type: NotificationType.BOOKING_STATUS,
|
||||||
title: 'Assign a truck for pickup',
|
title: 'Assign a truck for pickup',
|
||||||
body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`,
|
body,
|
||||||
link: `/bookings/${bookingId}`,
|
link: `/bookings/${bookingId}`,
|
||||||
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||||
});
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -1338,12 +1343,18 @@ export class WarehouseInventoryService {
|
|||||||
b.customer_truck_type AS "customerTruckType",
|
b.customer_truck_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
|
(b.customer_truck_assigned_at IS NOT NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM freight.last_mile lm
|
||||||
|
WHERE lm.booking_id = b.id
|
||||||
|
AND lm.vehicle_id IS NOT NULL
|
||||||
|
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||||
inv.status AS "currentStatus",
|
inv.status AS "currentStatus",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
inv.release_order_reference AS "releaseOrderReference",
|
inv.release_order_reference AS "releaseOrderReference",
|
||||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||||
inv.delivered_at AS "deliveredAt",
|
inv.delivered_at AS "deliveredAt",
|
||||||
|
inv.notes AS "notes",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry"
|
dy.country AS "destinationCountry"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
@@ -2141,13 +2152,30 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
async store(
|
||||||
|
id: string,
|
||||||
|
performedBy?: string,
|
||||||
|
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
||||||
|
): Promise<WarehouseInventory> {
|
||||||
const item = await this.findById(id);
|
const item = await this.findById(id);
|
||||||
this.assertTransition(item.status, 'STORED');
|
this.assertTransition(item.status, 'STORED');
|
||||||
|
|
||||||
|
// Explicit location wins when the operator picked warehouse + yard + zone;
|
||||||
|
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
|
||||||
|
const manualLocation =
|
||||||
|
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
|
||||||
|
? {
|
||||||
|
warehouseId: chosen.warehouseId,
|
||||||
|
yardId: chosen.yardId,
|
||||||
|
zoneId: chosen.zoneId,
|
||||||
|
path: undefined as string | undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const criteria = await this.getInventoryAllocationCriteria(item);
|
const criteria = await this.getInventoryAllocationCriteria(item);
|
||||||
const ruleLocation = await this.allocation.resolveLocation(criteria);
|
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
|
||||||
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
const location =
|
||||||
|
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
||||||
@@ -2191,18 +2219,19 @@ export class WarehouseInventoryService {
|
|||||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storedReason = manualLocation
|
||||||
|
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
|
||||||
|
: ruleLocation?.rule
|
||||||
|
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||||
|
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
||||||
|
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
status: 'STORED',
|
status: 'STORED',
|
||||||
storedAt: new Date(),
|
storedAt: new Date(),
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
yardId: location.yardId,
|
yardId: location.yardId,
|
||||||
zoneId: location.zoneId,
|
zoneId: location.zoneId,
|
||||||
notes: this.appendNote(
|
notes: this.appendNote(locked.notes, storedReason),
|
||||||
locked.notes,
|
|
||||||
ruleLocation?.rule
|
|
||||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
|
||||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
@@ -2210,9 +2239,7 @@ export class WarehouseInventoryService {
|
|||||||
activityType: 'INVENTORY_STORED',
|
activityType: 'INVENTORY_STORED',
|
||||||
inventoryId: id,
|
inventoryId: id,
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
description: ruleLocation?.rule
|
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||||||
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
|
|
||||||
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
|
|
||||||
performedBy,
|
performedBy,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
@@ -2331,6 +2358,26 @@ export class WarehouseInventoryService {
|
|||||||
'Customer must sign the handover before the exit paper can be generated',
|
'Customer must sign the handover before the exit paper can be generated',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||||
|
// total VGM cargo weight of the containers selected as loaded on it.
|
||||||
|
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||||
|
const selected = dto.containerNumber
|
||||||
|
.split(/[,;\n]+/)
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (selected.length) {
|
||||||
|
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||||
|
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
|
||||||
|
const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
|
||||||
|
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
|
||||||
|
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const releaseDate = isTruckLeaving
|
const releaseDate = isTruckLeaving
|
||||||
@@ -2565,6 +2612,7 @@ export class WarehouseInventoryService {
|
|||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
|
handoverSigned: boolean;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const rows: Array<{
|
const rows: Array<{
|
||||||
@@ -2611,6 +2659,10 @@ export class WarehouseInventoryService {
|
|||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Booking-level gate: the per-truck exit paper is blocked until the handover
|
||||||
|
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
|
||||||
|
const handoverSigned = await this.handover.isFullySigned(bookingId);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
goods: r.goods,
|
goods: r.goods,
|
||||||
@@ -2633,6 +2685,32 @@ export class WarehouseInventoryService {
|
|||||||
bookingReference: r.bookingReference,
|
bookingReference: r.bookingReference,
|
||||||
contractId: r.contractId,
|
contractId: r.contractId,
|
||||||
hasLastMile: r.hasLastMile,
|
hasLastMile: r.hasLastMile,
|
||||||
|
handoverSigned,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||||
|
* container number. Drives the truck-leaving exit weighing: the selected
|
||||||
|
* containers' total cargo weight must match (gross − tare).
|
||||||
|
*/
|
||||||
|
async bookingContainerWeights(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
|
||||||
|
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber",
|
||||||
|
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||||||
|
ORDER BY bcu.container_number`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
containerNumber: r.containerNumber,
|
||||||
|
weightTons: Number(r.weightTons) || 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export class WarehousesService {
|
|||||||
currentWeight: 0,
|
currentWeight: 0,
|
||||||
currentContainers: 0,
|
currentContainers: 0,
|
||||||
currentVolume: 0,
|
currentVolume: 0,
|
||||||
status: 'ACTIVE',
|
status: dto.status ?? 'ACTIVE',
|
||||||
isActive: true,
|
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.mapDbError(error);
|
this.mapDbError(error);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
type ContainerItem,
|
type ContainerItem,
|
||||||
type ContainerItemStage,
|
type ContainerItemStage,
|
||||||
} from '@/services/warehouse.service';
|
} from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface ContainerItemsModalProps {
|
interface ContainerItemsModalProps {
|
||||||
@@ -90,12 +91,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const requestSign = async () => {
|
||||||
|
try {
|
||||||
|
const res = await warehouseService.requestHandoverSignature(bookingId as string);
|
||||||
|
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||||
|
if (res.alreadySigned) {
|
||||||
|
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Handover not signed',
|
||||||
|
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openExitPaper = async (assignmentId: string, plate: string) => {
|
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,15 +182,27 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
{i.truckAssignmentId && (
|
{i.truckAssignmentId && (
|
||||||
<Button
|
<Tooltip
|
||||||
size="compact-xs"
|
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||||
variant="light"
|
disabled={i.handoverSigned}
|
||||||
color="orange"
|
withArrow
|
||||||
leftSection={<FileText size={13} />}
|
multiline
|
||||||
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
w={240}
|
||||||
>
|
>
|
||||||
Exit Paper
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color={i.handoverSigned ? 'orange' : 'gray'}
|
||||||
|
leftSection={<FileText size={13} />}
|
||||||
|
onClick={() =>
|
||||||
|
i.handoverSigned
|
||||||
|
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
|
||||||
|
: requestSign()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
||||||
toast({ title: 'Warehouse updated' });
|
toast({ title: 'Warehouse updated' });
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(payload);
|
await createMutation.mutateAsync({ ...payload, status: form.status });
|
||||||
toast({ title: 'Warehouse created' });
|
toast({ title: 'Warehouse created' });
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
/>
|
/>
|
||||||
{isEdit && (
|
<Select
|
||||||
<Select
|
label="Status"
|
||||||
label="Status"
|
data={statusOptions}
|
||||||
data={statusOptions}
|
value={form.status}
|
||||||
value={form.status}
|
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
allowDeselect={false}
|
||||||
allowDeselect={false}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
|
|||||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
|
||||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
|
|||||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
/** Inventory table + all lifecycle actions (advance / move / history). */
|
||||||
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
|
||||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||||
setBusyId(item.id);
|
setBusyId(item.id);
|
||||||
try {
|
try {
|
||||||
const stored = await storeMutation.mutateAsync(item.id);
|
const stored = await storeMutation.mutateAsync({ id: item.id });
|
||||||
toast({
|
toast({
|
||||||
title: 'Inventory stored',
|
title: 'Inventory stored',
|
||||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||||
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case 'store':
|
case 'store':
|
||||||
return storeInventory(item);
|
return storeInventory(item);
|
||||||
case 'reserve':
|
|
||||||
setReserveItem(item);
|
|
||||||
return;
|
|
||||||
case 'ready-for-loading':
|
case 'ready-for-loading':
|
||||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||||
case 'load':
|
case 'load':
|
||||||
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||||
<ReserveInventoryModal
|
|
||||||
opened={Boolean(reserveItem)}
|
|
||||||
onClose={() => setReserveItem(null)}
|
|
||||||
item={reserveItem}
|
|
||||||
/>
|
|
||||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||||
<InventoryHistoryModal
|
<InventoryHistoryModal
|
||||||
opened={Boolean(historyItem)}
|
opened={Boolean(historyItem)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
|
ArrowRightLeft,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -27,6 +29,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
History,
|
History,
|
||||||
Info,
|
Info,
|
||||||
|
MapPin,
|
||||||
|
MoreHorizontal,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
@@ -61,7 +65,6 @@ import type {
|
|||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { BookingSelect } from './BookingSelect';
|
import { BookingSelect } from './BookingSelect';
|
||||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||||
import { TruckDispatchModal } from './TruckDispatchModal';
|
|
||||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||||
import { FeePreviewModal } from './FeePreviewModal';
|
import { FeePreviewModal } from './FeePreviewModal';
|
||||||
import { InspectionReportModal } from './InspectionReportModal';
|
import { InspectionReportModal } from './InspectionReportModal';
|
||||||
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
|
|||||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||||
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
|
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const inspectMutation = useMutation(
|
const inspectMutation = useMutation(
|
||||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||||
);
|
);
|
||||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
|
||||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
|
|
||||||
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
|
||||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||||
const someSelected = selected.size > 0 && !allSelected;
|
const someSelected = selected.size > 0 && !allSelected;
|
||||||
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{r.currentStatus === 'UNLOADED' && (
|
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="yellow"
|
||||||
loading={busyId === r.id}
|
leftSection={<Truck size={14} />}
|
||||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
>
|
>
|
||||||
Store
|
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="orange"
|
|
||||||
loading={busyId === r.id}
|
|
||||||
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
|
||||||
>
|
|
||||||
Ready Pickup
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="yellow"
|
|
||||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
||||||
>
|
|
||||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="green"
|
|
||||||
loading={busyId === r.id}
|
|
||||||
onClick={() => setLoadTruckItem(toInventoryItem(r))}
|
|
||||||
>
|
|
||||||
Truck_dispatch
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
Exit Paper
|
Exit Paper
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
||||||
<Button
|
<Menu.Target>
|
||||||
size="compact-xs"
|
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
||||||
variant="light"
|
<MoreHorizontal size={16} />
|
||||||
color="green"
|
</ActionIcon>
|
||||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
</Menu.Target>
|
||||||
>
|
<Menu.Dropdown>
|
||||||
Deliver
|
{r.currentStatus === 'UNLOADED' && (
|
||||||
</Button>
|
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
||||||
)}
|
Store…
|
||||||
{r.inspectionStatus === 'PASSED' && (
|
</Menu.Item>
|
||||||
<Button
|
)}
|
||||||
size="compact-xs"
|
{r.currentStatus !== 'UNLOADED' && (
|
||||||
variant="light"
|
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
||||||
color="teal"
|
Move…
|
||||||
leftSection={<FileText size={14} />}
|
</Menu.Item>
|
||||||
onClick={() => openHandoverDocument(r)}
|
)}
|
||||||
>
|
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
||||||
</Button>
|
Ready for pickup
|
||||||
)}
|
</Menu.Item>
|
||||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
)}
|
||||||
Inspect / Report
|
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||||
</Button>
|
<Menu.Item
|
||||||
<Tooltip label="Storage / fee preview" withArrow>
|
leftSection={<Truck size={14} />}
|
||||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
disabled={!r.hasAssignedTruck}
|
||||||
<PackageCheck size={16} />
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
{r.hasAssignedTruck
|
||||||
<Tooltip label="History" withArrow>
|
? r.releaseOrderReference
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
? 'Truck leaving'
|
||||||
<History size={16} />
|
: 'Truck arrival'
|
||||||
</ActionIcon>
|
: 'Truck arrival — assign a truck first'}
|
||||||
</Tooltip>
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
|
||||||
|
Exit paper
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
|
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
|
||||||
|
)}
|
||||||
|
{r.inspectionStatus === 'PASSED' && (
|
||||||
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
|
||||||
|
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||||
|
Storage / fee preview
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||||
|
History
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
inventoryId={feeItem?.id ?? null}
|
inventoryId={feeItem?.id ?? null}
|
||||||
/>
|
/>
|
||||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||||
|
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
|
||||||
|
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||||
<TruckDispatchModal
|
|
||||||
opened={Boolean(loadTruckItem)}
|
|
||||||
onClose={() => setLoadTruckItem(null)}
|
|
||||||
bookingId={loadTruckItem?.booking?.id ?? null}
|
|
||||||
bookingReference={loadTruckItem?.booking?.reference ?? null}
|
|
||||||
/>
|
|
||||||
<ContainerItemsModal
|
<ContainerItemsModal
|
||||||
opened={Boolean(containerItemsItem)}
|
opened={Boolean(containerItemsItem)}
|
||||||
onClose={() => setContainerItemsItem(null)}
|
onClose={() => setContainerItemsItem(null)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
import { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
|
|||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
|
||||||
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
|
||||||
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
|
||||||
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
|
||||||
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
|
||||||
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
|
||||||
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
|
||||||
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
|
||||||
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
|
||||||
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
|
||||||
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
|
||||||
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
|
||||||
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
|
||||||
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
|
||||||
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
|
||||||
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
|
||||||
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
|
||||||
].map(([powerPlate, trailerPlate], index) => ({
|
|
||||||
value: powerPlate,
|
|
||||||
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
|
|
||||||
trailerPlate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const toIsoDateTime = (value: string) => {
|
const toIsoDateTime = (value: string) => {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||||
enabled: opened && Boolean(bookingId),
|
enabled: opened && Boolean(bookingId),
|
||||||
});
|
});
|
||||||
|
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||||||
|
// total cargo weight of the containers selected as loaded on it.
|
||||||
|
const { data: containerWeights = [] } = useQuery({
|
||||||
|
queryKey: ['release-container-weights', bookingId],
|
||||||
|
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
const [reference, setReference] = useState('');
|
const [reference, setReference] = useState('');
|
||||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||||
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
truckType: t.truckType,
|
truckType: t.truckType,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
const truckSelectOptions = [
|
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||||
...assignedTruckOptions,
|
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||||
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
// operator types the plate manually in the field below.
|
||||||
value: t.value,
|
const truckSelectOptions = assignedTruckOptions;
|
||||||
label: t.label,
|
|
||||||
trailerPlate: t.trailerPlate,
|
|
||||||
driverName: '',
|
|
||||||
driverPhone: '',
|
|
||||||
truckType: '',
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
|
||||||
|
// Which containers ride this truck, and their combined cargo weight. When the
|
||||||
|
// booking has container weights, that sum is the authoritative net; the
|
||||||
|
// operator selects the containers loaded on the truck at exit.
|
||||||
|
const hasContainerWeights = containerWeights.length > 0;
|
||||||
|
const containerWeightByNumber = new Map(
|
||||||
|
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||||
|
);
|
||||||
|
const containerSelectData = containerWeights.map((c) => ({
|
||||||
|
value: c.containerNumber,
|
||||||
|
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||||
|
}));
|
||||||
|
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||||
|
const selectedCargoWeight = Number(
|
||||||
|
selectedContainerNumbers
|
||||||
|
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||||
|
.toFixed(3),
|
||||||
|
);
|
||||||
|
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||||
|
|
||||||
|
const systemNetWeight = useContainerNet
|
||||||
|
? selectedCargoWeight
|
||||||
|
: item?.weight == null
|
||||||
|
? netWeight
|
||||||
|
: Number(item.weight);
|
||||||
const computedNetWeight =
|
const computedNetWeight =
|
||||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||||
const weightMismatch =
|
const weightMismatch =
|
||||||
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||||
|
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isExitStep && systemNetWeight === '') {
|
if (isExitStep && systemNetWeight === '') {
|
||||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||||
return;
|
return;
|
||||||
@@ -336,23 +341,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Select
|
{truckSelectOptions.length > 0 && (
|
||||||
label="Registered first / last-mile truck"
|
<Select
|
||||||
placeholder="Select truck or type plate manually below"
|
label="Assigned first / last-mile truck"
|
||||||
searchable
|
placeholder="Select the assigned truck"
|
||||||
clearable
|
searchable
|
||||||
data={truckSelectOptions}
|
clearable
|
||||||
disabled={isTruckIdentityLocked}
|
data={truckSelectOptions}
|
||||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
disabled={isTruckIdentityLocked}
|
||||||
onChange={(value) => {
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
onChange={(value) => {
|
||||||
setTruckPlateNumber(truck?.value ?? '');
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
setTruckPlateNumber(truck?.value ?? '');
|
||||||
if (truck?.driverName) setDriverName(truck.driverName);
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
if (truck?.driverName) setDriverName(truck.driverName);
|
||||||
if (truck?.truckType) setTruckType(truck.truckType);
|
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||||
}}
|
if (truck?.truckType) setTruckType(truck.truckType);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Truck plate number"
|
label="Truck plate number"
|
||||||
@@ -376,30 +383,51 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow align="flex-start">
|
||||||
<Stack gap={6}>
|
{hasContainerWeights ? (
|
||||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
<MultiSelect
|
||||||
{containerNumbers.map((containerNumber, index) => (
|
label="Containers on this truck"
|
||||||
<TextInput
|
description={
|
||||||
key={index}
|
isExitStep
|
||||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||||||
value={containerNumber}
|
: 'Containers this truck will carry.'
|
||||||
onChange={(e) =>
|
}
|
||||||
setContainerNumbers((numbers) =>
|
placeholder="Select containers"
|
||||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
searchable
|
||||||
)
|
data={containerSelectData}
|
||||||
}
|
value={selectedContainerNumbers}
|
||||||
readOnly={isTruckIdentityLocked}
|
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
</SimpleGrid>
|
<Stack gap={6}>
|
||||||
</Stack>
|
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||||
|
{containerNumbers.map((containerNumber, index) => (
|
||||||
|
<TextInput
|
||||||
|
key={index}
|
||||||
|
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||||
|
value={containerNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setContainerNumbers((numbers) =>
|
||||||
|
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
readOnly={isTruckIdentityLocked}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||||
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||||
<NumberInput label="Recorded net weight (system t)" min={0} value={systemNetWeight} readOnly />
|
<NumberInput
|
||||||
|
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||||
|
min={0}
|
||||||
|
value={systemNetWeight}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
|
import { extractErrorMessage } from './options';
|
||||||
|
|
||||||
|
interface StoreInventoryModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
item: WarehouseInventoryItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an unloaded import item. The operator may pick warehouse → yard → zone
|
||||||
|
* explicitly; leaving them blank falls back to the backend auto allocation.
|
||||||
|
*/
|
||||||
|
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||||
|
const [warehouseId, setWarehouseId] = useState('');
|
||||||
|
const [yardId, setYardId] = useState('');
|
||||||
|
const [zoneId, setZoneId] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
setWarehouseId('');
|
||||||
|
setYardId('');
|
||||||
|
setZoneId('');
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const warehousesQuery = useQuery(
|
||||||
|
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||||
|
);
|
||||||
|
const yardsQuery = useQuery(
|
||||||
|
api.warehouses.listYards.queryOptions({
|
||||||
|
input: { warehouseId },
|
||||||
|
enabled: Boolean(warehouseId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const zonesQuery = useQuery(
|
||||||
|
api.warehouses.listZones.queryOptions({
|
||||||
|
input: { yardId },
|
||||||
|
enabled: Boolean(yardId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const warehouseOptions = useMemo(
|
||||||
|
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||||
|
[warehousesQuery.data],
|
||||||
|
);
|
||||||
|
const yardOptions = useMemo(
|
||||||
|
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||||
|
[yardsQuery.data],
|
||||||
|
);
|
||||||
|
const zoneOptions = useMemo(
|
||||||
|
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||||
|
[zonesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isManual = Boolean(warehouseId || yardId || zoneId);
|
||||||
|
const manualComplete = Boolean(warehouseId && yardId && zoneId);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!item) return;
|
||||||
|
if (isManual && !manualComplete) {
|
||||||
|
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await storeMutation.mutateAsync({
|
||||||
|
id: item.id,
|
||||||
|
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||||
|
});
|
||||||
|
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
|
||||||
|
blank to let the system auto-allocate by rule / available capacity.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
<Select
|
||||||
|
label="Warehouse"
|
||||||
|
placeholder="Auto-allocate"
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
data={warehouseOptions}
|
||||||
|
value={warehouseId || null}
|
||||||
|
onChange={(v) => {
|
||||||
|
setWarehouseId(v ?? '');
|
||||||
|
setYardId('');
|
||||||
|
setZoneId('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Yard"
|
||||||
|
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={!warehouseId}
|
||||||
|
data={yardOptions}
|
||||||
|
value={yardId || null}
|
||||||
|
onChange={(v) => {
|
||||||
|
setYardId(v ?? '');
|
||||||
|
setZoneId('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Zone"
|
||||||
|
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={!yardId}
|
||||||
|
data={zoneOptions}
|
||||||
|
value={zoneId || null}
|
||||||
|
onChange={(v) => setZoneId(v ?? '')}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
|
||||||
|
{manualComplete ? 'Store here' : 'Store (auto)'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
|
|||||||
|
|
||||||
const actionColor: Record<InventoryAction, string> = {
|
const actionColor: Record<InventoryAction, string> = {
|
||||||
store: 'blue',
|
store: 'blue',
|
||||||
reserve: 'grape',
|
|
||||||
'ready-for-loading': 'cyan',
|
'ready-for-loading': 'cyan',
|
||||||
load: 'teal',
|
load: 'teal',
|
||||||
dispatch: 'edr-green',
|
dispatch: 'edr-green',
|
||||||
|
|||||||
@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
|||||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||||
|
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||||
|
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
|
||||||
|
*/
|
||||||
|
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
|
||||||
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
|
if (responseData instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const text = await responseData.text();
|
||||||
|
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
const raw = parsed?.message ?? parsed?.error;
|
||||||
|
if (Array.isArray(raw)) return raw.join(', ');
|
||||||
|
if (raw) return String(raw);
|
||||||
|
} catch {
|
||||||
|
/* not JSON — fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extractErrorMessage(error, fallback);
|
||||||
|
};
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
|||||||
handoverDocumentReference: row.handoverDocumentReference,
|
handoverDocumentReference: row.handoverDocumentReference,
|
||||||
handoverDocumentDate: row.handoverDocumentDate,
|
handoverDocumentDate: row.handoverDocumentDate,
|
||||||
deliveredAt: row.deliveredAt,
|
deliveredAt: row.deliveredAt,
|
||||||
|
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||||
|
// details captured at arrival (plate, driver, tare, gate-in).
|
||||||
|
notes: row.notes,
|
||||||
booking: row.bookingId
|
booking: row.bookingId
|
||||||
? {
|
? {
|
||||||
id: row.bookingId,
|
id: row.bookingId,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ import type {
|
|||||||
LoadInventoryPayload,
|
LoadInventoryPayload,
|
||||||
LoadPassedExportResult,
|
LoadPassedExportResult,
|
||||||
MoveInventoryPayload,
|
MoveInventoryPayload,
|
||||||
|
StoreInventoryPayload,
|
||||||
PayInvoicePayload,
|
PayInvoicePayload,
|
||||||
ReadyToLoadRow,
|
ReadyToLoadRow,
|
||||||
ReceiveInventoryPayload,
|
ReceiveInventoryPayload,
|
||||||
@@ -1051,10 +1052,13 @@ export const api = {
|
|||||||
() => [["warehouse-inventory"], ["warehouses"]],
|
() => [["warehouse-inventory"], ["warehouses"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
store: endpoint<string, WarehouseInventoryItem>(
|
store: endpoint<
|
||||||
|
{ id: string; payload?: StoreInventoryPayload },
|
||||||
|
WarehouseInventoryItem
|
||||||
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"store",
|
"store",
|
||||||
(id) => warehouseService.store(id).then((r) => r.data),
|
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
LoadableWagon,
|
LoadableWagon,
|
||||||
LoadInventoryPayload,
|
LoadInventoryPayload,
|
||||||
MoveInventoryPayload,
|
MoveInventoryPayload,
|
||||||
|
StoreInventoryPayload,
|
||||||
ReceiveInventoryPayload,
|
ReceiveInventoryPayload,
|
||||||
ReleaseOrderPayload,
|
ReleaseOrderPayload,
|
||||||
DeliverInventoryPayload,
|
DeliverInventoryPayload,
|
||||||
@@ -77,6 +78,7 @@ export interface ContainerItem {
|
|||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
|
handoverSigned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||||
@@ -135,6 +137,26 @@ export const warehouseService = {
|
|||||||
return data?.data ?? data ?? [];
|
return data?.data ?? data ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
|
||||||
|
requestHandoverSignature: async (
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
|
||||||
|
);
|
||||||
|
return data?.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
|
||||||
|
getContainerWeights: async (
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
|
||||||
|
const { data } = await apiClient.get(
|
||||||
|
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
|
||||||
|
);
|
||||||
|
return data?.data ?? data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
/** Booking container numbers not yet loaded onto any truck. */
|
/** Booking container numbers not yet loaded onto any truck. */
|
||||||
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
||||||
const { data } = await apiClient.get(
|
const { data } = await apiClient.get(
|
||||||
@@ -235,8 +257,8 @@ export const warehouseService = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
||||||
store: (id: string) =>
|
store: (id: string, payload?: StoreInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
|
||||||
reserve: (payload: ReserveInventoryPayload) =>
|
reserve: (payload: ReserveInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
||||||
markReadyForLoading: (id: string) =>
|
markReadyForLoading: (id: string) =>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
|||||||
|
|
||||||
export type InventoryAction =
|
export type InventoryAction =
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'reserve'
|
|
||||||
| 'ready-for-loading'
|
| 'ready-for-loading'
|
||||||
| 'load'
|
| 'load'
|
||||||
| 'dispatch'
|
| 'dispatch'
|
||||||
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
|||||||
UNLOADED: 'store',
|
UNLOADED: 'store',
|
||||||
UNLOADED_AT_DJIBOUTI_PORT: null,
|
UNLOADED_AT_DJIBOUTI_PORT: null,
|
||||||
RECEIVED: 'store',
|
RECEIVED: 'store',
|
||||||
STORED: 'reserve',
|
STORED: 'ready-for-loading',
|
||||||
RESERVED: 'ready-for-loading',
|
RESERVED: 'ready-for-loading',
|
||||||
ARRIVED_AT_WAREHOUSE: null,
|
ARRIVED_AT_WAREHOUSE: null,
|
||||||
UNDER_INSPECTION: null,
|
UNDER_INSPECTION: null,
|
||||||
@@ -85,6 +84,11 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
|||||||
// Import goods skip storage; they need inspection before pickup.
|
// Import goods skip storage; they need inspection before pickup.
|
||||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||||
return 'store';
|
return 'store';
|
||||||
|
case 'STORED':
|
||||||
|
// Reserve is retired: a stored export item goes straight to loading prep
|
||||||
|
// once inspection passes. Import STORED is handled via the import queue.
|
||||||
|
if (isImport) return null;
|
||||||
|
return inspected ? 'ready-for-loading' : null;
|
||||||
case 'RESERVED':
|
case 'RESERVED':
|
||||||
// Export loading is gated on a passed inspection.
|
// Export loading is gated on a passed inspection.
|
||||||
return inspected ? 'ready-for-loading' : null;
|
return inspected ? 'ready-for-loading' : null;
|
||||||
@@ -589,12 +593,21 @@ export interface ImportUnloadedItem {
|
|||||||
customerTruckType: string | null;
|
customerTruckType: string | null;
|
||||||
customerTruckContainerNumber: string | null;
|
customerTruckContainerNumber: string | null;
|
||||||
customerTruckAssignedAt: string | null;
|
customerTruckAssignedAt: string | null;
|
||||||
|
hasAssignedTruck: boolean;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
handoverDocumentReference: string | null;
|
handoverDocumentReference: string | null;
|
||||||
handoverDocumentDate: string | null;
|
handoverDocumentDate: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional explicit storage location; blank → backend auto-allocates. */
|
||||||
|
export interface StoreInventoryPayload {
|
||||||
|
warehouseId?: string;
|
||||||
|
yardId?: string;
|
||||||
|
zoneId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportTrainItem {
|
export interface ImportTrainItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user