diff --git a/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts new file mode 100644 index 000000000..4e4c5f5fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS signer_name varchar(160) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + DROP COLUMN IF EXISTS signer_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts new file mode 100644 index 000000000..5e13a6e3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts new file mode 100644 index 000000000..6b3697f03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts new file mode 100644 index 000000000..2ef499201 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index f5a730ea8..a0a7ad70f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 48ab3ac48..d50b27150 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -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 { + async signForBooking( + bookingId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { 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, + }, ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 720f30766..92aa36b85 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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 { 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 => { 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 { + 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 { + await this.dataSource.query( + `DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`, + [inventoryId], ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index f45abe507..055c9bac9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8bce58f01..1c1be66ec 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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, }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 1fecf9392..19788e027 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -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( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx index 2ac3fa2f9..5170b345d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx @@ -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 = { @@ -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() { Accrued Free days Alert + {rows.map((row) => { const meta = ALERT_META[row.alert]; + const busy = ack.isPending || unack.isPending; return ( - + {row.bookingReference ?? row.inventoryId.slice(0, 8)} @@ -120,9 +148,55 @@ export function AccrualDashboard() { - - {meta.label} - + {row.acknowledged ? ( + }> + Reviewed{row.snoozeUntil ? ' (snoozed)' : ''} + + ) : ( + + {meta.label} + + )} + + + + + + + + + + {row.acknowledged ? ( + } + onClick={() => unack.mutate(row.inventoryId)} + > + Un-acknowledge + + ) : ( + <> + } + onClick={() => ack.mutate({ id: row.inventoryId })} + > + Mark reviewed + + } + onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })} + > + Snooze 3 days + + } + onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })} + > + Snooze 7 days + + + )} + + ); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index bae6361fb..618d0ba30 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 0ab883ff8..febde7f85 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -430,6 +430,10 @@ export const warehouseService = { apiClient.get(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) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 7ef649bea..7a88b136e 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx index a24f34763..fe7f58e62 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx @@ -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 }> = [ + { 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 ( {/* ── 1. Clearance documents ──────────────────────────────────────── */} @@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { )} + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} + + Warehouse documents + + Goods Received Note, gate clearance / release order and handover — download all + available documents for this booking in one click. + + + + {!hasContract && otherBookingFiles.length === 0 && ( }> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx index a3723b650..90e95369f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx @@ -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(null); + const [signerName, setSignerName] = useState(""); const { data: docBlob, @@ -122,8 +123,9 @@ export function ApproveDeliveryModal({ }> - 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. @@ -151,6 +153,15 @@ export function ApproveDeliveryModal({ /> )} + setSignerName(e.currentTarget.value)} + disabled={busy} + /> + diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 6cd606b2e..6f4527c0b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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< diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 850081ea1..a60fe4275 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -373,9 +373,13 @@ export const bookingsService = { return data.data ?? data; }, - approveDelivery: async (id: string): Promise => { + approveDelivery: async ( + id: string, + signerName: string, + ): Promise => { const { data } = await client.post( `/api/warehouse-inventory/bookings/${id}/approve-delivery`, + { signerName }, ); return data.data ?? data; },