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

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