246 lines
13 KiB
TypeScript
246 lines
13 KiB
TypeScript
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
|
import { Head, Link, router, useForm } from '@inertiajs/react';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
|
import { Input } from '@/Components/ui/input';
|
|
import { Label } from '@/Components/ui/label';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/Components/ui/select';
|
|
import { PageProps } from '@/types';
|
|
import {
|
|
ChevronLeft, ClipboardList, Loader2, Plus, Save, X, Search
|
|
} from 'lucide-react';
|
|
import { FormEvent, useMemo, useState } from 'react';
|
|
import { MaterialCatalogModal, MaterialOption, MaterialGroupOption } from './MaterialCatalogModal';
|
|
|
|
interface ProjectOption {
|
|
id: number; ulid: string; name: string; code: string;
|
|
}
|
|
interface ItemEntry { material_ulid: string; quantity: string; unit_cost: string; }
|
|
|
|
interface Props extends PageProps {
|
|
requisition?: any; // If editing
|
|
materials: MaterialOption[];
|
|
materialGroups: MaterialGroupOption[];
|
|
}
|
|
|
|
export default function Form({ requisition, materials, materialGroups }: Props) {
|
|
const isEditing = !!requisition;
|
|
const [catalogModalOpen, setCatalogModalOpen] = useState(false);
|
|
const [activeRowIndex, setActiveRowIndex] = useState<number | null>(null);
|
|
|
|
const { data, setData, post, put, processing, errors } = useForm({
|
|
notes: requisition?.notes || '',
|
|
items: (requisition?.items || [{ material_ulid: '', quantity: '', unit_cost: '' }]).map((i: any) => ({
|
|
material_ulid: i.material?.ulid || i.material_ulid || '',
|
|
quantity: i.quantity || '',
|
|
unit_cost: i.unit_cost || '',
|
|
})) as ItemEntry[],
|
|
});
|
|
|
|
const materialSelectItems = useMemo(() => materials.map(m => ({ value: m.ulid, label: `${m.name} (${m.unit})` })), [materials]);
|
|
|
|
const addItem = () => setData('items', [...data.items, { material_ulid: '', quantity: '', unit_cost: '' }]);
|
|
const removeItem = (idx: number) => setData('items', data.items.filter((_, i) => i !== idx));
|
|
|
|
const updateItem = (idx: number, field: keyof ItemEntry, value: string) => {
|
|
setData('items', data.items.map((item, i) => i === idx ? { ...item, [field]: value } : item));
|
|
};
|
|
|
|
const openCatalogModal = (idx: number | null = null) => {
|
|
setActiveRowIndex(idx);
|
|
setCatalogModalOpen(true);
|
|
};
|
|
|
|
const handleCatalogMultipleSelect = (selectedMaterials: MaterialOption[]) => {
|
|
if (selectedMaterials.length === 0) return;
|
|
|
|
const newItems: ItemEntry[] = selectedMaterials.map(m => ({
|
|
material_ulid: m.ulid,
|
|
quantity: '1',
|
|
unit_cost: m.unit_cost || ''
|
|
}));
|
|
|
|
const updatedItems = [...data.items];
|
|
|
|
if (activeRowIndex !== null) {
|
|
// Replace the specific row with the first selected item, then append the rest
|
|
const existingRow = updatedItems[activeRowIndex];
|
|
if (!existingRow.material_ulid && !existingRow.quantity && selectedMaterials.length === 1) {
|
|
updatedItems[activeRowIndex] = newItems[0];
|
|
} else if (!existingRow.material_ulid && !existingRow.quantity) {
|
|
updatedItems.splice(activeRowIndex, 1, ...newItems);
|
|
} else {
|
|
updatedItems[activeRowIndex] = { ...existingRow, material_ulid: newItems[0].material_ulid, unit_cost: newItems[0].unit_cost };
|
|
if (newItems.length > 1) {
|
|
updatedItems.push(...newItems.slice(1));
|
|
}
|
|
}
|
|
} else {
|
|
// Check if there's a single empty item at the end to reuse
|
|
const lastItem = updatedItems[updatedItems.length - 1];
|
|
if (updatedItems.length === 1 && !lastItem.material_ulid && !lastItem.quantity) {
|
|
updatedItems.splice(0, 1, ...newItems);
|
|
} else {
|
|
updatedItems.push(...newItems);
|
|
}
|
|
}
|
|
|
|
setData('items', updatedItems);
|
|
};
|
|
|
|
const handleCatalogKitSelect = (group: MaterialGroupOption, quantityMultiplier: number) => {
|
|
// Group is now the Kit itself! We add it as a single line item to retain the 'container' context
|
|
const newItems: ItemEntry[] = [{
|
|
material_ulid: group.ulid,
|
|
quantity: String(quantityMultiplier),
|
|
unit_cost: '0' // Unit cost will be read from the item if it has one, or user can input
|
|
}];
|
|
|
|
const updatedItems = [...data.items];
|
|
if (activeRowIndex !== null) {
|
|
// If the active row is completely empty, replace it, then append the rest
|
|
if (!updatedItems[activeRowIndex].material_ulid && !updatedItems[activeRowIndex].quantity) {
|
|
updatedItems.splice(activeRowIndex, 1, ...newItems);
|
|
} else {
|
|
// Otherwise just append to the end of the array
|
|
updatedItems.push(...newItems);
|
|
}
|
|
} else {
|
|
updatedItems.push(...newItems);
|
|
}
|
|
setData('items', updatedItems);
|
|
};
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
e.preventDefault();
|
|
// filter out empty items
|
|
const payloadItems = data.items.filter(i => i.material_ulid && i.quantity);
|
|
setData('items', payloadItems);
|
|
|
|
if (isEditing) {
|
|
put(route('requisitions.update', requisition.ulid));
|
|
} else {
|
|
post(route('requisitions.store'));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AuthenticatedLayout
|
|
header={
|
|
<div className="flex items-center gap-4">
|
|
<Link href={route('requisitions.index')}>
|
|
<Button variant="ghost" size="icon"><ChevronLeft className="h-5 w-5" /></Button>
|
|
</Link>
|
|
<ClipboardList className="h-5 w-5" />
|
|
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
|
{isEditing ? `Edit Requisition: ${requisition.document_number}` : 'Create Material Requisition'}
|
|
</h2>
|
|
</div>
|
|
}
|
|
>
|
|
<Head title={isEditing ? 'Edit Requisition' : 'New Requisition'} />
|
|
|
|
<div className="py-6"><div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
|
|
<form onSubmit={handleSubmit}>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Requisition Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
|
<div>
|
|
<Label>Optional Notes</Label>
|
|
<Input value={data.notes || ''} onChange={e => setData('notes', e.target.value)} placeholder="E.g. required immediately..." />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<Label className="text-sm font-semibold">Materials / Items</Label>
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" size="sm" onClick={() => openCatalogModal(null)} className="text-indigo-600 border-indigo-200 hover:bg-indigo-50">
|
|
<Search className="mr-1 h-4 w-4" /> Browse Catalog
|
|
</Button>
|
|
<Button type="button" variant="outline" size="sm" onClick={addItem}>
|
|
<Plus className="mr-1 h-4 w-4" /> Add Empty Row
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{errors.items && <p className="mb-4 text-sm text-red-500">{errors.items}</p>}
|
|
|
|
<div className="space-y-3">
|
|
{data.items.map((item, idx) => (
|
|
<div key={idx} className="flex flex-wrap md:flex-nowrap items-end gap-2 rounded-md border p-4 bg-gray-50/50">
|
|
<div className="flex-1 w-full md:w-auto min-w-[200px]">
|
|
<Label className="text-xs text-gray-500">Material *</Label>
|
|
{item.material_ulid ? (
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<div className="flex-1 border rounded-md px-3 py-2 bg-white text-sm">
|
|
{materials.find(m => m.ulid === item.material_ulid)?.name || 'Unknown Material'}
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => openCatalogModal(idx)}>
|
|
Change
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full justify-start mt-1 text-gray-500"
|
|
onClick={() => openCatalogModal(idx)}
|
|
>
|
|
<Search className="h-4 w-4 mr-2" />
|
|
Select Material...
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="w-24">
|
|
<Label className="text-xs text-gray-500">Qty *</Label>
|
|
<Input type="number" step="0.01" min="0.01" value={item.quantity}
|
|
onChange={e => updateItem(idx, 'quantity', e.target.value)} />
|
|
</div>
|
|
<div className="w-32">
|
|
<Label className="text-xs text-gray-500">Est. Unit Cost</Label>
|
|
<Input type="number" step="0.01" value={item.unit_cost}
|
|
onChange={e => updateItem(idx, 'unit_cost', e.target.value)} />
|
|
</div>
|
|
{data.items.length > 1 && (
|
|
<div className="w-10 flex justify-end">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeItem(idx)}>
|
|
<X className="h-5 w-5 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-6 border-t gap-2">
|
|
<Link href={route('requisitions.index')}>
|
|
<Button type="button" variant="ghost">Cancel</Button>
|
|
</Link>
|
|
<Button type="submit" disabled={processing || data.items.every(i => !i.material_ulid)}>
|
|
{processing ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving...</>
|
|
: <><Save className="mr-2 h-4 w-4" /> Save Requisition</>}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
</div></div>
|
|
|
|
<MaterialCatalogModal
|
|
open={catalogModalOpen}
|
|
onOpenChange={setCatalogModalOpen}
|
|
materials={materials}
|
|
materialGroups={materialGroups}
|
|
onSelectMaterials={handleCatalogMultipleSelect}
|
|
onAddKit={handleCatalogKitSelect}
|
|
/>
|
|
</AuthenticatedLayout>
|
|
);
|
|
}
|