mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
BookingDocumentsCard,
|
||||
type BookingFileView,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
@@ -184,6 +185,7 @@ export default function BookingRequestDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{showContractButton && (
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge as MantineBadge,
|
||||
Box,
|
||||
Button as MantineButton,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select as MantineSelect,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table as MantineTable,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -31,6 +50,7 @@ import {
|
||||
useUpdateContainer,
|
||||
} from '@/hooks/useContainers';
|
||||
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
|
||||
import { useRouteYards } from '@/hooks/useRoutes';
|
||||
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
|
||||
import {
|
||||
useCreateLocomotive,
|
||||
@@ -39,6 +59,7 @@ import {
|
||||
useUpdateLocomotive,
|
||||
} from '@/hooks/useLocomotives';
|
||||
import type { Cargo } from '@/services/cargoService';
|
||||
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
|
||||
import type { Container } from '@/services/containerService';
|
||||
import type { Locomotive } from '@/services/locomotives.service';
|
||||
import type { Train } from '@/services/trains.service';
|
||||
@@ -84,6 +105,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
||||
removeConfirmMessage?: string;
|
||||
removeSuccessMessage?: string;
|
||||
hideViewAction?: boolean;
|
||||
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
|
||||
rowActions?: (item: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
const normalizePayload = (values: Record<string, FormValue>) =>
|
||||
@@ -165,6 +188,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
removeConfirmMessage,
|
||||
removeSuccessMessage,
|
||||
hideViewAction = false,
|
||||
rowActions,
|
||||
}: FleetCrudPageProps<T>) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -333,6 +357,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{rowActions?.(item)}
|
||||
{!hideViewAction ? (
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||
<Eye className="size-4" />
|
||||
@@ -522,74 +547,366 @@ export function TrainMasterDataPage() {
|
||||
|
||||
export function WagonTypesCrudPage() {
|
||||
const query = useWagonTypes();
|
||||
const create = useCreateWagonType();
|
||||
const update = useUpdateWagonType();
|
||||
const remove = useDeleteWagonType();
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WagonType | null>(null);
|
||||
const [viewing, setViewing] = useState<WagonType | null>(null);
|
||||
const [form, setForm] = useState<Record<string, FormValue>>({
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const pageSize = 10;
|
||||
const filtered = useMemo(() => {
|
||||
const queryText = search.trim().toLowerCase();
|
||||
const rows = query.data ?? [];
|
||||
if (!queryText) return rows;
|
||||
return rows.filter((type) =>
|
||||
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(queryText),
|
||||
);
|
||||
}, [query.data, search]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((left, right) => {
|
||||
const result = String(left[sortKey] ?? '').localeCompare(String(right[sortKey] ?? ''), undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
}, [filtered, sortDirection, sortKey]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
const isSaving = create.isPending || update.isPending;
|
||||
|
||||
const toggleSort = (key: keyof WagonType) => {
|
||||
setPage(1);
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
setFieldErrors({});
|
||||
setForm({
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = (type: WagonType) => {
|
||||
setEditing(type);
|
||||
setFieldErrors({});
|
||||
setForm({
|
||||
code: type.code ?? '',
|
||||
name: type.name ?? '',
|
||||
capacityTons: type.capacityTons ?? 0,
|
||||
lengthMeters: type.lengthMeters ?? 0,
|
||||
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
|
||||
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
||||
isActive: type.isActive,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const validateWagonType = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
|
||||
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
|
||||
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
|
||||
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const errors = validateWagonType();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
toast({ title: 'Save failed', description: Object.values(errors)[0], variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = normalizePayload(form);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await update.mutateAsync({ id: editing.id, data: payload });
|
||||
toast({ title: 'Wagon Type updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload);
|
||||
toast({ title: 'Wagon Type created' });
|
||||
}
|
||||
closeForm();
|
||||
} catch (error) {
|
||||
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
|
||||
setFieldErrors(backendFieldErrors);
|
||||
toast({ title: 'Save failed', description: message, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (type: WagonType) => {
|
||||
if (!window.confirm('Delete this wagon type?')) return;
|
||||
try {
|
||||
await remove.mutateAsync(type.id);
|
||||
toast({ title: 'Wagon Type deleted' });
|
||||
} catch {
|
||||
toast({ title: 'Delete failed', description: 'This wagon type may still be referenced.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const sortLabel = (key: keyof WagonType) => (sortKey === key ? (sortDirection === 'asc' ? ' ASC' : ' DESC') : '');
|
||||
|
||||
return (
|
||||
<FleetCrudPage<WagonType>
|
||||
title="Wagon Types"
|
||||
description="Manage wagon type capacities and load compatibility used by wagon master data."
|
||||
addLabel="Add Wagon Type"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateWagonType()}
|
||||
update={useUpdateWagonType()}
|
||||
remove={useDeleteWagonType()}
|
||||
searchText={(type) =>
|
||||
[type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ')
|
||||
}
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)' },
|
||||
{ key: 'lengthMeters', label: 'Length (m)' },
|
||||
{
|
||||
key: 'supportedLoadTypes',
|
||||
label: 'Load types',
|
||||
render: (type) => type.supportedLoadTypes?.join(', ') || '-',
|
||||
},
|
||||
{ key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
|
||||
{ key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true },
|
||||
{ key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' },
|
||||
{
|
||||
key: 'supportedLoadTypes',
|
||||
label: 'Supported load types',
|
||||
placeholder: 'container, break-bulk',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' },
|
||||
],
|
||||
onValueChange: (value) => ({ isActive: value === 'true' }),
|
||||
},
|
||||
]}
|
||||
emptyValues={{
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
}}
|
||||
/>
|
||||
<Box p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Box>
|
||||
<Title order={2}>Wagon Types</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Manage wagon type capacities and load compatibility used by wagon master data.
|
||||
</Text>
|
||||
</Box>
|
||||
<MantineButton
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
closeForm();
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Wagon Type
|
||||
</MantineButton>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
maw={420}
|
||||
leftSection={<Search size={16} />}
|
||||
placeholder="Search wagon types"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<ScrollArea>
|
||||
<MantineTable striped highlightOnHover verticalSpacing="sm" miw={900}>
|
||||
<MantineTable.Thead>
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('code')}>
|
||||
Code{sortLabel('code')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('name')}>
|
||||
Name{sortLabel('name')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('capacityTons')}>
|
||||
Capacity (tons){sortLabel('capacityTons')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>Length (m)</MantineTable.Th>
|
||||
<MantineTable.Th>Load types</MantineTable.Th>
|
||||
<MantineTable.Th>Status</MantineTable.Th>
|
||||
<MantineTable.Th ta="right">Actions</MantineTable.Th>
|
||||
</MantineTable.Tr>
|
||||
</MantineTable.Thead>
|
||||
<MantineTable.Tbody>
|
||||
{paged.map((type) => (
|
||||
<MantineTable.Tr key={type.id}>
|
||||
<MantineTable.Td fw={600}>{type.code}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.name}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<MantineBadge color={type.isActive === false ? 'gray' : 'green'} variant="light">
|
||||
{type.isActive === false ? 'Inactive' : 'Active'}
|
||||
</MantineBadge>
|
||||
</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ActionIcon variant="subtle" aria-label="View wagon type" onClick={() => setViewing(type)}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" aria-label="Edit wagon type" onClick={() => openEdit(type)}>
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label="Delete wagon type"
|
||||
onClick={() => handleDelete(type)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
))}
|
||||
{!query.isLoading && filtered.length === 0 ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No wagon types found.
|
||||
</Text>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
) : null}
|
||||
{query.isLoading ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
Loading...
|
||||
</Text>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
) : null}
|
||||
</MantineTable.Tbody>
|
||||
</MantineTable>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
|
||||
{sorted.length}
|
||||
</Text>
|
||||
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Modal opened={formOpen} onClose={closeForm} title={editing ? 'Edit Wagon Type' : 'Add Wagon Type'} centered>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput
|
||||
label="Code"
|
||||
required
|
||||
value={String(form.code ?? '')}
|
||||
error={fieldErrors.code}
|
||||
onChange={(event) => setForm((current) => ({ ...current, code: event.currentTarget.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
required
|
||||
value={String(form.name ?? '')}
|
||||
error={fieldErrors.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Capacity (tons)"
|
||||
required
|
||||
min={0}
|
||||
value={Number(form.capacityTons ?? 0)}
|
||||
error={fieldErrors.capacityTons}
|
||||
onChange={(value) => setForm((current) => ({ ...current, capacityTons: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Length (meters)"
|
||||
required
|
||||
min={0}
|
||||
value={Number(form.lengthMeters ?? 0)}
|
||||
error={fieldErrors.lengthMeters}
|
||||
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
min={0}
|
||||
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
|
||||
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
|
||||
/>
|
||||
<MantineSelect
|
||||
label="Status"
|
||||
value={form.isActive ? 'true' : 'false'}
|
||||
data={[
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' },
|
||||
]}
|
||||
onChange={(value) => setForm((current) => ({ ...current, isActive: value !== 'false' }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Supported load types"
|
||||
placeholder="container, break-bulk"
|
||||
value={Array.isArray(form.supportedLoadTypes) ? form.supportedLoadTypes.join(', ') : String(form.supportedLoadTypes ?? '')}
|
||||
onChange={(event) => setForm((current) => ({ ...current, supportedLoadTypes: event.currentTarget.value }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<MantineButton variant="default" type="button" onClick={closeForm}>
|
||||
Cancel
|
||||
</MantineButton>
|
||||
<MantineButton type="submit" loading={isSaving}>
|
||||
Save
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={Boolean(viewing)} onClose={() => setViewing(null)} title="Wagon Type details" centered>
|
||||
<Stack gap="xs">
|
||||
{viewing
|
||||
? Object.entries(viewing).map(([key, value]) => (
|
||||
<Group key={key} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{key}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="right">
|
||||
{Array.isArray(value) ? value.join(', ') : value == null ? '-' : String(value)}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
: null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function WagonsCrudPage() {
|
||||
const query = useWagons();
|
||||
const { data: wagonTypes = [] } = useWagonTypes();
|
||||
const { data: yards = [] } = useRouteYards();
|
||||
const wagonTypeOptions = wagonTypes.map((type: any) => ({
|
||||
value: type.id,
|
||||
label: `${type.code} - ${type.name}`,
|
||||
}));
|
||||
const yardOptions = yards.map((yard: any) => ({
|
||||
value: yard.id,
|
||||
label: `${yard.label ?? yard.code} (${yard.country ?? '-'})`,
|
||||
}));
|
||||
return (
|
||||
<FleetCrudPage<Wagon>
|
||||
title="Wagons"
|
||||
@@ -600,10 +917,26 @@ export function WagonsCrudPage() {
|
||||
create={useCreateWagon()}
|
||||
update={useUpdateWagon()}
|
||||
remove={useDeleteWagon()}
|
||||
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
|
||||
searchText={(wagon) => [
|
||||
wagon.wagonNumber,
|
||||
wagon.wagonTypeId,
|
||||
wagon.trainId,
|
||||
wagon.status,
|
||||
wagon.currentLocationYard?.label,
|
||||
wagon.currentLocationYard?.code,
|
||||
wagon.currentLocationYard?.country,
|
||||
].join(' ')}
|
||||
columns={[
|
||||
{ key: 'wagonNumber', label: 'Number' },
|
||||
{ key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) },
|
||||
{
|
||||
key: 'currentLocationYardId',
|
||||
label: 'Location',
|
||||
render: (wagon) =>
|
||||
wagon.currentLocationYard
|
||||
? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})`
|
||||
: '-',
|
||||
},
|
||||
{ key: 'maxPayloadWeight', label: 'Max payload' },
|
||||
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
|
||||
]}
|
||||
@@ -621,12 +954,31 @@ export function WagonsCrudPage() {
|
||||
return { maxPayloadWeight: Number(selectedType.capacityTons) };
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'currentLocationYardId',
|
||||
label: 'Wagon location',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: yardOptions,
|
||||
},
|
||||
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
|
||||
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'AVAILABLE', label: 'Available' },
|
||||
{ value: 'IMPORT_READY', label: 'Import ready' },
|
||||
{ value: 'EXPORT_READY', label: 'Export ready' },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||
{ value: 'RETIRED', label: 'Retired' },
|
||||
],
|
||||
},
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
]}
|
||||
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
|
||||
emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -718,7 +1070,18 @@ export function CargoesCrudPage() {
|
||||
{ key: 'quantity', label: 'Quantity' },
|
||||
{ key: 'weight', label: 'Weight' },
|
||||
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
|
||||
{
|
||||
key: 'receiverName',
|
||||
label: 'Proof of delivery',
|
||||
render: (cargo) =>
|
||||
cargo.status === 'DELIVERED' && cargo.receiverName
|
||||
? `${cargo.receiverName}${cargo.deliveredAt ? ` · ${new Date(cargo.deliveredAt).toLocaleDateString()}` : ''}`
|
||||
: '—',
|
||||
},
|
||||
]}
|
||||
rowActions={(cargo) =>
|
||||
cargo.status === 'LOADED' ? <DeliverCargoDialog cargoId={cargo.id} /> : null
|
||||
}
|
||||
fields={[
|
||||
{ key: 'cargoReference', label: 'Cargo reference', required: true },
|
||||
{ key: 'shipmentId', label: 'Shipment ID', required: true },
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InspectionReportModal,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatDate,
|
||||
} from '@/components/warehouses';
|
||||
import { useArrivalQueue, useAutoUnloadArrived, useUnloadBooking } from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { ArrivalQueueItem } from '@/types/warehouse';
|
||||
|
||||
function inspectionBadge(status: string | null) {
|
||||
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
|
||||
const color = status === 'PASSED' ? 'green' : status === 'FAILED' ? 'red' : 'orange';
|
||||
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
}
|
||||
|
||||
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
|
||||
export default function ArrivalQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useArrivalQueue();
|
||||
const autoUnload = useAutoUnloadArrived();
|
||||
const unloadOne = useUnloadBooking();
|
||||
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
const handleAutoUnload = async () => {
|
||||
try {
|
||||
const res = await autoUnload.mutateAsync();
|
||||
const r = res.data;
|
||||
toast({
|
||||
title: 'Auto-unload complete',
|
||||
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
|
||||
});
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Auto-unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnloadOne = async (item: ArrivalQueueItem) => {
|
||||
try {
|
||||
await unloadOne.mutateAsync({ bookingId: item.bookingId });
|
||||
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="warehouse"
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived bookings ready to unload, store and inspect."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{items.length} arrived booking(s)</Text>
|
||||
<Button
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={16} />}
|
||||
loading={autoUnload.isPending}
|
||||
onClick={handleAutoUnload}
|
||||
>
|
||||
Auto Unload Arrived Bookings
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No arrived bookings"
|
||||
description="Bookings in transit that arrive appear here for unloading and inspection."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Cargo / Container</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">{item.bookingReference}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.customer ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.container ?? item.cargo ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(item.arrivalDate)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.facility ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.warehouse ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.yard ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.zone ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{item.unloaded ? (
|
||||
<Badge variant="light" color="green" size="sm">
|
||||
{item.currentStatus ?? 'RECEIVED'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
Not unloaded
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{inspectionBadge(item.inspectionStatus)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{!item.unloaded && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={unloadOne.isPending}
|
||||
onClick={() => handleUnloadOne(item)}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
)}
|
||||
{item.inventoryId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ClipboardList size={14} />}
|
||||
onClick={() => setInspectInventoryId(item.inventoryId)}
|
||||
>
|
||||
Inspect
|
||||
</Button>
|
||||
)}
|
||||
{item.inventoryId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => navigate('/dashboard/warehouse-inventory')}
|
||||
>
|
||||
Inventory
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectInventoryId)}
|
||||
onClose={() => setInspectInventoryId(null)}
|
||||
inventoryId={inspectInventoryId}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Card, Container, Stack } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Items that are LOADED and awaiting dispatch (train departure). */
|
||||
export default function DispatchQueuePage() {
|
||||
const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' });
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Dispatch queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="route"
|
||||
title="Dispatch Queue"
|
||||
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
title="Nothing to dispatch"
|
||||
description="Loaded items appear here, ready to mark as dispatched."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={items} isLoading={isLoading} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import {
|
||||
useInventoryInquiry,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function InventoryInquiryPage() {
|
||||
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(draft.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(draft.yardId);
|
||||
|
||||
const { data, isFetching } = useInventoryInquiry(applied);
|
||||
const results = data ?? [];
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const runSearch = () => setApplied(draft);
|
||||
const reset = () => {
|
||||
setDraft({});
|
||||
setApplied({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Inventory inquiry' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<div>
|
||||
<Title order={2}>Inventory Inquiry</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Locate any cargo, container or goods inside the warehouse network.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking number"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.warehouseId}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.yardId}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Record of every inventory item loaded onto a wagon. */
|
||||
export default function LoadedInventoryPage() {
|
||||
const { data, isLoading } = useWarehouseLoadings();
|
||||
const loadings = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loaded inventory' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="wagon"
|
||||
title="Loaded Inventory"
|
||||
subtitle="Items loaded onto wagons, with their loading records."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : loadings.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No loaded inventory yet"
|
||||
description="Once items are loaded onto a wagon, their records show here."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Loaded Weight (kg)</Table.Th>
|
||||
<Table.Th>Loaded At</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{loadings.map((l) => (
|
||||
<Table.Tr key={l.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FreightVisual variant="wagon" size={22} />
|
||||
<Text fw={600} size="sm">
|
||||
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{l.inventory?.warehouse
|
||||
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
|
||||
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
|
||||
size="sm"
|
||||
>
|
||||
{l.inventory?.status ?? 'LOADED'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Button, Card, Container, Group, Stack, Table, Tabs, Text } from '@mantine/core';
|
||||
import { CreditCard, Eye, Truck } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
|
||||
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
|
||||
|
||||
/**
|
||||
* Loading Queue — manage inventory through the loading workflow.
|
||||
* Tabs:
|
||||
* - Ready to Load: READY_FOR_LOADING + booking PAID (can Mark as Loaded)
|
||||
* - Pending Payment: READY_FOR_LOADING + booking not PAID (no Load action)
|
||||
* - Loaded Inventory: LOADED (can Dispatch)
|
||||
* - Dispatch Queue: LOADED (can Dispatch)
|
||||
*/
|
||||
export default function LoadingQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const autoLoad = useAutoLoadReady();
|
||||
const { data: readyData, isLoading: readyLoading } = useWarehouseInventory({
|
||||
status: 'READY_FOR_LOADING',
|
||||
});
|
||||
const { data: loadedData, isLoading: loadedLoading } = useWarehouseInventory({ status: 'LOADED' });
|
||||
|
||||
const handleAutoLoad = async () => {
|
||||
try {
|
||||
const res = await autoLoad.mutateAsync();
|
||||
const r = res.data;
|
||||
toast({
|
||||
title: 'Auto-load complete',
|
||||
description: `Loaded ${r.loadedCount} PAID item(s); skipped ${r.skippedCount} (unpaid stay pending).`,
|
||||
});
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Auto-load failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const readyItems = readyData ?? [];
|
||||
const loadedItems = loadedData ?? [];
|
||||
|
||||
const paidItems = useMemo(() => readyItems.filter(isPaid), [readyItems]);
|
||||
const unpaidItems = useMemo(() => readyItems.filter((i) => !isPaid(i)), [readyItems]);
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loading queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="wagon"
|
||||
secondaryVariant="cargo"
|
||||
title="Loading Queue"
|
||||
subtitle="Manage bookings and inventory through the loading workflow."
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<Truck size={16} />}
|
||||
loading={autoLoad.isPending}
|
||||
onClick={handleAutoLoad}
|
||||
>
|
||||
Auto Load Ready Items
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Tabs defaultValue="ready">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="ready"
|
||||
leftSection={
|
||||
<Badge size="xs" color="green">
|
||||
{paidItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Ready to Load
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="pending"
|
||||
leftSection={
|
||||
<Badge size="xs" color="orange">
|
||||
{unpaidItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Pending Payment
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="loaded"
|
||||
leftSection={
|
||||
<Badge size="xs" color="teal">
|
||||
{loadedItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Loaded Inventory
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="dispatch"
|
||||
leftSection={
|
||||
<Badge size="xs" color="blue">
|
||||
{loadedItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Dispatch Queue
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* Ready to Load — PAID bookings, can be marked Loaded */}
|
||||
<Tabs.Panel value="ready" pt="md">
|
||||
{!readyLoading && paidItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="wagon"
|
||||
title="Nothing ready to load"
|
||||
description="Paid bookings marked Ready For Loading appear here, ready to load onto a wagon."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={paidItems} isLoading={readyLoading} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* Pending Payment — unpaid bookings, read-only (no Load action) */}
|
||||
<Tabs.Panel value="pending" pt="md">
|
||||
{!readyLoading && unpaidItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="cargo"
|
||||
title="No unpaid bookings"
|
||||
description="Ready-for-loading items whose booking is not yet PAID appear here."
|
||||
/>
|
||||
) : (
|
||||
<PendingPaymentTable items={unpaidItems} onNavigate={navigate} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* Loaded Inventory — LOADED items, can Dispatch */}
|
||||
<Tabs.Panel value="loaded" pt="md">
|
||||
{!loadedLoading && loadedItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No loaded inventory yet"
|
||||
description="Items loaded onto a wagon appear here, ready to dispatch."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* Dispatch Queue — LOADED items awaiting departure */}
|
||||
<Tabs.Panel value="dispatch" pt="md">
|
||||
{!loadedLoading && loadedItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
title="Nothing to dispatch"
|
||||
description="Loaded items appear here, ready to mark as dispatched."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface PendingPaymentTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
|
||||
function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Weight (kg)</Table.Th>
|
||||
<Table.Th>Payment</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{item.booking?.reference ?? item.bookingId?.slice(0, 8) ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{item.booking?.status ?? item.booking?.paymentStatus ?? 'UNPAID'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Eye size={14} />}
|
||||
disabled={!item.bookingId}
|
||||
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
|
||||
>
|
||||
Booking
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<CreditCard size={14} />}
|
||||
disabled={!item.bookingId}
|
||||
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
|
||||
>
|
||||
Payment
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
Send,
|
||||
Truck,
|
||||
Warehouse as WarehouseIcon,
|
||||
Boxes,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
/** Brand palette: alternating orange + light green. */
|
||||
const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' };
|
||||
const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' };
|
||||
|
||||
interface Metric {
|
||||
key: keyof WarehouseDashboard;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
/** Route to navigate to when the card is clicked. */
|
||||
to: string;
|
||||
theme: typeof ORANGE;
|
||||
}
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
|
||||
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
|
||||
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
|
||||
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
|
||||
];
|
||||
|
||||
export default function WarehouseDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useWarehouseDashboard();
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
radius="lg"
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`,
|
||||
border: `1px solid ${metric.theme.border}`,
|
||||
transition: 'box-shadow 150ms ease, transform 150ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`;
|
||||
e.currentTarget.style.transform = 'translateY(-3px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = '';
|
||||
e.currentTarget.style.transform = '';
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} size="32px" mt={8} style={{ color: metric.theme.text, lineHeight: 1.1 }}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="filled"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: metric.theme.solid, color: '#fff' }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Select,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
CreateYardModal,
|
||||
CreateZoneModal,
|
||||
InventoryWorkbench,
|
||||
WarehouseStatusBadge,
|
||||
WarehouseTypeBadge,
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useWarehouse,
|
||||
useWarehouseInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" mt={4}>
|
||||
{value}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WarehouseDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: warehouse, isLoading } = useWarehouse(id);
|
||||
const yardsQuery = useWarehouseYards(id);
|
||||
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
||||
|
||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
|
||||
const zonesQuery = useWarehouseZones(selectedYardId ?? undefined);
|
||||
|
||||
const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined);
|
||||
|
||||
const yards = yardsQuery.data ?? [];
|
||||
const yardOptions = useMemo(
|
||||
() => yards.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yards],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih="60vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!warehouse) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Text fw={700}>Warehouse not found</Text>
|
||||
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
|
||||
Back to warehouses
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Warehouses', href: '/dashboard/warehouses' },
|
||||
{ label: warehouse.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group gap="md" align="center">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses')}>
|
||||
<ArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Group gap="sm">
|
||||
<Title order={2}>{warehouse.name}</Title>
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{warehouse.code}
|
||||
{warehouse.locationName ? ` · ${warehouse.locationName}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
|
||||
Yards
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
|
||||
Zones
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
|
||||
Inventory
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
<StatCard label="Type" value={humanizeEnum(warehouse.type)} />
|
||||
<StatCard label="Yards" value={String(yards.length)} />
|
||||
<StatCard
|
||||
label="Weight (cur / cap)"
|
||||
value={formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Containers (cur / cap)"
|
||||
value={formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* YARDS */}
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{yards.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
No yards yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Code</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{yards.map((yard) => (
|
||||
<Table.Tr key={yard.id}>
|
||||
<Table.Td>{yard.name}</Table.Td>
|
||||
<Table.Td>{yard.code}</Table.Td>
|
||||
<Table.Td>{humanizeEnum(yard.type)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(yard.currentWeight, yard.capacityWeight)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(yard.currentContainers, yard.capacityContainers)}</Table.Td>
|
||||
<Table.Td>
|
||||
<WarehouseStatusBadge status={yard.status} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingYard(yard);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ZONES */}
|
||||
<Tabs.Panel value="zones" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={selectedYardId}
|
||||
onChange={setSelectedYardId}
|
||||
w={280}
|
||||
searchable
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selectedYardId}
|
||||
onClick={() => {
|
||||
setEditingZone(null);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Zone
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
</Text>
|
||||
) : (zonesQuery.data ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
No zones in this yard yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Code</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(zonesQuery.data ?? []).map((zone) => (
|
||||
<Table.Tr key={zone.id}>
|
||||
<Table.Td>{zone.name}</Table.Td>
|
||||
<Table.Td>{zone.code}</Table.Td>
|
||||
<Table.Td>{humanizeEnum(zone.type)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(zone.currentWeight, zone.capacityWeight)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(zone.currentContainers, zone.capacityContainers)}</Table.Td>
|
||||
<Table.Td>
|
||||
<WarehouseStatusBadge status={zone.status} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingZone(zone);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* INVENTORY */}
|
||||
<Tabs.Panel value="inventory" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
{id && (
|
||||
<CreateYardModal
|
||||
opened={yardModalOpen}
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
warehouseId={id}
|
||||
yard={editingYard}
|
||||
/>
|
||||
)}
|
||||
{selectedYardId && (
|
||||
<CreateZoneModal
|
||||
opened={zoneModalOpen}
|
||||
onClose={() => setZoneModalOpen(false)}
|
||||
yardId={selectedYardId}
|
||||
zone={editingZone}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { PackagePlus, Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
ReceiveInventoryModal,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useWarehouseInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseInventoryPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
||||
const [filter, setFilter] = useState<InventoryFilter>(
|
||||
initialStatus ? { status: initialStatus } : {},
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const queryFilter = useMemo<InventoryFilter>(
|
||||
() => ({ ...filter, search: debouncedSearch || undefined }),
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||
const inventoryQuery = useWarehouseInventory(queryFilter);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>Warehouse Inventory</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Track received items through the storage, reservation, loading and dispatch lifecycle.
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
|
||||
Receive Inventory
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search notes"
|
||||
leftSection={<Search size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={220}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All warehouses"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={filter.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={220}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All yards"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!filter.warehouseId}
|
||||
data={yardOptions}
|
||||
value={filter.yardId ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All zones"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!filter.yardId}
|
||||
data={zoneOptions}
|
||||
value={filter.zoneId ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={filter.status ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseHero } from '@/components/warehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useCancelInvoice,
|
||||
usePayInvoice,
|
||||
useWarehouseInvoice,
|
||||
useWarehouseInvoices,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
ISSUED: 'orange',
|
||||
PARTIALLY_PAID: 'yellow',
|
||||
PAID: 'green',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
|
||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined);
|
||||
const invoices = data ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return invoices;
|
||||
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
|
||||
}, [invoices, search]);
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse fee invoices' }]} />
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Fee Invoices"
|
||||
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search invoice no / booking / customer"
|
||||
leftSection={<Search size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={320}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
value={status}
|
||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : filtered.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Invoice No</Table.Th><Table.Th>Type</Table.Th><Table.Th>Total</Table.Th>
|
||||
<Table.Th>Paid</Table.Th><Table.Th>Balance</Table.Th><Table.Th>Status</Table.Th>
|
||||
<Table.Th>Issued</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((inv) => (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td><Text fw={600} size="sm">{inv.invoiceNumber}</Text></Table.Td>
|
||||
<Table.Td>{inv.invoiceType.replace(/_/g, ' ')}</Table.Td>
|
||||
<Table.Td>{fmt(inv.totalAmount, inv.currency)}</Table.Td>
|
||||
<Table.Td>{fmt(inv.paidAmount, inv.currency)}</Table.Td>
|
||||
<Table.Td>{fmt(inv.balanceAmount, inv.currency)}</Table.Td>
|
||||
<Table.Td><Badge variant="light" color={STATUS_COLOR[inv.status]}>{inv.status.replace(/_/g, ' ')}</Badge></Table.Td>
|
||||
<Table.Td><Text size="xs">{fmtDate(inv.issuedAt)}</Text></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setDetailId(inv.id)} title="View">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined);
|
||||
const pay = usePayInvoice();
|
||||
const cancel = useCancelInvoice();
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!inv || !payAmount) return;
|
||||
try {
|
||||
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
|
||||
toast({ title: 'Payment recorded' });
|
||||
setPayAmount('');
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
await cancel.mutateAsync(inv.id);
|
||||
toast({ title: 'Invoice cancelled' });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={Boolean(id)} onClose={onClose} title="Fee invoice" centered size="lg">
|
||||
{isLoading || !inv ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700} size="lg">{inv.invoiceNumber}</Text>
|
||||
<Badge variant="light" color={STATUS_COLOR[inv.status]} size="lg">{inv.status.replace(/_/g, ' ')}</Badge>
|
||||
</Group>
|
||||
|
||||
<Table withRowBorders={false} verticalSpacing={4}>
|
||||
<Table.Tbody>
|
||||
{(inv.items ?? []).map((it) => (
|
||||
<Table.Tr key={it.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{it.description}</Text>
|
||||
<Text size="xs" c="dimmed">{it.feeType.replace(/_/g, ' ')} · {it.chargeableDays ?? 0} day(s) @ {fmt(it.unitRate, it.currency)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right"><Text fw={600}>{fmt(it.amount, it.currency)}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Divider />
|
||||
<Group justify="space-between"><Text size="sm" c="dimmed">Subtotal</Text><Text>{fmt(inv.subtotalAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text size="sm" c="dimmed">Tax</Text><Text>{fmt(inv.taxAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text fw={700}>Total</Text><Text fw={700}>{fmt(inv.totalAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text size="sm" c="dimmed">Paid</Text><Text>{fmt(inv.paidAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text fw={600}>Balance</Text><Text fw={600}>{fmt(inv.balanceAmount, inv.currency)}</Text></Group>
|
||||
|
||||
{(inv.payments ?? []).length > 0 && (
|
||||
<>
|
||||
<Divider label="Payment history" labelPosition="left" />
|
||||
{(inv.payments ?? []).map((p, i) => (
|
||||
<Group key={i} justify="space-between">
|
||||
<Text size="xs" c="dimmed">{fmtDate(p.paidAt)} · {p.method ?? '—'}{p.reference ? ` · ${p.reference}` : ''}</Text>
|
||||
<Text size="sm">{fmt(p.amount, inv.currency)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canPay && (
|
||||
<>
|
||||
<Divider label="Record payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
min={0}
|
||||
value={payAmount}
|
||||
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button color="green" leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
|
||||
Pay
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
|
||||
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
|
||||
Cancel invoice
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Card, Center, Container, Group, Loader, Stack, Text, Title } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
CreateWarehouseModal,
|
||||
WarehouseCardView,
|
||||
WarehouseFilters,
|
||||
WarehouseTable,
|
||||
type WarehouseView,
|
||||
} from '@/components/warehouses';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<WarehouseFilter>({});
|
||||
const [view, setView] = useState<WarehouseView>('table');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Warehouse | null>(null);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(filter.search, 300);
|
||||
const queryFilter = useMemo<WarehouseFilter>(
|
||||
() => ({ ...filter, search: debouncedSearch }),
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||
const warehouses = data ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
const openEdit = (warehouse: Warehouse) => {
|
||||
setEditing(warehouse);
|
||||
setModalOpen(true);
|
||||
};
|
||||
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouses' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>Warehouses</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Manage warehouses, yards and zones.
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<Plus size={16} />} onClick={openCreate}>
|
||||
Create Warehouse
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Text c="red" ta="center" py="xl">
|
||||
Failed to load warehouses.
|
||||
</Text>
|
||||
) : view === 'table' ? (
|
||||
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||
) : (
|
||||
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<CreateWarehouseModal opened={modalOpen} onClose={() => setModalOpen(false)} warehouse={editing} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseHero } from '@/components/warehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useAllocationRules,
|
||||
useCreateAllocationRule,
|
||||
useCreateFeeRule,
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
];
|
||||
const TRADE = [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
|
||||
const clean = (s: string) => s.trim() || undefined;
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse rules' }]} />
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="warehouse"
|
||||
secondaryVariant="container"
|
||||
title="Allocation & Fee Rules"
|
||||
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
|
||||
/>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Tabs defaultValue="allocation">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
|
||||
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="allocation" pt="md">
|
||||
<AllocationRules />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="fees" pt="md">
|
||||
<FeeRules />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocationRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const create = useCreateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
priority: 100,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerStatus: '',
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
|
||||
return;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
containerStatus: clean(form.containerStatus) ?? null,
|
||||
targetYardCode: form.targetYardCode.trim(),
|
||||
storageType: clean(form.storageType) ?? null,
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — matched by ascending priority</Text>
|
||||
<Button color="orange" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
|
||||
</Group>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
|
||||
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.priority}</Table.Td>
|
||||
<Table.Td>{r.name}</Table.Td>
|
||||
<Table.Td>{r.freightType ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
|
||||
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
|
||||
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button color="orange" loading={create.isPending} onClick={submit}>Create</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — most specific match applies</Text>
|
||||
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
|
||||
</Group>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
|
||||
<Table.Td>{r.name}</Table.Td>
|
||||
<Table.Td>{r.freightType ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.freeDays}</Table.Td>
|
||||
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {r.currency}</Table.Td>
|
||||
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
|
||||
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
|
||||
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button color="teal" loading={create.isPending} onClick={submit}>Create</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user