Release Order plus Storage fee

This commit is contained in:
hagiye
2026-06-24 15:23:50 +03:00
parent 8ee31789c6
commit c69dd3c238
15 changed files with 178 additions and 85 deletions

View File

@@ -13,6 +13,8 @@ interface ItemAttributes {
tradeDirection: string | null;
cargoTypeCode: string | null;
containerTypeCode: string | null;
inventoryQuantity: number;
bookingContainerCount: number;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -31,6 +33,8 @@ export interface FeePreview {
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
elapsedDays: number;
chargeableDays: number;
containerCount: number;
billableUnits: number;
amount: number;
}
@@ -67,6 +71,7 @@ export class WarehouseFeeService {
`SELECT inv.arrived_at AS "arrivedAt",
inv.gate_cleared_at AS "gateClearedAt",
inv.release_date AS "releaseDate",
inv.quantity AS "inventoryQuantity",
inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId",
inv.zone_id AS "zoneId",
@@ -74,7 +79,8 @@ export class WarehouseFeeService {
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode",
ctt.code AS "containerTypeCode"
ctt.code AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -82,6 +88,12 @@ export class WarehouseFeeService {
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
FROM freight.booking_container bc
WHERE bc.booking_id = inv.booking_id
AND bc.deleted_at IS NULL
) container_lines ON true
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
[inventoryId],
);
@@ -131,12 +143,18 @@ export class WarehouseFeeService {
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
const freeDays = rule?.freeDays ?? 0;
const ratePerDay = Number(rule?.ratePerDay ?? 0);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
: 0;
const chargeableDays = Math.max(0, elapsedDays - freeDays);
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
const billableUnits = chargeableDays * containerCount;
const amount = Math.round(billableUnits * ratePerDay * 100) / 100;
return {
ruleType,
@@ -150,6 +168,8 @@ export class WarehouseFeeService {
endIsOpen,
elapsedDays,
chargeableDays,
containerCount,
billableUnits,
amount,
};
}

View File

@@ -75,9 +75,9 @@ export class WarehouseInvoiceService {
feeType,
description:
p.ruleType === 'STORAGE_FEE'
? `Storage fee ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
quantity: p.chargeableDays,
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
currency: p.currency,

View File

@@ -1,7 +1,7 @@
import { useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, Calendar, Package, User } from "lucide-react";
import { Group, Text } from "@mantine/core";
import { Group } from "@mantine/core";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";

View File

@@ -160,16 +160,6 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
)}
</Group>
<Select
label="Facility / Port"
placeholder="Select facility"
clearable
searchable
data={facilityOptions}
value={form.stationId || null}
onChange={(value) => setForm((f) => ({ ...f, stationId: value ?? '' }))}
/>
<TextInput
label="Location name"
placeholder="Modjo, Oromia"

View File

