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:
Hagernesh
2026-07-14 09:35:21 +00:00
parent d9fe79e160
commit a367306565
18 changed files with 362 additions and 33 deletions

View File

@@ -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
`);
}
}

View File

@@ -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`);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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,
},
);
}

View File

@@ -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],
);
}

View File

@@ -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')

View File

@@ -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,
};
}

View File

@@ -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(

View File

@@ -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>
);

View File

@@ -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: {

View File

@@ -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) =>

View File

@@ -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;

View File

@@ -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} />}>

View File

@@ -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 &amp; sign delivery
</Button>

View File

@@ -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<

View File

@@ -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;
},