Merge pull request #498 from Tria-plc/Doublehandling

Doublehandling
This commit is contained in:
Hagernesh Tadesse
2026-07-07 09:48:26 +03:00
committed by GitHub
13 changed files with 467 additions and 47 deletions

View File

@@ -28,6 +28,8 @@ interface FeePreviewModalProps {
const LABELS: Record<string, { label: string; color: string }> = {
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
STORAGE_FEE: { label: 'Storage', color: 'teal' },
DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' },
TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' },
};
function fmtDate(iso: string | null) {

View File

@@ -646,9 +646,15 @@ function TruckEntranceFields({
function LocationSelects({
value,
onChange,
allowedYardTypes,
allowedZoneTypes,
}: {
value: Location;
onChange: (next: Location) => void;
/** When non-empty, only yards of these types are offered (matched to freight). */
allowedYardTypes?: string[];
/** When non-empty, only zones of these types are offered. */
allowedZoneTypes?: string[];
}) {
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
@@ -674,15 +680,17 @@ function LocationSelects({
() =>
(yardsQuery.data ?? [])
.filter((y) => y.status === 'ACTIVE')
.filter((y) => !allowedYardTypes?.length || allowedYardTypes.includes((y as { type?: string }).type ?? ''))
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
[yardsQuery.data, allowedYardTypes],
);
const zoneOptions = useMemo(
() =>
(zonesQuery.data ?? [])
.filter((z) => z.status === 'ACTIVE')
.filter((z) => !allowedZoneTypes?.length || allowedZoneTypes.includes((z as { type?: string }).type ?? ''))
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
[zonesQuery.data, allowedZoneTypes],
);
return (
@@ -1760,6 +1768,29 @@ const importLocationTypesForFreight = (freightType: string | null | undefined) =
const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
/**
* Yard/zone types valid for the freight being received — used to filter the receive
* location pickers so the yard list matches the cargo. Container freight → container
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
* Union across the given freight types; empty input → no restriction (show all).
*/
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
const yardTypes = new Set<string>();
const zoneTypes = new Set<string>();
for (const freightType of freightTypes) {
const normalized = (freightType ?? '').toUpperCase();
if (!normalized) continue;
if (normalized === 'CONTAINER') {
yardTypes.add('CONTAINER_YARD');
zoneTypes.add('CONTAINER_ZONE');
} else {
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
}
}
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
};
const isImportUnloadPending = (item: ImportTrainItem) =>
!item.currentStatus || item.currentStatus === 'RECEIVED';
@@ -2888,6 +2919,24 @@ export function WarehouseFlowWorkbench({
);
const activeDirection = direction === 'BOTH' ? tab : direction;
// Match the yard/zone list to the freight being received (container → container
// yards, etc). Same query key as the export tab, so React Query dedupes it.
const { data: eligibleForLocation = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: activeDirection },
enabled: enabled && activeDirection === 'EXPORT',
}),
);
const { yardTypes: allowedYardTypes, zoneTypes: allowedZoneTypes } = useMemo(
() =>
yardZoneTypesForFreights(
eligibleForLocation
.filter((r) => r.direction === activeDirection)
.map((r) => r.freightType),
),
[eligibleForLocation, activeDirection],
);
useEffect(() => {
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
}, [enabled, direction]);
@@ -2895,7 +2944,12 @@ export function WarehouseFlowWorkbench({
return (
<Stack gap="md">
{activeDirection === 'EXPORT' && (
<LocationSelects value={location} onChange={setLocation} />
<LocationSelects
value={location}
onChange={setLocation}
allowedYardTypes={allowedYardTypes}
allowedZoneTypes={allowedZoneTypes}
/>
)}
{direction === 'BOTH' ? (

View File

@@ -32,7 +32,21 @@ import {
useFeeRules,
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
import {
FEE_RULE_BASES,
FEE_RULE_BASIS_LABELS,
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
type FeeRuleBasis,
type FeeRuleType,
} from '@/types/warehouse';
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
STORAGE_FEE: 'teal',
DEMURRAGE_FEE: 'orange',
DOUBLE_HANDLING_FEE: 'grape',
TRUCK_DETENTION_FEE: 'blue',
};
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
const FREIGHT = [
@@ -353,6 +367,7 @@ function FeeRules() {
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
basis: 'PER_CONTAINER' as FeeRuleBasis,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
@@ -367,11 +382,15 @@ function FeeRules() {
const containerTypeOptions = codeOptions(containerTypes);
const isBulkRule = form.freightType === 'BULK';
const isContainerRule = form.freightType === 'CONTAINER';
// Double handling is a flat per-unit charge (basis × rate), not day-based:
// no free days, no progressive tiers.
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
const resetForm = () =>
setForm({
name: '',
ruleType: 'DEMURRAGE_FEE',
basis: 'PER_CONTAINER',
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
@@ -440,10 +459,12 @@ function FeeRules() {
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
freeDays: form.freeDays,
// Double handling: flat basis × rate — no free days, no tiers.
freeDays: isDoubleHandling ? 0 : form.freeDays,
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
...(tiers.length ? { tiers } : {}),
...(isDoubleHandling ? { basis: form.basis } : {}),
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
};
try {
@@ -514,8 +535,8 @@ function FeeRules() {
{rules.map((rule) => (
<Table.Tr key={rule.id}>
<Table.Td>
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
<Badge color={RULE_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
</Badge>
</Table.Td>
<Table.Td>{rule.name}</Table.Td>
@@ -577,7 +598,7 @@ function FeeRules() {
label="Rule type"
data={FEE_RULE_TYPES.map((type) => ({
value: type,
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
label: FEE_RULE_TYPE_LABELS[type],
}))}
value={form.ruleType}
onChange={(value) =>
@@ -637,14 +658,26 @@ function FeeRules() {
/>
)}
<Group grow>
{isDoubleHandling ? (
<Select
label="Basis"
data={FEE_RULE_BASES.map((b) => ({ value: b, label: FEE_RULE_BASIS_LABELS[b] }))}
value={form.basis}
onChange={(value) =>
setForm((f) => ({ ...f, basis: selectValue(value, 'PER_CONTAINER') as FeeRuleBasis }))
}
allowDeselect={false}
/>
) : (
<NumberInput
label="Free days"
min={0}
value={form.freeDays}
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
/>
)}
<NumberInput
label="Free days"
min={0}
value={form.freeDays}
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
/>
<NumberInput
label="Rate / day"
label={isDoubleHandling ? 'Rate / unit' : 'Rate / day'}
min={0}
value={form.ratePerDay}
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
@@ -657,6 +690,13 @@ function FeeRules() {
allowDeselect={false}
/>
</Group>
{isDoubleHandling && (
<Text size="xs" c="dimmed">
Flat charge the rate is multiplied by the selected basis (
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
</Text>
)}
{!isDoubleHandling && (
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" fw={600}>
@@ -702,6 +742,7 @@ function FeeRules() {
</Text>
)}
</Stack>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>
Cancel

View File

@@ -737,13 +737,38 @@ export interface AllocationRule {
}
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export const FEE_RULE_TYPES = [
'STORAGE_FEE',
'DEMURRAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
/** Human labels for each fee rule type (dropdowns, badges). */
export const FEE_RULE_TYPE_LABELS: Record<FeeRuleType, string> = {
STORAGE_FEE: 'Storage',
DEMURRAGE_FEE: 'Demurrage',
DOUBLE_HANDLING_FEE: 'Double Handling',
TRUCK_DETENTION_FEE: 'Truck Detention Cost',
};
/** Charge basis for a Double Handling rule (flat rate × the chosen quantity). */
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
PER_CONTAINER: 'Per Container',
PER_TON: 'Per Ton',
PER_ITEM: 'Per Item',
};
export interface FeeRule {
id: string;
name: string;
ruleType: FeeRuleType;
/** Double-handling charge basis; null for day-based fee types. */
basis?: FeeRuleBasis | null;
priority: number;
freightType?: string | null;
tradeDirection?: string | null;
@@ -776,6 +801,7 @@ export interface FeePreviewTier extends FeeRuleTier {
export interface FeePreview {
ruleType: FeeRuleType;
basis?: FeeRuleBasis | null;
ruleId: string | null;
ruleName: string | null;
freeDays: number;