@@ -5,15 +5,9 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import {
useFeePreview,
useGateClearance,
useGenerateInvoice,
useInvoicesForInventory,
} from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
@@ -75,6 +69,8 @@ function FeeCard({ fee }: { fee: FeePreview }) {
<Row label="Period" value={`${fmtDate(fee.startDate)}${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
</Stack>
)}
</Card>
@@ -134,8 +130,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
if (!inventoryId) return;
const pdfWindow = window.open('', '_blank');
try {
const response = await gateClear.mutateAsync(inventoryId) as { data?: { booking?: { reference?: string | null }; bookingId?: string | null } };
const releasedItem = response.data;
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);

View File

@@ -12,9 +12,6 @@ import {
} from '@mantine/core';
import { Upload } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import {

View File

@@ -6,13 +6,6 @@ import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import {
useBulkMarkInspected,
useDispatchInventory,
useMarkReadyForLoading,
useMarkReadyForPickup,
useStoreInventory,
} from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { DeliverInventoryModal } from './DeliverInventoryModal';
@@ -126,8 +119,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
const storeInventory = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
const response = (await storeMutation.mutateAsync(item.id)) as { data: WarehouseInventoryItem };
const stored = response.data;
const stored = await storeMutation.mutateAsync(item.id);
toast({
title: 'Inventory stored',
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),

View File

@@ -6,7 +6,6 @@ import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useReleaseInventory } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
@@ -36,11 +35,10 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
id: item.id,
payload: { reference: reference.trim() || undefined },
});
const releasedItem = released.data;
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
const filename = `release-${releasedItem.booking?.reference ?? releasedItem.bookingId ?? item.id}.pdf`;
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
title: 'Release exit paper issued',

View File

@@ -167,7 +167,7 @@ function CapacityRow({
</Text>
</Group>
<Text size="sm" fw={700}>
{formatCapacity(current, capacity)}
{formatCapacity(Number(current) || 0, capacity)}
</Text>
</Group>
<Progress value={percent} color={color} size="xs" radius="xl" bg="var(--mantine-color-gray-1)" />

View File

@@ -1,10 +1,20 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { ActionIcon, Group, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react';
import {
Building2,
Eye,
MapPin,
Package,
Pencil,
Scale,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { useQuery } from '@tanstack/react-query';
import { bookingTable } from '@/components/bookings/booking-ui.styles';
import { api } from '@/services/api';
import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
@@ -16,6 +26,43 @@ interface WarehouseTableProps {
onEdit: (warehouse: Warehouse) => void;
}
const HEADER = bookingTable.headerCell;
function CapacityCell({
current,
capacity,
icon,
}: {
current?: number | null;
capacity?: number | null;
icon: ReactNode;
}) {
const numericCurrent = Number(current) || 0;
const numericCapacity = Number(capacity) || 0;
const hasCapacity = numericCapacity > 0;
const ratio = hasCapacity ? Math.min(100, Math.max(0, (numericCurrent / numericCapacity) * 100)) : 0;
const isOverCapacity = hasCapacity && numericCurrent > numericCapacity;
return (
<div className="min-w-[8rem] space-y-2 py-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-background/70 text-muted-foreground">
{icon}
</span>
<span className="whitespace-nowrap">{formatCapacity(numericCurrent, capacity)}</span>
</div>
{hasCapacity ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
className={isOverCapacity ? 'h-full rounded-full bg-red-500' : 'h-full rounded-full bg-edr-green'}
style={{ width: `${ratio}%` }}
/>
</div>
) : null}
</div>
);
}
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
@@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
const columns: ColumnDef<Warehouse>[] = [
{
id: 'code',
header: 'Code',
header: () => <span className={HEADER}>Warehouse</span>,
cell: ({ row }) => (
<Text
fw={600}
size="sm"
c="edr-green.7"
style={{ cursor: 'pointer' }}
onClick={() => onView(row.original)}
>
{row.original.code}
</Text>
<div className="flex min-w-[11rem] items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<WarehouseIcon className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<button
type="button"
className="block max-w-full truncate text-left text-sm font-semibold text-edr-green transition-colors hover:text-edr-green/80"
onClick={(e) => {
e.stopPropagation();
onView(row.original);
}}
>
{row.original.code}
</button>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{row.original.name}
</p>
</div>
</div>
),
},
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{
id: 'facility',
header: 'Facility',
header: () => <span className={HEADER}>Facility</span>,
cell: ({ row }) => {
const name = row.original.stationId
? stationNameById.get(row.original.stationId)
: undefined;
return name ? (
<Text size="sm" fw={500}>
{name}
</Text>
<div className="flex min-w-[9rem] items-center gap-2 py-1 text-sm font-medium text-foreground">
<Building2 className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{name}</span>
</div>
) : (
<Text size="sm" c="dimmed">
-
</Text>
);
},
},
{
id: 'type',
header: 'Type',
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />,
header: () => <span className={HEADER}>Type</span>,
cell: ({ row }) => (
<div className="py-1">
<WarehouseTypeBadge type={row.original.type} />
</div>
),
},
{
id: 'location',
header: 'Location',
cell: ({ row }) => row.original.locationName ?? '—',
header: () => <span className={HEADER}>Location</span>,
cell: ({ row }) => (
<div className="flex min-w-[10rem] items-center gap-2 py-1 text-sm text-foreground">
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{row.original.locationName ?? '-'}</span>
</div>
),
},
{
id: 'weight',
header: 'Weight (cur / cap)',
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
header: () => <span className={HEADER}>Weight</span>,
cell: ({ row }) => (
<CapacityCell
current={row.original.currentWeight}
capacity={row.original.capacityWeight}
icon={<Scale className="size-3.5" />}
/>
),
},
{
id: 'containers',
header: 'Containers (cur / cap)',
cell: ({ row }) =>
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
header: () => <span className={HEADER}>Containers</span>,
cell: ({ row }) => (
<CapacityCell
current={row.original.currentContainers}
capacity={row.original.capacityContainers}
icon={<Package className="size-3.5" />}
/>
),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
header: () => <span className={HEADER}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<WarehouseStatusBadge status={row.original.status} />
</div>
),
},
{
id: 'actions',
@@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
status="success"
onRowClick={(warehouse) => onView(warehouse)}
emptyMessage="No warehouses found."
containerClassName="border-0 shadow-none"
containerClassName="border-0 bg-transparent shadow-none"
/>
);
}

View File

@@ -57,6 +57,8 @@ const inventoryStatusColor: Record<InventoryStatus, string> = {
RECEIVED: "yellow",
STORED: "blue",
RESERVED: "grape",
ARRIVED_AT_WAREHOUSE: "orange",
UNDER_INSPECTION: "yellow",
READY_FOR_LOADING: "cyan",
LOADED: "teal",
READY_FOR_PICKUP: "teal",

View File

@@ -29,7 +29,7 @@ interface Metric {
theme: string;
}
const ORANGE = 'rgb(245, 227, 203)';
const ORANGE = 'rgb(241, 147, 23)';
const GREEN = '#084b21';
const METRICS: Metric[] = [

View File

@@ -230,7 +230,10 @@ function AllocationRules() {
placeholder="e.g. Import containers to open yard"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, name: value }));
}}
/>
<NumberInput
label="Priority"
@@ -261,13 +264,19 @@ function AllocationRules() {
label="Cargo type code"
placeholder="e.g. COFFEE"
value={form.cargoTypeCode}
onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, cargoTypeCode: value }));
}}
/>
<TextInput
label="Container status"
placeholder="e.g. MAINTENANCE"
value={form.containerStatus}
onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, containerStatus: value }));
}}
/>
</Group>
<Group grow>
@@ -287,7 +296,10 @@ function AllocationRules() {
label="Storage type"
placeholder="e.g. OPEN_STACK"
value={form.storageType}
onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, storageType: value }));
}}
/>
</Group>
<Group justify="flex-end" mt="sm">
@@ -337,7 +349,6 @@ function FeeRules() {
freeDays: form.freeDays,
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
isActive: true,
} as never);
toast({ title: 'Fee rule created' });
setOpen(false);
@@ -417,7 +428,10 @@ function FeeRules() {
label="Name"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, name: value }));
}}
/>
<Select
label="Rule type"
@@ -453,7 +467,10 @@ function FeeRules() {
<TextInput
label="Cargo type code"
value={form.cargoTypeCode}
onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, cargoTypeCode: value }));
}}
/>
</Group>
<Group grow>

View File

@@ -48,6 +48,7 @@ import type {
Warehouse,
WarehouseActivityLog,
WarehouseDashboard,
WarehouseFacility,
WarehouseFilter,
WarehouseInventoryItem,
WarehouseLoading,
@@ -67,6 +68,8 @@ export const warehouseService = {
params: cleanParams(filter ?? {}),
}),
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getDashboardSummary: (_filter?: InventoryFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
create: (payload: SaveWarehousePayload) =>
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),

View File

@@ -151,13 +151,14 @@ export interface Facility {
isActive?: boolean;
}
export type WarehouseFacility = Facility;
export interface Warehouse {
id: string;
name: string;
code: string;
type: WarehouseType;
stationId: string | null;
facility?: WarehouseFacility | null;
facilityId: string | null;
facility?: Facility | null;
locationName: string | null;
@@ -202,12 +203,6 @@ export interface WarehouseInventoryItem {
releaseOrderReference: string | null;
deliveredAt: string | null;
notes: string | null;
booking?: {
id: string;
reference: string;
status: string;
paymentStatus: string;
} | null;
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
@@ -625,6 +620,8 @@ export interface FeePreview {
endIsOpen: boolean;
elapsedDays: number;
chargeableDays: number;
containerCount: number;
billableUnits: number;
amount: number;
}