mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
feat(warehouse): accrual acknowledge/snooze + zone weight fix, handover signer, portal delivery
Accrual dashboard: - warehouse_accrual_acks table (migration 2140) + acknowledge/unacknowledge endpoints; dashboard rows carry acknowledged/snoozeUntil, acked items sink and are skipped by the alert cron. Row menu: mark reviewed / snooze 3d / 7d / un-acknowledge; acked rows dimmed with a "Reviewed" badge. - Fix zone weight occupancy: normalise inventory kg vs zone-capacity tonnes. Handover (rode along, shared files): - Require signer full name on delivery handover (signature optional); migration 2130 adds signer_name. Portal delivery/docs (rode along, shared files): - Approve-delivery name capture, booking-scoped GRN/release docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 })
|
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
||||||
signedAt?: Date | null;
|
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 })
|
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
|
||||||
signedByUserId?: string | null;
|
signedByUserId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -183,12 +183,20 @@ export class HandoverService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
/** 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
|
await this.dataSource
|
||||||
.getRepository(BookingHandover)
|
.getRepository(BookingHandover)
|
||||||
.update(
|
.update(
|
||||||
{ bookingId, signedAt: IsNull() },
|
{ bookingId, signedAt: IsNull() },
|
||||||
{ signedAt: new Date(), signedByUserId: userId ?? null },
|
{
|
||||||
|
signedAt: new Date(),
|
||||||
|
signedByUserId: userId ?? null,
|
||||||
|
signerName: signerName?.trim() || null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export interface AccrualDashboardRow {
|
|||||||
freeDaysLeft: number | null;
|
freeDaysLeft: number | null;
|
||||||
charging: boolean;
|
charging: boolean;
|
||||||
alert: AccrualAlert;
|
alert: AccrualAlert;
|
||||||
|
/** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
|
||||||
|
acknowledged: boolean;
|
||||||
|
snoozeUntil: string | null;
|
||||||
breakdown: Array<{
|
breakdown: Array<{
|
||||||
type: FeeRuleType;
|
type: FeeRuleType;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -116,7 +119,9 @@ export class WarehouseFeeService {
|
|||||||
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
|
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
|
||||||
async sendAccrualAlerts(): Promise<void> {
|
async sendAccrualAlerts(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const alerts = (await this.accrualDashboard()).filter((r) => r.alert !== 'OK');
|
const alerts = (await this.accrualDashboard()).filter(
|
||||||
|
(r) => r.alert !== 'OK' && !r.acknowledged,
|
||||||
|
);
|
||||||
if (!alerts.length) return;
|
if (!alerts.length) return;
|
||||||
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
|
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
|
||||||
|
|
||||||
@@ -549,6 +554,14 @@ export class WarehouseFeeService {
|
|||||||
ORDER BY inv.created_at ASC`,
|
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(
|
const rows = await Promise.all(
|
||||||
items.map(async (it): Promise<AccrualDashboardRow> => {
|
items.map(async (it): Promise<AccrualDashboardRow> => {
|
||||||
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
|
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
|
||||||
@@ -581,6 +594,10 @@ export class WarehouseFeeService {
|
|||||||
freeDaysLeft,
|
freeDaysLeft,
|
||||||
charging,
|
charging,
|
||||||
alert,
|
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) => ({
|
breakdown: previews.map((p) => ({
|
||||||
type: p.ruleType,
|
type: p.ruleType,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
@@ -593,8 +610,43 @@ export class WarehouseFeeService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
|
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(
|
return rows.sort(
|
||||||
(a, b) => rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount,
|
(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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
|
|||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
|
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
@@ -348,12 +349,17 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/approve-delivery')
|
@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(
|
approveDeliveryForBooking(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: ApproveDeliveryDto,
|
||||||
@Request() req: { user?: { id?: string; sub?: string } },
|
@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')
|
@Get('bookings/:bookingId/handovers')
|
||||||
|
|||||||
@@ -493,14 +493,17 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
return rows.map((r) => {
|
return rows.map((r) => {
|
||||||
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
|
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 =
|
const byWeight =
|
||||||
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
|
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
|
||||||
const byItems =
|
const byItems =
|
||||||
r.capacityContainers && r.capacityContainers > 0
|
r.capacityContainers && r.capacityContainers > 0
|
||||||
? (r.usedItems / r.capacityContainers) * 100
|
? (r.usedItems / r.capacityContainers) * 100
|
||||||
: null;
|
: null;
|
||||||
// Prefer container-count occupancy (unit-consistent). Weight capacity is
|
// Container zones use item-count occupancy; bulk zones (no container cap)
|
||||||
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
|
// fall back to the now unit-correct weight occupancy.
|
||||||
const pct = byItems ?? byWeight;
|
const pct = byItems ?? byWeight;
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -3171,16 +3174,20 @@ export class WarehouseInventoryService {
|
|||||||
async approveDeliveryForBooking(
|
async approveDeliveryForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
|
signerName?: string,
|
||||||
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException('Authentication is required to approve delivery');
|
throw new BadRequestException('Authentication is required to approve delivery');
|
||||||
}
|
}
|
||||||
|
const name = signerName?.trim();
|
||||||
const signature = await this.signatures.getForUser(userId);
|
if (!name) {
|
||||||
if (!signature?.signatureImageUrl) {
|
throw new BadRequestException('Please enter your full name to approve delivery');
|
||||||
throw new BadRequestException('Please save your signature before approving 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<{
|
const [item]: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
warehouseId: string | null;
|
warehouseId: string | null;
|
||||||
@@ -3215,8 +3222,8 @@ export class WarehouseInventoryService {
|
|||||||
const approvedAt = new Date();
|
const approvedAt = new Date();
|
||||||
const approval = {
|
const approval = {
|
||||||
approvedAt: approvedAt.toISOString(),
|
approvedAt: approvedAt.toISOString(),
|
||||||
signerDisplayName: signature.signerDisplayName,
|
signerDisplayName: name,
|
||||||
signatureImageUrl: signature.signatureImageUrl,
|
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||||
@@ -3231,8 +3238,8 @@ export class WarehouseInventoryService {
|
|||||||
activityType: 'INVENTORY_RELEASED',
|
activityType: 'INVENTORY_RELEASED',
|
||||||
inventoryId: item.id,
|
inventoryId: item.id,
|
||||||
warehouseId: item.warehouseId,
|
warehouseId: item.warehouseId,
|
||||||
description: `Customer approved delivery as ${signature.signerDisplayName}`,
|
description: `Customer approved delivery as ${name}`,
|
||||||
performedBy: signature.signerDisplayName,
|
performedBy: name,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
@@ -3240,13 +3247,13 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
// Sign the structured handover record(s) for this booking (self-haul: before
|
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||||
// the truck leaves). Kept alongside the legacy approval note.
|
// the truck leaves). Kept alongside the legacy approval note.
|
||||||
await this.handover.signForBooking(bookingId, userId);
|
await this.handover.signForBooking(bookingId, userId, name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
inventoryId: item.id,
|
inventoryId: item.id,
|
||||||
approvedAt: approval.approvedAt,
|
approvedAt: approval.approvedAt,
|
||||||
signerDisplayName: signature.signerDisplayName,
|
signerDisplayName: name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
UpdateAllocationRuleDto,
|
UpdateAllocationRuleDto,
|
||||||
} from './dto/allocation-rule.dto';
|
} from './dto/allocation-rule.dto';
|
||||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||||
|
import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
|
||||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||||
|
|
||||||
@@ -83,6 +84,25 @@ export class WarehouseRulesController {
|
|||||||
return this.feeService.accrualDashboard(billingCurrency);
|
return this.feeService.accrualDashboard(billingCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||||
|
@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)
|
||||||
|
@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')
|
@Get('warehouse-inventory/:id/fee-preview')
|
||||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||||
feePreview(
|
feePreview(
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Badge, Card, Group, Loader, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||||
import { AlertTriangle, Clock, DollarSign } from 'lucide-react';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
|
||||||
|
|
||||||
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
|
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
|
||||||
|
|
||||||
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
|
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
|
||||||
@@ -27,6 +30,29 @@ function freeDaysLabel(row: AccrualDashboardRow): string {
|
|||||||
*/
|
*/
|
||||||
export function AccrualDashboard() {
|
export function AccrualDashboard() {
|
||||||
const { data: rows = [], isLoading } = useAccrualDashboard();
|
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 summary = useMemo(() => {
|
||||||
const currency = rows[0]?.currency ?? 'USD';
|
const currency = rows[0]?.currency ?? 'USD';
|
||||||
@@ -86,13 +112,15 @@ export function AccrualDashboard() {
|
|||||||
<Table.Th ta="right">Accrued</Table.Th>
|
<Table.Th ta="right">Accrued</Table.Th>
|
||||||
<Table.Th>Free days</Table.Th>
|
<Table.Th>Free days</Table.Th>
|
||||||
<Table.Th>Alert</Table.Th>
|
<Table.Th>Alert</Table.Th>
|
||||||
|
<Table.Th ta="right" />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const meta = ALERT_META[row.alert];
|
const meta = ALERT_META[row.alert];
|
||||||
|
const busy = ack.isPending || unack.isPending;
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={row.inventoryId}>
|
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
|
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
|
||||||
@@ -120,9 +148,55 @@ export function AccrualDashboard() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
|
{row.acknowledged ? (
|
||||||
{meta.label}
|
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
|
||||||
</Badge>
|
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.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -559,6 +559,8 @@ export const URL_CONSTANTS = {
|
|||||||
FEE_PREVIEW: (inventoryId: string) =>
|
FEE_PREVIEW: (inventoryId: string) =>
|
||||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||||
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
||||||
|
ACCRUAL_ACK: (inventoryId: string) =>
|
||||||
|
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVOICES: {
|
WAREHOUSE_INVOICES: {
|
||||||
|
|||||||
@@ -430,6 +430,10 @@ export const warehouseService = {
|
|||||||
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
||||||
params: cleanParams({ billingCurrency }),
|
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 ────────────────────────────────────────
|
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
|
||||||
listInvoices: (filter?: WarehouseInvoiceFilter) =>
|
listInvoices: (filter?: WarehouseInvoiceFilter) =>
|
||||||
|
|||||||
@@ -1134,6 +1134,8 @@ export interface AccrualDashboardRow {
|
|||||||
freeDaysLeft: number | null;
|
freeDaysLeft: number | null;
|
||||||
charging: boolean;
|
charging: boolean;
|
||||||
alert: AccrualAlert;
|
alert: AccrualAlert;
|
||||||
|
acknowledged: boolean;
|
||||||
|
snoozeUntil: string | null;
|
||||||
breakdown: Array<{
|
breakdown: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModa
|
|||||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
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 { fmtDate } from "../utils";
|
||||||
import { IconSquare } from "./Documents";
|
import { IconSquare } from "./Documents";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
@@ -361,6 +365,39 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
|
|
||||||
const company = contract?.company;
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* ── 1. Clearance documents ──────────────────────────────────────── */}
|
{/* ── 1. Clearance documents ──────────────────────────────────────── */}
|
||||||
@@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</SectionCard>
|
</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 && (
|
{!hasContract && otherBookingFiles.length === 0 && (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CheckCircle2, Info } from "lucide-react";
|
import { CheckCircle2, Info } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -47,6 +47,7 @@ export function ApproveDeliveryModal({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
const [signerName, setSignerName] = useState("");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: docBlob,
|
data: docBlob,
|
||||||
@@ -122,8 +123,9 @@ export function ApproveDeliveryModal({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Review the handover document below. Approving applies your saved signature
|
Review the handover document below, then type your full name to sign and
|
||||||
and confirms you received the goods.
|
confirm you received the goods. Your saved signature is applied automatically
|
||||||
|
if you have one.
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</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">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={onClose} disabled={busy}>
|
<Button variant="default" onClick={onClose} disabled={busy}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -159,8 +170,8 @@ export function ApproveDeliveryModal({
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
disabled={isLoading || isError}
|
disabled={isLoading || isError || !signerName.trim()}
|
||||||
onClick={() => approve.mutate({ id: bookingId })}
|
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
|
||||||
>
|
>
|
||||||
Approve & sign delivery
|
Approve & sign delivery
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -387,10 +387,10 @@ export const api = {
|
|||||||
({ orderId }) => bookingsService.checkPayment(orderId),
|
({ orderId }) => bookingsService.checkPayment(orderId),
|
||||||
),
|
),
|
||||||
|
|
||||||
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
|
approveDelivery: endpoint<{ id: string; signerName: string }, ApproveDeliveryResponse>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"approveDelivery",
|
"approveDelivery",
|
||||||
({ id }) => bookingsService.approveDelivery(id),
|
({ id, signerName }) => bookingsService.approveDelivery(id, signerName),
|
||||||
),
|
),
|
||||||
|
|
||||||
getBookableSchedules: endpoint<
|
getBookableSchedules: endpoint<
|
||||||
|
|||||||
@@ -373,9 +373,13 @@ export const bookingsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
|
approveDelivery: async (
|
||||||
|
id: string,
|
||||||
|
signerName: string,
|
||||||
|
): Promise<ApproveDeliveryResponse> => {
|
||||||
const { data } = await client.post(
|
const { data } = await client.post(
|
||||||
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
|
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
|
||||||
|
{ signerName },
|
||||||
);
|
);
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user