mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* The person who signs off a handover must record their full name (a signature
|
||||
* is optional, especially for self-haul). Stored per handover record.
|
||||
*/
|
||||
export class AddHandoverSignerName2130000000000 implements MigrationInterface {
|
||||
name = "AddHandoverSignerName2130000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
ADD COLUMN IF NOT EXISTS signer_name varchar(160)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
DROP COLUMN IF EXISTS signer_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
|
||||
* accrual as reviewed (optionally snoozed until a date) so it stops nudging and
|
||||
* drops down the accrual dashboard. One row per inventory item.
|
||||
*/
|
||||
export class CreateAccrualAcks2140000000000 implements MigrationInterface {
|
||||
name = "CreateAccrualAcks2140000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id uuid NOT NULL UNIQUE,
|
||||
acknowledged_by uuid,
|
||||
acknowledged_at timestamptz NOT NULL DEFAULT now(),
|
||||
snooze_until timestamptz,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
/** Acknowledge (optionally snooze) an item's fee-accrual alert. */
|
||||
export class AcknowledgeAccrualDto {
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(90)
|
||||
snoozeDays?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional reason / note.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** The customer approving a handover must record their full name (signature optional). */
|
||||
export class ApproveDeliveryDto {
|
||||
@ApiProperty({ description: 'Full name of the person approving delivery.' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(160)
|
||||
signerName!: string;
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity {
|
||||
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
||||
signedAt?: Date | null;
|
||||
|
||||
/** Full name of the person who signed off the handover (required at sign time). */
|
||||
@Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true })
|
||||
signerName?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
|
||||
signedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -183,12 +183,20 @@ export class HandoverService {
|
||||
}
|
||||
|
||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
||||
async signForBooking(
|
||||
bookingId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(BookingHandover)
|
||||
.update(
|
||||
{ bookingId, signedAt: IsNull() },
|
||||
{ signedAt: new Date(), signedByUserId: userId ?? null },
|
||||
{
|
||||
signedAt: new Date(),
|
||||
signedByUserId: userId ?? null,
|
||||
signerName: signerName?.trim() || null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
@@ -26,6 +29,35 @@ interface ItemAttributes {
|
||||
zoneId: string | null;
|
||||
}
|
||||
|
||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||
|
||||
export interface AccrualDashboardRow {
|
||||
inventoryId: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
currency: string;
|
||||
accruedAmount: number;
|
||||
freeDaysLeft: number | null;
|
||||
charging: boolean;
|
||||
alert: AccrualAlert;
|
||||
/** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
|
||||
acknowledged: boolean;
|
||||
snoozeUntil: string | null;
|
||||
breakdown: Array<{
|
||||
type: FeeRuleType;
|
||||
amount: number;
|
||||
freeDays: number;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||||
@@ -70,12 +102,82 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeService {
|
||||
private readonly logger = new Logger(WarehouseFeeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Daily accrual alerts: for every in-warehouse item that is charging or within
|
||||
* its last free days, send the customer an in-app notification with the
|
||||
* outstanding accrued amount so they can collect before (more) charges hit.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
|
||||
async sendAccrualAlerts(): Promise<void> {
|
||||
try {
|
||||
const alerts = (await this.accrualDashboard()).filter(
|
||||
(r) => r.alert !== 'OK' && !r.acknowledged,
|
||||
);
|
||||
if (!alerts.length) return;
|
||||
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
|
||||
|
||||
// Per-customer: notify each company about its own items.
|
||||
for (const row of alerts.filter((r) => r.companyId)) {
|
||||
const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
|
||||
const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
|
||||
const body = row.charging
|
||||
? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.`
|
||||
: `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: row.companyId! },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
|
||||
body,
|
||||
link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
|
||||
data: {
|
||||
inventoryId: row.inventoryId,
|
||||
bookingId: row.bookingId,
|
||||
alert: row.alert,
|
||||
accruedAmount: row.accruedAmount,
|
||||
action: 'ACCRUAL_ALERT',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ops staff: one digest covering every alerting item.
|
||||
const charging = alerts.filter((r) => r.charging).length;
|
||||
const nearing = alerts.length - charging;
|
||||
const currency = alerts[0]?.currency ?? 'USD';
|
||||
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Warehouse fee accruals need attention',
|
||||
body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
|
||||
link: '/dashboard/warehouse-fee-invoices',
|
||||
data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
listRules(): Promise<WarehouseFeeRule[]> {
|
||||
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
|
||||
@@ -416,6 +518,138 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live accrual dashboard: for every item still in the warehouse, the fees
|
||||
* accruing right now (storage + demurrage + double-handling), how many free
|
||||
* days remain, and an alert level so staff can act before charges land.
|
||||
*/
|
||||
async accrualDashboard(billingCurrency = 'USD'): Promise<AccrualDashboardRow[]> {
|
||||
const items: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.status,
|
||||
b.id AS "bookingId",
|
||||
b.company_id AS "companyId",
|
||||
b.reference AS "bookingReference",
|
||||
c.name AS "customerName",
|
||||
w.code AS "warehouseCode",
|
||||
z.code AS "zoneCode",
|
||||
inv.created_at AS "receivedAt"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
ORDER BY inv.created_at ASC`,
|
||||
);
|
||||
|
||||
const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
|
||||
FROM freight.warehouse_accrual_acks`,
|
||||
);
|
||||
const now = new Date();
|
||||
const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));
|
||||
|
||||
const rows = await Promise.all(
|
||||
items.map(async (it): Promise<AccrualDashboardRow> => {
|
||||
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
|
||||
(p) => p.ruleId,
|
||||
);
|
||||
const accruedAmount =
|
||||
Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
|
||||
const charging = previews.some((p) => p.chargeableDays > 0);
|
||||
const freeDaysLeftVals = previews
|
||||
.filter((p) => p.endIsOpen)
|
||||
.map((p) => Math.max(0, p.freeDays - p.elapsedDays));
|
||||
const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
|
||||
const alert: AccrualAlert = charging
|
||||
? 'CHARGING'
|
||||
: freeDaysLeft != null && freeDaysLeft <= 2
|
||||
? 'WARNING'
|
||||
: 'OK';
|
||||
return {
|
||||
inventoryId: it.id,
|
||||
status: it.status,
|
||||
bookingId: it.bookingId,
|
||||
companyId: it.companyId,
|
||||
bookingReference: it.bookingReference,
|
||||
customerName: it.customerName,
|
||||
warehouseCode: it.warehouseCode,
|
||||
zoneCode: it.zoneCode,
|
||||
receivedAt: it.receivedAt,
|
||||
currency: billingCurrency,
|
||||
accruedAmount,
|
||||
freeDaysLeft,
|
||||
charging,
|
||||
alert,
|
||||
acknowledged:
|
||||
acks.has(it.id) &&
|
||||
(acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
|
||||
snoozeUntil: acks.get(it.id) ?? null,
|
||||
breakdown: previews.map((p) => ({
|
||||
type: p.ruleType,
|
||||
amount: p.amount,
|
||||
freeDays: p.freeDays,
|
||||
elapsedDays: p.elapsedDays,
|
||||
chargeableDays: p.chargeableDays,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
|
||||
// Acknowledged items sink to the bottom; among the rest, worst alert first.
|
||||
return rows.sort(
|
||||
(a, b) =>
|
||||
Number(a.acknowledged) - Number(b.acknowledged) ||
|
||||
rank(a.alert) - rank(b.alert) ||
|
||||
b.accruedAmount - a.accruedAmount,
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
|
||||
async acknowledgeAccrual(
|
||||
inventoryId: string,
|
||||
opts: { snoozeDays?: number; note?: string; userId?: string } = {},
|
||||
): Promise<void> {
|
||||
const snoozeUntil =
|
||||
opts.snoozeDays && opts.snoozeDays > 0
|
||||
? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouse_accrual_acks
|
||||
(inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
|
||||
VALUES ($1, $2, now(), $3, $4, now())
|
||||
ON CONFLICT (inventory_id) DO UPDATE
|
||||
SET acknowledged_by = EXCLUDED.acknowledged_by,
|
||||
acknowledged_at = now(),
|
||||
snooze_until = EXCLUDED.snooze_until,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now()`,
|
||||
[inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove an acknowledgement so the item re-surfaces for alerts. */
|
||||
async unacknowledgeAccrual(inventoryId: string): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
|
||||
[inventoryId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
@@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
@ApiTags('warehouse-inspection')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
|
||||
export class WarehouseInspectionController {
|
||||
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
||||
|
||||
@Post('warehouse-inventory/:inventoryId/inspection-reports')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create)
|
||||
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
|
||||
create(
|
||||
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||
@@ -46,12 +50,14 @@ export class WarehouseInspectionController {
|
||||
}
|
||||
|
||||
@Patch('warehouse-inspection-reports/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
|
||||
@ApiOperation({ summary: 'Update an inspection report' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
|
||||
return this.inspectionService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('warehouse-inspection-reports/:id/attachments')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload inspection images / documents' })
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -11,6 +13,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
@@ -29,48 +32,63 @@ export class WarehouseInventoryController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findReadyForLoading(filter);
|
||||
}
|
||||
|
||||
@Get('inquiry')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
|
||||
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
|
||||
return this.inventoryService.inquiry(filter);
|
||||
}
|
||||
|
||||
@Get('arrival-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
|
||||
arrivalQueue() {
|
||||
return this.inventoryService.arrivalQueue();
|
||||
}
|
||||
|
||||
@Get('ops-stats')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' })
|
||||
opsStats() {
|
||||
return this.inventoryService.opsStats();
|
||||
}
|
||||
|
||||
@Get('zone-occupancy')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
||||
zoneOccupancy(@Query('yardId') yardId?: string) {
|
||||
return this.inventoryService.zoneOccupancy(yardId);
|
||||
}
|
||||
|
||||
@Post('auto-unload-arrived')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||
autoUnloadArrived() {
|
||||
return this.inventoryService.autoUnloadArrived();
|
||||
}
|
||||
|
||||
@Post('auto-load-ready')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
|
||||
autoLoadReady() {
|
||||
return this.inventoryService.autoLoadReady();
|
||||
}
|
||||
|
||||
@Get('eligible-bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
|
||||
eligibleBookings(@Query('direction') direction?: string) {
|
||||
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
|
||||
@@ -78,6 +96,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('receive-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
receiveBulk(@Body() dto: BulkReceiveDto) {
|
||||
return this.inventoryService.bulkReceive(dto);
|
||||
@@ -85,36 +104,42 @@ export class WarehouseInventoryController {
|
||||
|
||||
|
||||
@Get('ready-to-load-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
|
||||
readyToLoadExport() {
|
||||
return this.inventoryService.readyToLoadExport();
|
||||
}
|
||||
|
||||
@Get('received-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
|
||||
receivedExport() {
|
||||
return this.inventoryService.receivedExport();
|
||||
}
|
||||
|
||||
@Get('loaded-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||
loadedExport() {
|
||||
return this.inventoryService.loadedExport();
|
||||
}
|
||||
|
||||
@Get('loadable-trains')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
|
||||
loadableTrains() {
|
||||
return this.inventoryService.loadableTrains();
|
||||
}
|
||||
|
||||
@Get('train/:scheduleId/loadable-items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
|
||||
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.inventoryService.trainLoadableItems(scheduleId);
|
||||
}
|
||||
|
||||
@Post('train/:scheduleId/load')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
|
||||
loadItemsOntoTrain(
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
@@ -124,18 +149,21 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bulk-dispatch-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
|
||||
}
|
||||
|
||||
@Post('bulk-mark-inspected')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect)
|
||||
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
|
||||
bulkMarkInspected(@Body() dto: BulkInspectDto) {
|
||||
return this.inventoryService.bulkMarkInspected(dto);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/unload')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||
unloadBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -145,24 +173,28 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/gate-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
||||
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.gateClearance(id, performedBy);
|
||||
}
|
||||
|
||||
@Get('import/arrive-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
|
||||
importArriveQueue() {
|
||||
return this.scheduling.importArriveQueue();
|
||||
}
|
||||
|
||||
@Get('import/trains/:scheduleId/items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
|
||||
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.importTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('import/auto-unload-arrived-bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
|
||||
autoUnloadArrivedBookings(@Body() dto: {
|
||||
scheduleId: string;
|
||||
@@ -179,12 +211,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
|
||||
importUnloadedQueue() {
|
||||
return this.inventoryService.importUnloadedQueue();
|
||||
}
|
||||
|
||||
@Get('export/djibouti-arrival-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
|
||||
exportDjiboutiArrivalQueue(
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@@ -203,102 +237,119 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('export/djibouti-trains/:scheduleId/items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
|
||||
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('export/auto-unload-at-djibouti')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
|
||||
}
|
||||
|
||||
@Get('import/pickup-ready-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
||||
importPickupReadyQueue() {
|
||||
return this.inventoryService.importPickupReadyQueue();
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
return this.scheduling.listLoadableWagons();
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/schedule')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
|
||||
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.scheduling.getBookingSchedule(bookingId);
|
||||
}
|
||||
|
||||
@Post('receive')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||
return this.inventoryService.receive(dto);
|
||||
}
|
||||
|
||||
@Post('reserve')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||
reserve(@Body() dto: ReserveInventoryDto) {
|
||||
return this.inventoryService.reserve(dto);
|
||||
}
|
||||
|
||||
@Get(':id/movements')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Inventory movement history' })
|
||||
movements(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findMovements(id);
|
||||
}
|
||||
|
||||
@Get(':id/activity')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Inventory activity log' })
|
||||
activity(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findActivity(id);
|
||||
}
|
||||
|
||||
@Get(':id/loadings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Loading records for an inventory item' })
|
||||
loadings(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findLoadingsByInventory(id);
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
|
||||
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
|
||||
return this.inventoryService.move(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForLoading(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
|
||||
return this.inventoryService.load(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-pickup')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
||||
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForPickup(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/release')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.release)
|
||||
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
|
||||
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
|
||||
return this.inventoryService.release(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/release-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
|
||||
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
|
||||
@@ -309,6 +360,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('customer-truck-exit-paper/:assignmentId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
|
||||
async truckExitPaper(
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@@ -322,6 +374,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get(':id/grn-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocument(id);
|
||||
@@ -342,12 +395,17 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/approve-delivery')
|
||||
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
|
||||
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
|
||||
approveDeliveryForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ApproveDeliveryDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
||||
return this.inventoryService.approveDeliveryForBooking(
|
||||
bookingId,
|
||||
req.user?.id ?? req.user?.sub,
|
||||
dto.signerName,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handovers')
|
||||
@@ -362,6 +420,26 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/grn-document')
|
||||
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
|
||||
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/release-document')
|
||||
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
|
||||
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
@@ -385,12 +463,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
return this.inventoryService.deliver(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/dispatch')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.dispatch(id, performedBy);
|
||||
|
||||
@@ -397,6 +397,45 @@ export class WarehouseInventoryService {
|
||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||
*/
|
||||
/**
|
||||
* At-a-glance warehouse ops counters for the KPI strip:
|
||||
* - receivedToday: items received today
|
||||
* - pendingInspection: RECEIVED items not yet inspected
|
||||
* - trucksOnSite: customer trucks arrived but not departed
|
||||
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
||||
*/
|
||||
async opsStats(): Promise<{
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}> {
|
||||
const [row]: Array<{
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL
|
||||
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||||
);
|
||||
return {
|
||||
receivedToday: row?.receivedToday ?? 0,
|
||||
pendingInspection: row?.pendingInspection ?? 0,
|
||||
trucksOnSite: row?.trucksOnSite ?? 0,
|
||||
itemsAging: row?.itemsAging ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
||||
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
||||
@@ -454,14 +493,17 @@ export class WarehouseInventoryService {
|
||||
|
||||
return rows.map((r) => {
|
||||
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
|
||||
// Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
|
||||
// used weight to tonnes before comparing so weight occupancy is correct.
|
||||
const usedWeightTons = r.usedWeight / 1000;
|
||||
const byWeight =
|
||||
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
|
||||
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
|
||||
const byItems =
|
||||
r.capacityContainers && r.capacityContainers > 0
|
||||
? (r.usedItems / r.capacityContainers) * 100
|
||||
: null;
|
||||
// Prefer container-count occupancy (unit-consistent). Weight capacity is
|
||||
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
|
||||
// Container zones use item-count occupancy; bulk zones (no container cap)
|
||||
// fall back to the now unit-correct weight occupancy.
|
||||
const pct = byItems ?? byWeight;
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -3132,16 +3174,20 @@ export class WarehouseInventoryService {
|
||||
async approveDeliveryForBooking(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
signerName?: string,
|
||||
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Authentication is required to approve delivery');
|
||||
}
|
||||
|
||||
const signature = await this.signatures.getForUser(userId);
|
||||
if (!signature?.signatureImageUrl) {
|
||||
throw new BadRequestException('Please save your signature before approving delivery');
|
||||
const name = signerName?.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Please enter your full name to approve delivery');
|
||||
}
|
||||
|
||||
// A saved signature is applied when available; otherwise the typed full name
|
||||
// is the record of who approved (self-haul customers may have no signature).
|
||||
const signature = await this.signatures.getForUser(userId).catch(() => null);
|
||||
|
||||
const [item]: Array<{
|
||||
id: string;
|
||||
warehouseId: string | null;
|
||||
@@ -3176,8 +3222,8 @@ export class WarehouseInventoryService {
|
||||
const approvedAt = new Date();
|
||||
const approval = {
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
signatureImageUrl: signature.signatureImageUrl,
|
||||
signerDisplayName: name,
|
||||
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
||||
userId,
|
||||
};
|
||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||
@@ -3192,8 +3238,8 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: item.id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Customer approved delivery as ${signature.signerDisplayName}`,
|
||||
performedBy: signature.signerDisplayName,
|
||||
description: `Customer approved delivery as ${name}`,
|
||||
performedBy: name,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
@@ -3201,13 +3247,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||
// the truck leaves). Kept alongside the legacy approval note.
|
||||
await this.handover.signForBooking(bookingId, userId);
|
||||
await this.handover.signForBooking(bookingId, userId, name);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
inventoryId: item.id,
|
||||
approvedAt: approval.approvedAt,
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
signerDisplayName: name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3226,6 +3272,31 @@ export class WarehouseInventoryService {
|
||||
return this.handoverDocument(inv.id);
|
||||
}
|
||||
|
||||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!inv) {
|
||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||
}
|
||||
return inv.id;
|
||||
}
|
||||
|
||||
/** Booking-scoped GRN document (customer portal). */
|
||||
async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
/** Booking-scoped gate-clearance / release document (customer portal). */
|
||||
async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
@@ -13,12 +15,14 @@ export class WarehouseInvoiceController {
|
||||
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
|
||||
|
||||
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
||||
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
|
||||
return this.invoiceService.generateForInventory(id, dto);
|
||||
}
|
||||
|
||||
@Post('last-mile/:id/generate-truck-detention-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
|
||||
generateTruckDetention(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -28,6 +32,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-invoices')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
|
||||
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
||||
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.listForInventory(id);
|
||||
@@ -40,6 +45,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
|
||||
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
|
||||
findAll(
|
||||
@Query('status') status?: string,
|
||||
@@ -86,12 +92,14 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-invoices/:id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.cancel(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-invoices/:id/pay')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay)
|
||||
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
|
||||
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||
return this.invoiceService.pay(id, dto);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-loadings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-loadings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
export class WarehouseLoadingsController {
|
||||
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
AllocationPreviewDto,
|
||||
CreateAllocationRuleDto,
|
||||
UpdateAllocationRuleDto,
|
||||
} from './dto/allocation-rule.dto';
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
@@ -21,18 +24,21 @@ export class WarehouseRulesController {
|
||||
|
||||
// ── Allocation rules ───────────────────────────────────────────────────────
|
||||
@Get('warehouse-allocation-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
|
||||
@ApiOperation({ summary: 'List warehouse allocation rules' })
|
||||
listAllocationRules() {
|
||||
return this.allocationService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create)
|
||||
@ApiOperation({ summary: 'Create a warehouse allocation rule' })
|
||||
createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
|
||||
return this.allocationService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-allocation-rules/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update)
|
||||
@ApiOperation({ summary: 'Update a warehouse allocation rule' })
|
||||
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
|
||||
return this.allocationService.updateRule(id, dto);
|
||||
@@ -40,12 +46,14 @@ export class WarehouseRulesController {
|
||||
|
||||
@Delete('warehouse-allocation-rules/:id')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete)
|
||||
@ApiOperation({ summary: 'Delete a warehouse allocation rule' })
|
||||
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.allocationService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation/preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
|
||||
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
|
||||
previewAllocation(@Body() dto: AllocationPreviewDto) {
|
||||
return this.allocationService.resolveLocation(dto);
|
||||
@@ -53,18 +61,21 @@ export class WarehouseRulesController {
|
||||
|
||||
// ── Fee rules ────────────────────────────────────────────────────────────────
|
||||
@Get('warehouse-fee-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'List storage / demurrage fee rules' })
|
||||
listFeeRules() {
|
||||
return this.feeService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create)
|
||||
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
|
||||
createFeeRule(@Body() dto: CreateFeeRuleDto) {
|
||||
return this.feeService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-rules/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Update a fee rule' })
|
||||
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
|
||||
return this.feeService.updateRule(id, dto);
|
||||
@@ -72,12 +83,42 @@ export class WarehouseRulesController {
|
||||
|
||||
@Delete('warehouse-fee-rules/:id')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete)
|
||||
@ApiOperation({ summary: 'Delete a fee rule' })
|
||||
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.feeService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Get('warehouse-fees/accrual-dashboard')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
|
||||
accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
|
||||
return this.feeService.accrualDashboard(billingCurrency);
|
||||
}
|
||||
|
||||
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
|
||||
acknowledgeAccrual(
|
||||
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||
@Body() dto: AcknowledgeAccrualDto,
|
||||
) {
|
||||
return this.feeService.acknowledgeAccrual(inventoryId, {
|
||||
snoozeDays: dto.snoozeDays,
|
||||
note: dto.note,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' })
|
||||
unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
|
||||
return this.feeService.unacknowledgeAccrual(inventoryId);
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||
feePreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -87,6 +128,7 @@ export class WarehouseRulesController {
|
||||
}
|
||||
|
||||
@Get('last-mile/:id/truck-detention-preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
|
||||
truckDetentionPreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
@@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
@@ -28,18 +31,21 @@ export class WarehouseYardsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.update)
|
||||
@ApiOperation({ summary: 'Update warehouse yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
|
||||
return this.yardsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':yardId/zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
@ApiOperation({ summary: 'List zones within a yard' })
|
||||
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
|
||||
return this.zonesService.findByYard(yardId);
|
||||
}
|
||||
|
||||
@Post(':yardId/zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
|
||||
@ApiOperation({ summary: 'Create a zone within a yard' })
|
||||
createZone(
|
||||
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-zones')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@@ -23,6 +26,7 @@ export class WarehouseZonesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
|
||||
@ApiOperation({ summary: 'Update warehouse zone' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||
return this.zonesService.update(id, dto);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
@@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
@ApiTags('warehouses')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouses')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.view)
|
||||
export class WarehousesController {
|
||||
constructor(
|
||||
private readonly warehousesService: WarehousesService,
|
||||
@@ -26,12 +29,14 @@ export class WarehousesController {
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
|
||||
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
|
||||
dashboard() {
|
||||
return this.dashboardService.getDashboard();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.create)
|
||||
@ApiOperation({ summary: 'Create warehouse' })
|
||||
create(@Body() dto: CreateWarehouseDto) {
|
||||
return this.warehousesService.create(dto);
|
||||
@@ -44,18 +49,21 @@ export class WarehousesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.update)
|
||||
@ApiOperation({ summary: 'Update warehouse' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
|
||||
return this.warehousesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
|
||||
return this.yardsService.findByWarehouse(warehouseId);
|
||||
}
|
||||
|
||||
@Post(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.create)
|
||||
@ApiOperation({ summary: 'Create a yard within a warehouse' })
|
||||
createYard(
|
||||
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
|
||||
|
||||
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
|
||||
|
||||
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
|
||||
CHARGING: { color: 'red', label: 'Charging' },
|
||||
WARNING: { color: 'orange', label: 'Free days ending' },
|
||||
OK: { color: 'teal', label: 'Within free days' },
|
||||
};
|
||||
|
||||
function money(amount: number, currency: string): string {
|
||||
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
function freeDaysLabel(row: AccrualDashboardRow): string {
|
||||
if (row.charging) return 'charging now';
|
||||
if (row.freeDaysLeft == null) return '—';
|
||||
return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
|
||||
* sorted so items already charging (or about to) surface first. Read-only.
|
||||
*/
|
||||
export function AccrualDashboard() {
|
||||
const { data: rows = [], isLoading } = useAccrualDashboard();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refresh = () =>
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
|
||||
|
||||
const ack = useMutation({
|
||||
mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
|
||||
warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
|
||||
onSuccess: (_r, v) => {
|
||||
toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
|
||||
});
|
||||
const unack = useMutation({
|
||||
mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Acknowledgement removed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const currency = rows[0]?.currency ?? 'USD';
|
||||
return {
|
||||
currency,
|
||||
charging: rows.filter((r) => r.alert === 'CHARGING').length,
|
||||
atRisk: rows.filter((r) => r.alert === 'WARNING').length,
|
||||
totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
|
||||
<StatCard
|
||||
icon={<DollarSign size={18} />}
|
||||
label="Accruing now"
|
||||
value={money(summary.totalAccruing, summary.currency)}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="Charging"
|
||||
value={summary.charging}
|
||||
color={summary.charging > 0 ? 'red' : 'gray'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock size={18} />}
|
||||
label="Free days ending (≤2d)"
|
||||
value={summary.atRisk}
|
||||
color={summary.atRisk > 0 ? 'orange' : 'gray'}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
{rows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No in-warehouse items are accruing fees.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Accrued</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Alert</Table.Th>
|
||||
<Table.Th ta="right" />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const meta = ALERT_META[row.alert];
|
||||
const busy = ack.isPending || unack.isPending;
|
||||
return (
|
||||
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{row.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
|
||||
{money(row.accruedAmount, row.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={row.charging ? 'red' : undefined}>
|
||||
{freeDaysLabel(row)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.acknowledged ? (
|
||||
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
|
||||
Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" loading={busy} aria-label="Accrual actions">
|
||||
<MoreVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{row.acknowledged ? (
|
||||
<Menu.Item
|
||||
leftSection={<Bell size={14} />}
|
||||
onClick={() => unack.mutate(row.inventoryId)}
|
||||
>
|
||||
Un-acknowledge
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<Check size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId })}
|
||||
>
|
||||
Mark reviewed
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
|
||||
>
|
||||
Snooze 3 days
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
|
||||
>
|
||||
Snooze 7 days
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color={color} variant="light" size={40} radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={800} fz={20} lh={1.1} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import { openPdfBlob, saveBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
// One-click bundle: download every available document for the item (GRN +
|
||||
// gate clearance / release order + handover). Best-effort — docs that aren't
|
||||
// generatable yet for this item are skipped.
|
||||
const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const ref = item.booking?.reference ?? item.bookingId ?? item.id;
|
||||
const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
|
||||
{ name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
|
||||
{ name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
|
||||
{ name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
|
||||
];
|
||||
let saved = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const response = await job.fn();
|
||||
saveBlob(response.data, job.name);
|
||||
saved += 1;
|
||||
} catch {
|
||||
// Document not available for this item yet — skip it.
|
||||
}
|
||||
}
|
||||
setBusyId(null);
|
||||
if (saved === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'No documents available',
|
||||
description: 'This item has no GRN, gate clearance or handover document yet.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
|
||||
description: `Bundle for ${ref} (available documents only).`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||
const reference = item.booking?.reference;
|
||||
if (!reference) {
|
||||
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onHandoverDocument={openHandoverDocument}
|
||||
onDownloadBundle={downloadDocumentBundle}
|
||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onDownloadBundle?: (item: WarehouseInventoryItem) => void;
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onHandoverDocument,
|
||||
onDownloadBundle,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadBundle && item.grnNumber && (
|
||||
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
|
||||
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
|
||||
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
|
||||
|
||||
/**
|
||||
* At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
|
||||
* on-site, items aging). Drop-in for any warehouse ops page header.
|
||||
*/
|
||||
export function WarehouseOpsKpiStrip() {
|
||||
const { data, isLoading } = useWarehouseOpsStats();
|
||||
|
||||
return (
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Received today",
|
||||
value: data?.receivedToday ?? 0,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
value: data?.pendingInspection ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Trucks on-site",
|
||||
value: data?.trucksOnSite ?? 0,
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Items aging (>7d)",
|
||||
value: data?.itemsAging ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||
hint: "In warehouse over 7 days",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -30,3 +30,5 @@ export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
|
||||
@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
|
||||
URL.revokeObjectURL(url);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Force a browser download of a blob under the given filename (no preview tab). */
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Delay revoke so the download has time to start (esp. for rapid multi-saves).
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
|
||||
@@ -486,6 +486,7 @@ export const URL_CONSTANTS = {
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||
yardId
|
||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||
@@ -557,6 +558,9 @@ export const URL_CONSTANTS = {
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
||||
ACCRUAL_ACK: (inventoryId: string) =>
|
||||
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
|
||||
@@ -144,6 +144,22 @@ export function useZoneOccupancy(yardId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||
export function useWarehouseOpsStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'ops-stats'],
|
||||
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -291,6 +292,8 @@ export default function ArrivalQueuePage() {
|
||||
breadcrumbs={[{ label: 'Arrival queue' }]}
|
||||
/>
|
||||
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ActivityTimeline,
|
||||
InventoryMovementHistoryTable,
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -292,6 +293,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
|
||||
/>
|
||||
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
|
||||
@@ -74,6 +75,8 @@ export default function LoadingQueuePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card>
|
||||
<Tabs defaultValue="ready">
|
||||
<Tabs.List>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { AccrualDashboard } from '@/components/warehouses';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
@@ -122,6 +123,13 @@ export default function WarehouseInvoicesPage() {
|
||||
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text fw={700} size="sm" tt="uppercase" c="dimmed">
|
||||
Accruing now
|
||||
</Text>
|
||||
<AccrualDashboard />
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
|
||||
@@ -5,6 +5,8 @@ import { api as apiClient } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
ZoneOccupancy,
|
||||
WarehouseOpsStats,
|
||||
AccrualDashboardRow,
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult,
|
||||
AllocationRule,
|
||||
@@ -370,6 +372,8 @@ export const warehouseService = {
|
||||
apiClient.get<ZoneOccupancy[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId),
|
||||
),
|
||||
opsStats: () =>
|
||||
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
@@ -422,6 +426,14 @@ export const warehouseService = {
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
|
||||
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
|
||||
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
|
||||
unacknowledgeAccrual: (inventoryId: string) =>
|
||||
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId)),
|
||||
|
||||
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
|
||||
listInvoices: (filter?: WarehouseInvoiceFilter) =>
|
||||
|
||||
@@ -1107,3 +1107,40 @@ export interface ZoneOccupancy {
|
||||
/** 0–100+, container-count based (weight is a rough fallback). Null if no capacity set. */
|
||||
occupancyPct: number | null;
|
||||
}
|
||||
|
||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||
export interface WarehouseOpsStats {
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}
|
||||
|
||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||
|
||||
/** One item's live fee accrual for the accrual dashboard. */
|
||||
export interface AccrualDashboardRow {
|
||||
inventoryId: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
currency: string;
|
||||
accruedAmount: number;
|
||||
freeDaysLeft: number | null;
|
||||
charging: boolean;
|
||||
alert: AccrualAlert;
|
||||
acknowledged: boolean;
|
||||
snoozeUntil: string | null;
|
||||
breakdown: Array<{
|
||||
type: string;
|
||||
amount: number;
|
||||
freeDays: number;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModa
|
||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { fmtDate } from "../utils";
|
||||
import { IconSquare } from "./Documents";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
@@ -361,6 +365,39 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
|
||||
const company = contract?.company;
|
||||
|
||||
// One-click warehouse-document bundle: GRN + gate clearance + handover.
|
||||
const [bundleBusy, setBundleBusy] = useState(false);
|
||||
const downloadWarehouseDocuments = async () => {
|
||||
setBundleBusy(true);
|
||||
const ref = booking.reference ?? booking.id;
|
||||
const jobs: Array<{ name: string; fn: () => Promise<Blob> }> = [
|
||||
{ name: `GRN-${ref}.pdf`, fn: () => bookingsService.downloadBookingGrnDocument(booking.id) },
|
||||
{
|
||||
name: `gate-clearance-${ref}.pdf`,
|
||||
fn: () => bookingsService.downloadBookingReleaseDocument(booking.id),
|
||||
},
|
||||
{
|
||||
name: `handover-${ref}.pdf`,
|
||||
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
|
||||
},
|
||||
];
|
||||
let saved = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
saveBlob(await job.fn(), job.name);
|
||||
saved += 1;
|
||||
} catch {
|
||||
// Document not available for this booking yet — skip it.
|
||||
}
|
||||
}
|
||||
setBundleBusy(false);
|
||||
if (saved === 0) {
|
||||
toast.error("No warehouse documents are available for this booking yet.");
|
||||
} else {
|
||||
toast.success(`Downloaded ${saved} document${saved !== 1 ? "s" : ""}.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* ── 1. Clearance documents ──────────────────────────────────────── */}
|
||||
@@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
|
||||
<SectionCard>
|
||||
<CardTitle>Warehouse documents</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||
Goods Received Note, gate clearance / release order and handover — download all
|
||||
available documents for this booking in one click.
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
loading={bundleBusy}
|
||||
onClick={downloadWarehouseDocuments}
|
||||
>
|
||||
Download documents
|
||||
</Button>
|
||||
</SectionCard>
|
||||
|
||||
{!hasContract && otherBookingFiles.length === 0 && (
|
||||
<SectionCard>
|
||||
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -47,6 +47,7 @@ export function ApproveDeliveryModal({
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
|
||||
const {
|
||||
data: docBlob,
|
||||
@@ -122,8 +123,9 @@ export function ApproveDeliveryModal({
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||
<Text size="sm">
|
||||
Review the handover document below. Approving applies your saved signature
|
||||
and confirms you received the goods.
|
||||
Review the handover document below, then type your full name to sign and
|
||||
confirm you received the goods. Your saved signature is applied automatically
|
||||
if you have one.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
@@ -151,6 +153,15 @@ export function ApproveDeliveryModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Your full name"
|
||||
placeholder="e.g. Abebe Kebede"
|
||||
required
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.currentTarget.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
@@ -159,8 +170,8 @@ export function ApproveDeliveryModal({
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={busy}
|
||||
disabled={isLoading || isError}
|
||||
onClick={() => approve.mutate({ id: bookingId })}
|
||||
disabled={isLoading || isError || !signerName.trim()}
|
||||
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
|
||||
>
|
||||
Approve & sign delivery
|
||||
</Button>
|
||||
|
||||
@@ -387,10 +387,10 @@ export const api = {
|
||||
({ orderId }) => bookingsService.checkPayment(orderId),
|
||||
),
|
||||
|
||||
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
|
||||
approveDelivery: endpoint<{ id: string; signerName: string }, ApproveDeliveryResponse>(
|
||||
"bookings",
|
||||
"approveDelivery",
|
||||
({ id }) => bookingsService.approveDelivery(id),
|
||||
({ id, signerName }) => bookingsService.approveDelivery(id, signerName),
|
||||
),
|
||||
|
||||
getBookableSchedules: endpoint<
|
||||
|
||||
@@ -209,6 +209,20 @@ export const bookingsService = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingReleaseDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/release-document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
@@ -359,9 +373,13 @@ export const bookingsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
|
||||
approveDelivery: async (
|
||||
id: string,
|
||||
signerName: string,
|
||||
): Promise<ApproveDeliveryResponse> => {
|
||||
const { data } = await client.post(
|
||||
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
|
||||
{ signerName },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
# Build: DOCKER_BUILDKIT=1 docker compose build
|
||||
# Run: docker compose up -d
|
||||
services:
|
||||
# Message broker for payment event delivery (payment-api -> passenger/freight).
|
||||
# Management UI: http://localhost:15672 (login edr / edr_secret). vhost: payment.
|
||||
# In deployed envs this is a shared/managed RabbitMQ; only PAYMENT_RABBITMQ_URL changes.
|
||||
freight-api:
|
||||
build:
|
||||
context: .
|
||||
@@ -21,9 +18,6 @@ services:
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
|
||||
# Standalone GT06 GPS tracker ingester (@edr/gps-tracker). Raw TCP only, no
|
||||
# HTTP. Writes freight.gps_devices / freight.gps_positions in the shared
|
||||
# freight DB; the /gps REST API stays in freight-api. Never runs migrations.
|
||||
gps-tracker:
|
||||
build:
|
||||
context: .
|
||||
@@ -33,14 +27,12 @@ services:
|
||||
depends_on:
|
||||
- freight-api
|
||||
ports:
|
||||
# Raw TCP — reachable by tracker SIMs. Not HTTP; no L7 proxy can host-route it.
|
||||
- "${GT06_TCP_PORT:-5023}:5023"
|
||||
environment:
|
||||
GT06_TCP_PORT: "5023"
|
||||
GT06_TCP_HOST: "0.0.0.0"
|
||||
env_file:
|
||||
# Reuses the freight DB credentials (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME).
|
||||
- apps/edr-freight-api/.env
|
||||
- apps/edr-gps-tracker/.env
|
||||
restart: unless-stopped
|
||||
|
||||
passenger-api:
|
||||
@@ -54,17 +46,7 @@ services:
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
|
||||
freight-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -80,6 +62,7 @@ services:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
||||
|
||||
freight-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -95,6 +78,7 @@ services:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
||||
|
||||
passenger-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -110,17 +94,7 @@ services:
|
||||
env_file:
|
||||
- apps/edr-passenger-web/portal/.env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
passenger-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -136,17 +110,6 @@ services:
|
||||
env_file:
|
||||
- apps/edr-passenger-web/backoffice/.env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
payment-api:
|
||||
build:
|
||||
|
||||
Reference in New Issue
Block a user