Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx
Hagernesh 8dc4dd585e feat(procurement): validate acquisition lease fields and enforce bulk-receive capacity
Reject lease start/end and monthly payment on PURCHASE acquisitions (create and
update, validated against the resulting record). Add asset_acquisitions.item_name
column + migration. Enforce warehouse/yard/zone capacity on bulk receive and apply
capacity-counter deltas on save. Adds acquisition-guard spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 12:15:47 +00:00

693 lines
23 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Switch,
Table,
Tabs,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { vehiclesService, type Vehicle } from "@/services/vehicles.service";
import {
procurementService,
type AssetAcquisition,
type AssetDisposal,
type Vendor,
type AcquisitionType,
type AcquisitionStatus,
type VendorType,
type DisposalMethod,
} from "@/services/procurement.service";
const money = (x: number | null | undefined) =>
`ETB ${(Number(x) || 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const ACQUISITION_TYPES: AcquisitionType[] = ["PURCHASE", "LEASE", "RENTAL"];
const ACQUISITION_STATUSES: AcquisitionStatus[] = ["ACTIVE", "LEASE_EXPIRING", "DISPOSED"];
const VENDOR_TYPES: VendorType[] = ["DEALER", "LEASING", "PARTS", "SERVICE", "OTHER"];
const DISPOSAL_METHODS: DisposalMethod[] = ["SALE", "SCRAP", "RETURN_LEASE", "TRADE_IN"];
const typeBadgeColor = (t: AcquisitionType) =>
t === "PURCHASE" ? "green" : t === "LEASE" ? "blue" : "grape";
const statusBadgeColor = (s: AcquisitionStatus) =>
s === "ACTIVE" ? "green" : s === "LEASE_EXPIRING" ? "yellow" : "gray";
const vehicleLabel = (
v?: { plateNumber?: string | null; registrationNumber?: string | null } | null,
fallback?: string | null,
) => v?.plateNumber || v?.registrationNumber || fallback || "—";
// Strip empty strings / null / undefined before sending to the API (ValidationPipe rejects "" for UUID fields).
const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== "" && v !== undefined && v !== null),
) as Partial<T>;
const emptyAcquisition = {
itemName: "",
vehicleId: "",
vendorId: "",
acquisitionType: "PURCHASE" as AcquisitionType,
acquisitionDate: new Date().toISOString().split("T")[0],
cost: undefined as number | undefined,
usefulLifeMonths: undefined as number | undefined,
salvageValue: undefined as number | undefined,
leaseStart: "",
leaseEnd: "",
monthlyPayment: undefined as number | undefined,
status: "ACTIVE" as AcquisitionStatus,
notes: "",
};
const emptyVendor = {
name: "",
type: "" as VendorType | "",
contactPerson: "",
phone: "",
email: "",
address: "",
isActive: true,
};
const emptyDisposal = {
vehicleId: "",
disposalDate: new Date().toISOString().split("T")[0],
method: "SALE" as DisposalMethod,
salePrice: undefined as number | undefined,
buyer: "",
notes: "",
};
export default function ProcurementPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [tab, setTab] = useState<string>("acquisitions");
const [acqModalOpen, setAcqModalOpen] = useState(false);
const [vendorModalOpen, setVendorModalOpen] = useState(false);
const [disposalModalOpen, setDisposalModalOpen] = useState(false);
const [acqForm, setAcqForm] = useState({ ...emptyAcquisition });
const [vendorForm, setVendorForm] = useState({ ...emptyVendor });
const [disposalForm, setDisposalForm] = useState({ ...emptyDisposal });
// ---- Queries ----
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "list"],
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const { data: acquisitions = [], isLoading: loadingAcquisitions } = useQuery({
queryKey: ["procurement", "acquisitions"],
queryFn: async () => {
const res = await procurementService.listAcquisitions();
return res.data || [];
},
});
const { data: vendors = [], isLoading: loadingVendors } = useQuery({
queryKey: ["procurement", "vendors"],
queryFn: async () => {
const res = await procurementService.listVendors();
return res.data || [];
},
});
const { data: disposals = [], isLoading: loadingDisposals } = useQuery({
queryKey: ["procurement", "disposals"],
queryFn: async () => {
const res = await procurementService.listDisposals();
return res.data || [];
},
});
const vehicleOptions =
vehiclesData?.map((v: Vehicle) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const vendorOptions = vendors.map((v: Vendor) => ({ value: v.id, label: v.name }));
// ---- Mutations ----
const createAcquisition = useMutation({
mutationFn: async () => {
const res = await procurementService.createAcquisition(clean(acqForm) as never);
return res.data;
},
onSuccess: () => {
toast({ title: "Acquisition recorded" });
setAcqModalOpen(false);
setAcqForm({ ...emptyAcquisition });
qc.invalidateQueries({ queryKey: ["procurement", "acquisitions"] });
},
onError: (error: any) => {
toast({
title: "Error recording acquisition",
description: error?.response?.data?.message || "Failed to record acquisition",
variant: "destructive",
});
},
});
const createVendor = useMutation({
mutationFn: async () => {
const res = await procurementService.createVendor(clean(vendorForm) as never);
return res.data;
},
onSuccess: () => {
toast({ title: "Vendor created" });
setVendorModalOpen(false);
setVendorForm({ ...emptyVendor });
qc.invalidateQueries({ queryKey: ["procurement", "vendors"] });
},
onError: (error: any) => {
toast({
title: "Error creating vendor",
description: error?.response?.data?.message || "Failed to create vendor",
variant: "destructive",
});
},
});
const createDisposal = useMutation({
mutationFn: async () => {
const res = await procurementService.createDisposal(clean(disposalForm) as never);
return res.data;
},
onSuccess: () => {
toast({ title: "Disposal recorded" });
setDisposalModalOpen(false);
setDisposalForm({ ...emptyDisposal });
qc.invalidateQueries({ queryKey: ["procurement", "disposals"] });
},
onError: (error: any) => {
toast({
title: "Error recording disposal",
description: error?.response?.data?.message || "Failed to record disposal",
variant: "destructive",
});
},
});
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Procurement" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Procurement & Assets</Title>
</Group>
<Tabs value={tab} onChange={(val) => setTab(val || "acquisitions")}>
<Tabs.List mb="lg">
<Tabs.Tab value="acquisitions">Acquisitions</Tabs.Tab>
<Tabs.Tab value="vendors">Vendors</Tabs.Tab>
<Tabs.Tab value="disposals">Disposals</Tabs.Tab>
</Tabs.List>
{/* ---- Acquisitions ---- */}
<Tabs.Panel value="acquisitions">
<Group justify="flex-end" mb="md">
<Button
leftSection={<Plus size={16} />}
onClick={() => setAcqModalOpen(true)}
color="edr-green"
>
New Acquisition
</Button>
</Group>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Item / Asset</Table.Th>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th align="right">Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadingAcquisitions ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : acquisitions.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="md">
No acquisitions recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{acquisitions.map((a: AssetAcquisition) => (
<Table.Tr key={a.id}>
<Table.Td>{a.itemName || "—"}</Table.Td>
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
<Table.Td>
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
{a.acquisitionType}
</Badge>
</Table.Td>
<Table.Td>{new Date(a.acquisitionDate).toLocaleDateString()}</Table.Td>
<Table.Td align="right">{money(a.cost)}</Table.Td>
<Table.Td>
<Badge size="sm" color={statusBadgeColor(a.status)}>
{a.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
{/* ---- Vendors ---- */}
<Tabs.Panel value="vendors">
<Group justify="flex-end" mb="md">
<Button
leftSection={<Plus size={16} />}
onClick={() => setVendorModalOpen(true)}
color="edr-green"
>
New Vendor
</Button>
</Group>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Contact</Table.Th>
<Table.Th>Phone</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Active</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadingVendors ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : vendors.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="md">
No vendors added yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{vendors.map((v: Vendor) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>{v.type ? <Badge size="sm">{v.type}</Badge> : "—"}</Table.Td>
<Table.Td>{v.contactPerson || "—"}</Table.Td>
<Table.Td>{v.phone || "—"}</Table.Td>
<Table.Td>{v.email || "—"}</Table.Td>
<Table.Td>
<Badge size="sm" color={v.isActive ? "green" : "gray"}>
{v.isActive ? "Active" : "Inactive"}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
{/* ---- Disposals ---- */}
<Tabs.Panel value="disposals">
<Group justify="flex-end" mb="md">
<Button
leftSection={<Plus size={16} />}
onClick={() => setDisposalModalOpen(true)}
color="edr-green"
>
New Disposal
</Button>
</Group>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th align="right">Sale Price</Table.Th>
<Table.Th>Buyer</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadingDisposals ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : disposals.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" py="md">
No disposals recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{disposals.map((d: AssetDisposal) => (
<Table.Tr key={d.id}>
<Table.Td>{d.vehicleId}</Table.Td>
<Table.Td>
<Badge size="sm">{d.method}</Badge>
</Table.Td>
<Table.Td>{new Date(d.disposalDate).toLocaleDateString()}</Table.Td>
<Table.Td align="right">{money(d.salePrice)}</Table.Td>
<Table.Td>{d.buyer || "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
</Tabs>
{/* ---- Acquisition Modal ---- */}
<Modal
opened={acqModalOpen}
onClose={() => setAcqModalOpen(false)}
title="New Acquisition"
size="lg"
>
<Stack gap="md">
<TextInput
label="Item / Asset"
placeholder="What was acquired — e.g. brake pads, tyres, truck 3-15288"
value={acqForm.itemName}
onChange={(e) => setAcqForm({ ...acqForm, itemName: e.currentTarget.value })}
required
/>
<Select
label="Related vehicle (optional)"
description="Only when the acquisition is a fleet vehicle itself — parts and general procurement stay unlinked."
placeholder="Not tied to a vehicle"
data={vehicleOptions}
value={acqForm.vehicleId}
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
searchable
clearable
/>
<Group gap="xs" align="flex-end" wrap="nowrap">
<Select
style={{ flex: 1 }}
label="Vendor"
placeholder="Select vendor"
data={vendorOptions}
value={acqForm.vendorId}
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
searchable
clearable
/>
<Button variant="light" size="sm" onClick={() => setVendorModalOpen(true)}>
Register vendor
</Button>
</Group>
<Select
label="Acquisition Type"
data={ACQUISITION_TYPES}
value={acqForm.acquisitionType}
onChange={(val) => {
const acquisitionType = (val as AcquisitionType) || "PURCHASE";
// Lease terms are invalid on a purchase — drop them on switch.
setAcqForm(
acquisitionType === "PURCHASE"
? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined }
: { ...acqForm, acquisitionType },
);
}}
required
/>
<TextInput
label="Acquisition Date"
type="date"
value={acqForm.acquisitionDate}
onChange={(e) => setAcqForm({ ...acqForm, acquisitionDate: e.currentTarget.value })}
required
/>
<NumberInput
label="Cost"
placeholder="0.00"
value={acqForm.cost}
onChange={(val) => setAcqForm({ ...acqForm, cost: val as number | undefined })}
decimalScale={2}
min={0}
/>
<NumberInput
label="Useful Life (months)"
placeholder="Optional"
value={acqForm.usefulLifeMonths}
onChange={(val) =>
setAcqForm({ ...acqForm, usefulLifeMonths: val as number | undefined })
}
decimalScale={0}
min={0}
/>
<NumberInput
label="Salvage Value"
placeholder="0.00"
value={acqForm.salvageValue}
onChange={(val) => setAcqForm({ ...acqForm, salvageValue: val as number | undefined })}
decimalScale={2}
min={0}
/>
{acqForm.acquisitionType !== "PURCHASE" && (
<>
<TextInput
label="Lease Start"
type="date"
value={acqForm.leaseStart}
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
/>
<TextInput
label="Lease End"
type="date"
value={acqForm.leaseEnd}
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
/>
<NumberInput
label="Monthly Payment"
placeholder="0.00"
value={acqForm.monthlyPayment}
onChange={(val) =>
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
}
decimalScale={2}
min={0}
/>
</>
)}
<Select
label="Status"
data={ACQUISITION_STATUSES}
value={acqForm.status}
onChange={(val) =>
setAcqForm({ ...acqForm, status: (val as AcquisitionStatus) || "ACTIVE" })
}
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={acqForm.notes}
onChange={(e) => setAcqForm({ ...acqForm, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setAcqModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createAcquisition.mutate()}
loading={createAcquisition.isPending}
disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2}
>
Save Acquisition
</Button>
</Group>
</Stack>
</Modal>
{/* ---- Vendor Modal ---- */}
<Modal
opened={vendorModalOpen}
onClose={() => setVendorModalOpen(false)}
title="New Vendor"
size="lg"
>
<Stack gap="md">
<TextInput
label="Name"
placeholder="Vendor name"
value={vendorForm.name}
onChange={(e) => setVendorForm({ ...vendorForm, name: e.currentTarget.value })}
required
/>
<Select
label="Type"
placeholder="Select type"
data={VENDOR_TYPES}
value={vendorForm.type || null}
onChange={(val) => setVendorForm({ ...vendorForm, type: (val as VendorType) || "" })}
clearable
/>
<TextInput
label="Contact Person"
placeholder="Optional"
value={vendorForm.contactPerson}
onChange={(e) => setVendorForm({ ...vendorForm, contactPerson: e.currentTarget.value })}
/>
<TextInput
label="Phone"
placeholder="Optional"
value={vendorForm.phone}
onChange={(e) => setVendorForm({ ...vendorForm, phone: e.currentTarget.value })}
/>
<TextInput
label="Email"
placeholder="Optional"
value={vendorForm.email}
onChange={(e) => setVendorForm({ ...vendorForm, email: e.currentTarget.value })}
/>
<TextInput
label="Address"
placeholder="Optional"
value={vendorForm.address}
onChange={(e) => setVendorForm({ ...vendorForm, address: e.currentTarget.value })}
/>
<Switch
label="Active"
checked={vendorForm.isActive}
onChange={(e) => setVendorForm({ ...vendorForm, isActive: e.currentTarget.checked })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setVendorModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createVendor.mutate()}
loading={createVendor.isPending}
disabled={!vendorForm.name}
>
Save Vendor
</Button>
</Group>
</Stack>
</Modal>
{/* ---- Disposal Modal ---- */}
<Modal
opened={disposalModalOpen}
onClose={() => setDisposalModalOpen(false)}
title="New Disposal"
size="lg"
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={disposalForm.vehicleId}
onChange={(val) => setDisposalForm({ ...disposalForm, vehicleId: val || "" })}
searchable
required
/>
<TextInput
label="Disposal Date"
type="date"
value={disposalForm.disposalDate}
onChange={(e) =>
setDisposalForm({ ...disposalForm, disposalDate: e.currentTarget.value })
}
required
/>
<Select
label="Method"
data={DISPOSAL_METHODS}
value={disposalForm.method}
onChange={(val) =>
setDisposalForm({ ...disposalForm, method: (val as DisposalMethod) || "SALE" })
}
required
/>
<NumberInput
label="Sale Price"
placeholder="0.00"
value={disposalForm.salePrice}
onChange={(val) =>
setDisposalForm({ ...disposalForm, salePrice: val as number | undefined })
}
decimalScale={2}
min={0}
/>
<TextInput
label="Buyer"
placeholder="Optional"
value={disposalForm.buyer}
onChange={(e) => setDisposalForm({ ...disposalForm, buyer: e.currentTarget.value })}
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={disposalForm.notes}
onChange={(e) => setDisposalForm({ ...disposalForm, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setDisposalModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createDisposal.mutate()}
loading={createDisposal.isPending}
disabled={!disposalForm.vehicleId || !disposalForm.disposalDate}
>
Save Disposal
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}