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