Merge pull request #910 from Tria-plc/testfixes

Operations permissons and dispatechand procurement and Asset Feature
This commit is contained in:
Hagernesh Tadesse
2026-07-22 15:18:35 +03:00
committed by GitHub
11 changed files with 324 additions and 77 deletions

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
*/
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
name = 'AddAcquisitionItemName2470000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
ADD COLUMN IF NOT EXISTS item_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
DROP COLUMN IF EXISTS item_name
`);
}
}

View File

@@ -7,6 +7,7 @@ import {
IsOptional, IsOptional,
IsEnum, IsEnum,
IsBoolean, IsBoolean,
MinLength,
} from 'class-validator'; } from 'class-validator';
import { VendorType } from '../entities/vendor.entity'; import { VendorType } from '../entities/vendor.entity';
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
@@ -72,6 +73,11 @@ export class UpdateVendorDto {
} }
export class CreateAcquisitionDto { export class CreateAcquisitionDto {
/** WHAT was acquired — required so an acquisition can't be saved empty. */
@IsString()
@MinLength(2)
itemName!: string;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
vehicleId?: string; vehicleId?: string;
@@ -120,6 +126,11 @@ export class CreateAcquisitionDto {
} }
export class UpdateAcquisitionDto { export class UpdateAcquisitionDto {
@IsOptional()
@IsString()
@MinLength(2)
itemName?: string;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
vehicleId?: string; vehicleId?: string;

View File

@@ -18,6 +18,12 @@ export enum AcquisitionStatus {
@Entity({ name: 'asset_acquisitions', schema: 'freight' }) @Entity({ name: 'asset_acquisitions', schema: 'freight' })
@Index(['vehicleId', 'acquisitionDate']) @Index(['vehicleId', 'acquisitionDate'])
export class AssetAcquisition extends BaseEntity { export class AssetAcquisition extends BaseEntity {
/** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */
@Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true })
itemName?: string;
/** Optional link — only when the acquisition IS a fleet vehicle. Parts and
* general procurement stay unlinked so reports don't misattribute them. */
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) @Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string; vehicleId?: string;

View File

@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
import { ProcurementService } from './procurement.service';
import { AcquisitionType } from './entities/asset-acquisition.entity';
// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may.
describe('ProcurementService acquisition lease-field guard', () => {
const repo = {
createAcquisition: jest.fn(async (dto) => dto),
findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })),
updateAcquisition: jest.fn(async (_id, dto) => dto),
};
const svc = new ProcurementService(repo as never);
it('rejects a PURCHASE with lease dates', async () => {
await expect(
svc.createAcquisition({
itemName: 'Brake pads',
acquisitionType: AcquisitionType.PURCHASE,
acquisitionDate: '2026-07-22',
leaseStart: '2026-07-01',
} as never),
).rejects.toThrow(BadRequestException);
});
it('accepts a LEASE with lease dates and a plain PURCHASE', async () => {
await expect(
svc.createAcquisition({
itemName: 'Rented crane',
acquisitionType: AcquisitionType.LEASE,
acquisitionDate: '2026-07-22',
leaseStart: '2026-07-01',
leaseEnd: '2027-07-01',
} as never),
).resolves.toBeDefined();
await expect(
svc.createAcquisition({
itemName: 'Brake pads',
acquisitionType: AcquisitionType.PURCHASE,
acquisitionDate: '2026-07-22',
} as never),
).resolves.toBeDefined();
});
it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => {
await expect(
svc.updateAcquisition('a1', { monthlyPayment: 500 } as never),
).rejects.toThrow(BadRequestException);
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ProcurementRepository } from './procurement.repository'; import { ProcurementRepository } from './procurement.repository';
import { Vendor } from './entities/vendor.entity'; import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity'; import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity'; import { AssetDisposal } from './entities/asset-disposal.entity';
import { import {
CreateVendorDto, CreateVendorDto,
@@ -51,7 +51,23 @@ export class ProcurementService {
} }
// ---- Acquisitions ---- // ---- Acquisitions ----
/** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */
private assertLeaseFieldsValid(dto: {
acquisitionType?: string;
leaseStart?: string;
leaseEnd?: string;
monthlyPayment?: number;
}): void {
if (dto.acquisitionType !== AcquisitionType.PURCHASE) return;
if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) {
throw new BadRequestException(
'Lease start/end and monthly payment are not valid for a PURCHASE acquisition',
);
}
}
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> { async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
this.assertLeaseFieldsValid(dto);
return this.procurementRepository.createAcquisition(dto); return this.procurementRepository.createAcquisition(dto);
} }
@@ -64,6 +80,20 @@ export class ProcurementService {
} }
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> { async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
// Validate against the resulting record, not just the patch — switching an
// acquisition to PURCHASE must also shed any stored lease terms.
const existing = await this.procurementRepository.findAcquisitionById(id);
if (existing) {
const next = { ...existing, ...dto };
if (next.acquisitionType === AcquisitionType.PURCHASE) {
this.assertLeaseFieldsValid({
acquisitionType: next.acquisitionType,
leaseStart: dto.leaseStart,
leaseEnd: dto.leaseEnd,
monthlyPayment: dto.monthlyPayment,
});
}
}
return this.procurementRepository.updateAcquisition(id, dto); return this.procurementRepository.updateAcquisition(id, dto);
} }

View File

@@ -1578,6 +1578,14 @@ export class WarehouseInventoryService {
notes: `Bulk received (${dto.direction})`, notes: `Bulk received (${dto.direction})`,
truckEntrance, truckEntrance,
}); });
// Validate capacity before saving
const weight = Number(booking.weight) || 0;
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
this.assertCapacity('Yard', yard, weight, 0, containerCount);
this.assertCapacity('Zone', zone, weight, 0, containerCount);
const saved = await manager.getRepository(WarehouseInventory).save( const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({ manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId, warehouseId: dto.warehouseId,
@@ -1585,7 +1593,7 @@ export class WarehouseInventoryService {
zoneId: dto.zoneId, zoneId: dto.zoneId,
bookingId, bookingId,
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight: Number(booking.weight) || 0, weight,
grnNumber, grnNumber,
status: 'RECEIVED', status: 'RECEIVED',
arrivedAt: now, arrivedAt: now,
@@ -1593,6 +1601,9 @@ export class WarehouseInventoryService {
}), }),
); );
// Update warehouse/yard/zone capacity counters
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
// Receiving the booking flags every container unit as received into the // Receiving the booking flags every container unit as received into the
// port (self-haul export: the delivering truck's goods are now in) so // port (self-haul export: the delivering truck's goods are now in) so
// staff can raise the per-container GRN over what's received. // staff can raise the per-container GRN over what's received.

View File

@@ -1,4 +1,4 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
@@ -44,6 +44,7 @@ export class WarehouseYardsService {
// Ensure the parent warehouse exists. // Ensure the parent warehouse exists.
await this.warehousesService.findById(warehouseId); await this.warehousesService.findById(warehouseId);
await this.assertCodeUnique(warehouseId, dto.code.trim()); await this.assertCodeUnique(warehouseId, dto.code.trim());
await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
return this.yardsRepository.create({ return this.yardsRepository.create({
warehouseId, warehouseId,
@@ -69,14 +70,22 @@ export class WarehouseYardsService {
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
} }
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
// Validate updated capacity doesn't exceed warehouse limits
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id);
}
const status = dto.status ?? existing.status; const status = dto.status ?? existing.status;
const updated = await this.yardsRepository.update(id, { const updated = await this.yardsRepository.update(id, {
name: dto.name?.trim() ?? existing.name, name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code, code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type, type: dto.type ?? existing.type,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight, capacityWeight: newCapacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers, capacityContainers: newCapacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight, maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume, maxVolume: dto.maxVolume ?? existing.maxVolume,
status, status,
@@ -97,4 +106,39 @@ export class WarehouseYardsService {
throw new ConflictException(`Yard code ${code} already exists in this warehouse`); throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
} }
} }
private async assertCapacityWithinWarehouse(
warehouseId: string,
newCapacityWeight: number | null,
newCapacityContainers: number | null,
excludeYardId?: string,
): Promise<void> {
const warehouse = await this.warehousesService.findById(warehouseId);
const yards = await this.findByWarehouse(warehouseId);
// Sum existing yard capacities, excluding the yard being updated if provided
const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards;
const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0);
const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0);
// Check weight capacity
if (newCapacityWeight !== null && warehouse.capacityWeight != null) {
const totalWeight = totalExistingWeight + newCapacityWeight;
if (totalWeight > warehouse.capacityWeight) {
throw new BadRequestException(
`Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`,
);
}
}
// Check container capacity
if (newCapacityContainers !== null && warehouse.capacityContainers != null) {
const totalContainers = totalExistingContainers + newCapacityContainers;
if (totalContainers > warehouse.capacityContainers) {
throw new BadRequestException(
`Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`,
);
}
}
}
} }

View File

@@ -1,4 +1,4 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
@@ -43,6 +43,7 @@ export class WarehouseZonesService {
// Ensure the parent yard exists. // Ensure the parent yard exists.
await this.yardsService.findById(yardId); await this.yardsService.findById(yardId);
await this.assertCodeUnique(yardId, dto.code.trim()); await this.assertCodeUnique(yardId, dto.code.trim());
await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
return this.zonesRepository.create({ return this.zonesRepository.create({
yardId, yardId,
@@ -68,14 +69,22 @@ export class WarehouseZonesService {
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
} }
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
// Validate updated capacity doesn't exceed yard limits
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id);
}
const status = dto.status ?? existing.status; const status = dto.status ?? existing.status;
const updated = await this.zonesRepository.update(id, { const updated = await this.zonesRepository.update(id, {
name: dto.name?.trim() ?? existing.name, name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code, code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type, type: dto.type ?? existing.type,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight, capacityWeight: newCapacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers, capacityContainers: newCapacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight, maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume, maxVolume: dto.maxVolume ?? existing.maxVolume,
status, status,
@@ -96,4 +105,39 @@ export class WarehouseZonesService {
throw new ConflictException(`Zone code ${code} already exists in this yard`); throw new ConflictException(`Zone code ${code} already exists in this yard`);
} }
} }
private async assertCapacityWithinYard(
yardId: string,
newCapacityWeight: number | null,
newCapacityContainers: number | null,
excludeZoneId?: string,
): Promise<void> {
const yard = await this.yardsService.findById(yardId);
const zones = await this.findByYard(yardId);
// Sum existing zone capacities, excluding the zone being updated if provided
const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones;
const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0);
const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0);
// Check weight capacity
if (newCapacityWeight !== null && yard.capacityWeight != null) {
const totalWeight = totalExistingWeight + newCapacityWeight;
if (totalWeight > yard.capacityWeight) {
throw new BadRequestException(
`Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`,
);
}
}
// Check container capacity
if (newCapacityContainers !== null && yard.capacityContainers != null) {
const totalContainers = totalExistingContainers + newCapacityContainers;
if (totalContainers > yard.capacityContainers) {
throw new BadRequestException(
`Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`,
);
}
}
}
} }

View File

@@ -808,41 +808,41 @@ export const POSITION_PERMISSION_PRESETS = {
// permission catalog (all CRUD across bookings, contracts, scheduling, // permission catalog (all CRUD across bookings, contracts, scheduling,
// fleet, warehouse, mile, finance, settings, staff). // fleet, warehouse, mile, finance, settings, staff).
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
// Dispatcher: warehouse floor operations — receive/GRN, move, load/unload, // Dispatcher: full CRUD on warehouse management (incl. import/export/intercity
// inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, // inventory flows) and fleet management, plus truck dispatch on the mile legs
// plus truck dispatch on the mile legs and read-only operational context. // and operational context. The ONE carve-out: allocation & fee rules stay
// Allocation & fee rules are VIEW-ONLY — never create/update/delete. // VIEW-ONLY — a dispatcher never creates/updates/deletes those rules.
dispatcher: dedupe([ dispatcher: dedupe([
// Warehouse management — full CRUD.
FREIGHT_PERMS.warehouseDashboard.view, FREIGHT_PERMS.warehouseDashboard.view,
FREIGHT_PERMS.warehouses.view, ...Object.values(FREIGHT_PERMS.warehouses),
FREIGHT_PERMS.warehouseYards.view, ...Object.values(FREIGHT_PERMS.warehouseYards),
FREIGHT_PERMS.warehouseZones.view, ...Object.values(FREIGHT_PERMS.warehouseZones),
FREIGHT_PERMS.warehouseInventory.view, ...Object.values(FREIGHT_PERMS.warehouseInventory),
FREIGHT_PERMS.warehouseInventory.receive, ...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
FREIGHT_PERMS.warehouseInventory.move, ...Object.values(FREIGHT_PERMS.interchangeDocuments),
FREIGHT_PERMS.warehouseInventory.load, ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
FREIGHT_PERMS.warehouseInventory.unload,
FREIGHT_PERMS.warehouseInventory.dispatch,
FREIGHT_PERMS.warehouseInventory.gatePass,
FREIGHT_PERMS.warehouseInventory.release,
FREIGHT_PERMS.warehouseInventory.deliver,
FREIGHT_PERMS.warehouseInventory.inspect,
FREIGHT_PERMS.warehouseInspectionReports.view,
FREIGHT_PERMS.warehouseInspectionReports.create,
FREIGHT_PERMS.warehouseInspectionReports.update,
FREIGHT_PERMS.interchangeDocuments.view,
FREIGHT_PERMS.interchangeDocuments.generate,
FREIGHT_PERMS.interchangeDocuments.acknowledge,
FREIGHT_PERMS.warehouseFeeInvoices.view,
FREIGHT_PERMS.warehouseFeeInvoices.generate,
// View-only on the rules that govern allocation and fees. // View-only on the rules that govern allocation and fees.
FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view, FREIGHT_PERMS.warehouseFeeRules.view,
// Fleet management — full CRUD.
...Object.values(FREIGHT_PERMS.fleet),
FREIGHT_PERMS.fleetDashboard.view,
...Object.values(FREIGHT_PERMS.fleetReports),
...Object.values(FREIGHT_PERMS.vehicles),
...Object.values(FREIGHT_PERMS.drivers),
...Object.values(FREIGHT_PERMS.tracking),
...Object.values(FREIGHT_PERMS.fuel),
...Object.values(FREIGHT_PERMS.maintenance),
...Object.values(FREIGHT_PERMS.locomotives),
...Object.values(FREIGHT_PERMS.wagons),
...Object.values(FREIGHT_PERMS.trains),
...Object.values(FREIGHT_PERMS.routes),
...Object.values(FREIGHT_PERMS.containers),
...Object.values(FREIGHT_PERMS.cargoes),
// Truck dispatch on the EDR mile legs + operational context. // Truck dispatch on the EDR mile legs + operational context.
FREIGHT_PERMS.firstMile.view, ...Object.values(FREIGHT_PERMS.firstMile),
FREIGHT_PERMS.firstMile.assignVehicles, ...Object.values(FREIGHT_PERMS.lastMile),
FREIGHT_PERMS.lastMile.view,
FREIGHT_PERMS.lastMile.assignVehicles,
FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.bookings.operations,
]), ]),

View File

@@ -61,6 +61,7 @@ const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
) as Partial<T>; ) as Partial<T>;
const emptyAcquisition = { const emptyAcquisition = {
itemName: "",
vehicleId: "", vehicleId: "",
vendorId: "", vendorId: "",
acquisitionType: "PURCHASE" as AcquisitionType, acquisitionType: "PURCHASE" as AcquisitionType,
@@ -239,6 +240,7 @@ export default function ProcurementPage() {
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Item / Asset</Table.Th>
<Table.Th>Vehicle</Table.Th> <Table.Th>Vehicle</Table.Th>
<Table.Th>Type</Table.Th> <Table.Th>Type</Table.Th>
<Table.Th>Date</Table.Th> <Table.Th>Date</Table.Th>
@@ -249,7 +251,7 @@ export default function ProcurementPage() {
<Table.Tbody> <Table.Tbody>
{loadingAcquisitions ? ( {loadingAcquisitions ? (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={5}> <Table.Td colSpan={6}>
<Group justify="center" py="md"> <Group justify="center" py="md">
<Loader size="sm" /> <Loader size="sm" />
</Group> </Group>
@@ -257,7 +259,7 @@ export default function ProcurementPage() {
</Table.Tr> </Table.Tr>
) : acquisitions.length === 0 ? ( ) : acquisitions.length === 0 ? (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={5}> <Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="md"> <Text c="dimmed" ta="center" py="md">
No acquisitions recorded yet. No acquisitions recorded yet.
</Text> </Text>
@@ -266,6 +268,7 @@ export default function ProcurementPage() {
) : null} ) : null}
{acquisitions.map((a: AssetAcquisition) => ( {acquisitions.map((a: AssetAcquisition) => (
<Table.Tr key={a.id}> <Table.Tr key={a.id}>
<Table.Td>{a.itemName || "—"}</Table.Td>
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td> <Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
<Table.Td> <Table.Td>
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}> <Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
@@ -411,31 +414,51 @@ export default function ProcurementPage() {
size="lg" size="lg"
> >
<Stack gap="md"> <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 <Select
label="Vehicle" label="Related vehicle (optional)"
placeholder="Select vehicle" description="Only when the acquisition is a fleet vehicle itself — parts and general procurement stay unlinked."
placeholder="Not tied to a vehicle"
data={vehicleOptions} data={vehicleOptions}
value={acqForm.vehicleId} value={acqForm.vehicleId}
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })} onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
searchable searchable
clearable clearable
/> />
<Select <Group gap="xs" align="flex-end" wrap="nowrap">
label="Vendor" <Select
placeholder="Select vendor" style={{ flex: 1 }}
data={vendorOptions} label="Vendor"
value={acqForm.vendorId} placeholder="Select vendor"
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })} data={vendorOptions}
searchable value={acqForm.vendorId}
clearable onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
/> searchable
clearable
/>
<Button variant="light" size="sm" onClick={() => setVendorModalOpen(true)}>
Register vendor
</Button>
</Group>
<Select <Select
label="Acquisition Type" label="Acquisition Type"
data={ACQUISITION_TYPES} data={ACQUISITION_TYPES}
value={acqForm.acquisitionType} value={acqForm.acquisitionType}
onChange={(val) => onChange={(val) => {
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" }) 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 required
/> />
<TextInput <TextInput
@@ -471,28 +494,32 @@ export default function ProcurementPage() {
decimalScale={2} decimalScale={2}
min={0} min={0}
/> />
<TextInput {acqForm.acquisitionType !== "PURCHASE" && (
label="Lease Start" <>
type="date" <TextInput
value={acqForm.leaseStart} label="Lease Start"
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })} type="date"
/> value={acqForm.leaseStart}
<TextInput onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
label="Lease End" />
type="date" <TextInput
value={acqForm.leaseEnd} label="Lease End"
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })} type="date"
/> value={acqForm.leaseEnd}
<NumberInput onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
label="Monthly Payment" />
placeholder="0.00" <NumberInput
value={acqForm.monthlyPayment} label="Monthly Payment"
onChange={(val) => placeholder="0.00"
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined }) value={acqForm.monthlyPayment}
} onChange={(val) =>
decimalScale={2} setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
min={0} }
/> decimalScale={2}
min={0}
/>
</>
)}
<Select <Select
label="Status" label="Status"
data={ACQUISITION_STATUSES} data={ACQUISITION_STATUSES}
@@ -514,7 +541,7 @@ export default function ProcurementPage() {
<Button <Button
onClick={() => createAcquisition.mutate()} onClick={() => createAcquisition.mutate()}
loading={createAcquisition.isPending} loading={createAcquisition.isPending}
disabled={!acqForm.acquisitionDate} disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2}
> >
Save Acquisition Save Acquisition
</Button> </Button>

View File

@@ -20,6 +20,7 @@ export interface Vendor {
export interface AssetAcquisition { export interface AssetAcquisition {
id: string; id: string;
itemName?: string | null;
vehicleId?: string | null; vehicleId?: string | null;
vendorId?: string | null; vendorId?: string | null;
acquisitionType: AcquisitionType; acquisitionType: AcquisitionType;