mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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 })
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface AccrualDashboardRow {
|
||||
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;
|
||||
@@ -116,7 +119,9 @@ export class WarehouseFeeService {
|
||||
@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');
|
||||
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`);
|
||||
|
||||
@@ -549,6 +554,14 @@ export class WarehouseFeeService {
|
||||
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(
|
||||
@@ -581,6 +594,10 @@ export class WarehouseFeeService {
|
||||
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,
|
||||
@@ -593,8 +610,43 @@ export class WarehouseFeeService {
|
||||
);
|
||||
|
||||
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) => 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 { 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';
|
||||
@@ -348,12 +349,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')
|
||||
|
||||
@@ -493,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,
|
||||
@@ -3171,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;
|
||||
@@ -3215,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);
|
||||
@@ -3231,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,
|
||||
);
|
||||
@@ -3240,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
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';
|
||||
|
||||
@@ -83,6 +84,25 @@ export class WarehouseRulesController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||
feePreview(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Badge, Card, Group, Loader, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||
import { AlertTriangle, Clock, DollarSign } from 'lucide-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 }> = {
|
||||
@@ -27,6 +30,29 @@ function freeDaysLabel(row: AccrualDashboardRow): string {
|
||||
*/
|
||||
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';
|
||||
@@ -86,13 +112,15 @@ export function AccrualDashboard() {
|
||||
<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}>
|
||||
<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)}
|
||||
@@ -120,9 +148,55 @@ export function AccrualDashboard() {
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -559,6 +559,8 @@ export const URL_CONSTANTS = {
|
||||
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: {
|
||||
|
||||
@@ -430,6 +430,10 @@ export const warehouseService = {
|
||||
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) =>
|
||||
|
||||
@@ -1134,6 +1134,8 @@ export interface AccrualDashboardRow {
|
||||
freeDaysLeft: number | null;
|
||||
charging: boolean;
|
||||
alert: AccrualAlert;
|
||||
acknowledged: boolean;
|
||||
snoozeUntil: string | null;
|
||||
breakdown: Array<{
|
||||
type: string;
|
||||
amount: 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<
|
||||
|
||||
@@ -373,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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user