Files
GSB-Construction/Modules/MaterialLogistics/resources/js/Pages/Inventory/OperationsForm.tsx

518 lines
34 KiB
TypeScript

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Textarea } from '@/Components/ui/textarea';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/Components/ui/tabs';
import { Badge } from '@/Components/ui/badge';
import { PageProps } from '@/types';
import {
ArrowDownToLine, ArrowRightLeft, ArrowUpFromLine, RotateCcw,
ChevronLeft, Loader2, Trash2, Search, PackageOpen
} from 'lucide-react';
import { FormEvent, useMemo, useState } from 'react';
interface WarehouseStock { warehouse_id: number; quantity: string; }
interface ProjectInventory { project_id: number; on_hand_qty: string; allocated_qty: string; }
interface MaterialRef {
id: number; ulid: string; name: string; sku?: string; unit: string; unit_cost: string;
warehouse_stocks: WarehouseStock[];
project_inventories: ProjectInventory[];
}
interface WarehouseRef { id: number; ulid: string; name: string; code: string; }
interface ProjectRef { id: number; ulid: string; name: string; code: string; }
interface UserRef { id: number; name: string; }
interface Props extends PageProps {
warehouses: WarehouseRef[];
projects: ProjectRef[];
materials: MaterialRef[];
projectManagers: UserRef[];
defaultType: 'dispatch' | 'return' | 'adjust';
}
export default function OperationsForm({ warehouses, projects, materials, projectManagers, defaultType }: Props) {
const params = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
const { data, setData, post, processing, errors } = useForm({
warehouse_ulid: params.get('warehouse_ulid') || '',
project_ulid: params.get('project_ulid') || '',
location_type: 'warehouse' as 'warehouse' | 'project',
location_ulid: '',
direction: 'in' as 'in' | 'out',
handler_id: '',
notes: '',
reason: '',
items: [] as { material_ulid: string; quantity: string }[]
});
const [searchQuery, setSearchQuery] = useState('');
const [stockFilter, setStockFilter] = useState<'all' | 'unallocated' | 'allocated'>('all');
const updateForm = (key: string, value: string | null) => {
if (value !== null) setData(key as any, value);
};
const getAvailableStock = (materialUlid: string, filterType: 'unallocated' | 'allocated' = 'unallocated') => {
if (!materialUlid) return null;
const mat = materials.find(m => m.ulid === materialUlid);
if (!mat) return null;
if (defaultType === 'dispatch') {
if (!data.warehouse_ulid) return null;
const wId = warehouses.find(w => w.ulid === data.warehouse_ulid)?.id;
const ws = mat.warehouse_stocks.find(s => s.warehouse_id === wId);
return filterType === 'unallocated' ? (ws ? Number(ws.quantity) : 0) : 0; // Warehouses don't use allocated
}
if (defaultType === 'return' || (defaultType === 'adjust' && data.location_type === 'project' && data.direction === 'out')) {
const pUlid = defaultType === 'return' ? data.project_ulid : data.location_ulid;
if (!pUlid) return null;
const pId = projects.find(p => p.ulid === pUlid)?.id;
const pi = mat.project_inventories.find(s => s.project_id === pId);
if (!pi) return 0;
return filterType === 'unallocated' ? (Number(pi.on_hand_qty) - Number(pi.allocated_qty)) : Number(pi.allocated_qty);
}
if (defaultType === 'adjust' && data.location_type === 'warehouse' && data.direction === 'out') {
if (!data.location_ulid) return null;
const wId = warehouses.find(w => w.ulid === data.location_ulid)?.id;
const ws = mat.warehouse_stocks.find(s => s.warehouse_id === wId);
return filterType === 'unallocated' ? (ws ? Number(ws.quantity) : 0) : 0;
}
return null; // N/A for receiving or adding stock
};
const addToCart = (materialUlid: string) => {
const unallocatedMax = getAvailableStock(materialUlid, 'unallocated');
const itemIndex = data.items.findIndex(i => i.material_ulid === materialUlid);
if (itemIndex > -1) {
// Already in cart, increment quantity if within limit
const newItems = [...data.items];
const currentQty = Number(newItems[itemIndex].quantity || 0);
if (unallocatedMax !== null && currentQty + 1 > unallocatedMax) {
newItems[itemIndex].quantity = String(unallocatedMax);
} else {
newItems[itemIndex].quantity = String(currentQty + 1);
}
setData('items', newItems);
} else {
// Add new to cart, block if completely out of stock
if (unallocatedMax !== null && unallocatedMax <= 0) return;
setData('items', [...data.items, { material_ulid: materialUlid, quantity: '1' }]);
}
};
const removeLineItem = (index: number) => {
setData('items', data.items.filter((_, i) => i !== index));
};
const updateLineItem = (index: number, field: string, value: string) => {
const newItems = [...data.items];
let finalizedValue = value;
if (field === 'quantity') {
const materialUlid = newItems[index].material_ulid;
const unallocatedMax = getAvailableStock(materialUlid, 'unallocated');
const numVal = Number(value);
if (unallocatedMax !== null && numVal > unallocatedMax) {
finalizedValue = String(unallocatedMax);
}
}
newItems[index] = { ...newItems[index], [field]: finalizedValue };
setData('items', newItems);
};
const submitOperation = (e: FormEvent) => {
e.preventDefault();
const routeMap = {
'dispatch': 'inventory.dispatch',
'return': 'inventory.return',
'adjust': 'inventory.adjust'
};
post(route(routeMap[defaultType]), {
onSuccess: () => {
setData(prev => ({ ...prev, items: [], notes: '', reason: '' }));
}
});
};
const warehouseItems = useMemo(() => warehouses.map(w => ({ value: w.ulid, label: `${w.name} (${w.code})` })), [warehouses]);
const projectItems = useMemo(() => projects.map(p => ({ value: p.ulid, label: `${p.name} (${p.code})` })), [projects]);
const personnelItems = useMemo(() => projectManagers.map(p => ({ value: String(p.id), label: p.name })), [projectManagers]);
// Filter materials for catalog
const filteredMaterials = useMemo(() => {
return materials.filter(m => {
const matchesSearch = m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(m.sku && m.sku.toLowerCase().includes(searchQuery.toLowerCase()));
if (!matchesSearch) return false;
if (stockFilter === 'all') return true;
const unallocated = getAvailableStock(m.ulid, 'unallocated') ?? 0;
const allocated = getAvailableStock(m.ulid, 'allocated') ?? 0;
if (stockFilter === 'unallocated' && unallocated > 0) return true;
if (stockFilter === 'allocated' && allocated > 0) return true;
return false;
});
}, [materials, searchQuery, stockFilter, data]); // Rely on data changes to refresh stock filters
const uiConfig = {
dispatch: { title: 'Dispatch Materials', icon: <ArrowUpFromLine className="h-5 w-5" />, handlerLabel: 'Dispatched By' },
return: { title: 'Return to Warehouse', icon: <RotateCcw className="h-5 w-5" />, handlerLabel: 'Returned By' },
adjust: { title: 'Adjust Inventory', icon: <ArrowRightLeft className="h-5 w-5" />, handlerLabel: 'Adjusted By' },
}[defaultType];
const isSourceSelected = useMemo(() => {
if (defaultType === 'dispatch') return !!data.warehouse_ulid;
if (defaultType === 'return') return !!data.project_ulid;
if (defaultType === 'adjust') return data.direction === 'in' ? !!data.location_ulid : !!data.location_ulid;
return false;
}, [defaultType, data]);
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-4">
<Link href={route('inventory.index')}>
<Button variant="ghost" size="icon"><ChevronLeft className="h-5 w-5" /></Button>
</Link>
<div className="text-gray-500">{uiConfig.icon}</div>
<h2 className="text-xl font-semibold leading-tight text-gray-800">
{uiConfig.title}
</h2>
</div>
}
>
<Head title={uiConfig.title} />
<div className="py-6 px-4 sm:px-6 lg:px-8 w-full max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT PANE: Form & Cart */}
<div className="lg:col-span-5 flex flex-col gap-6">
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50 border-b">
<CardTitle className="text-lg">Operation Settings</CardTitle>
<CardDescription>Select locations and personnel.</CardDescription>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{defaultType === 'dispatch' && (
<>
<div>
<Label>From Warehouse *</Label>
<Select value={data.warehouse_ulid} onValueChange={(v) => updateForm('warehouse_ulid', v)} items={warehouseItems}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map(w => <SelectItem key={w.ulid} value={w.ulid}>{w.name}</SelectItem>)}</SelectContent>
</Select>
{errors.warehouse_ulid && <p className="text-sm text-red-500 mt-1">{errors.warehouse_ulid}</p>}
</div>
<div>
<Label>To Project *</Label>
<Select value={data.project_ulid} onValueChange={(v) => updateForm('project_ulid', v)} items={projectItems}>
<SelectTrigger><SelectValue placeholder="Select project" /></SelectTrigger>
<SelectContent>{projects.map(p => <SelectItem key={p.ulid} value={p.ulid}>{p.name}</SelectItem>)}</SelectContent>
</Select>
{errors.project_ulid && <p className="text-sm text-red-500 mt-1">{errors.project_ulid}</p>}
</div>
</>
)}
{defaultType === 'return' && (
<>
<div>
<Label>From Project *</Label>
<Select value={data.project_ulid} onValueChange={(v) => updateForm('project_ulid', v)} items={projectItems}>
<SelectTrigger><SelectValue placeholder="Select project" /></SelectTrigger>
<SelectContent>{projects.map(p => <SelectItem key={p.ulid} value={p.ulid}>{p.name}</SelectItem>)}</SelectContent>
</Select>
{errors.project_ulid && <p className="text-sm text-red-500 mt-1">{errors.project_ulid}</p>}
</div>
<div>
<Label>To Warehouse *</Label>
<Select value={data.warehouse_ulid} onValueChange={(v) => updateForm('warehouse_ulid', v)} items={warehouseItems}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map(w => <SelectItem key={w.ulid} value={w.ulid}>{w.name}</SelectItem>)}</SelectContent>
</Select>
{errors.warehouse_ulid && <p className="text-sm text-red-500 mt-1">{errors.warehouse_ulid}</p>}
</div>
</>
)}
{defaultType === 'adjust' && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Location Target *</Label>
<Select value={data.location_type} onValueChange={(v) => updateForm('location_type', v)} items={[{value: 'warehouse', label: 'Warehouse'}, {value: 'project', label: 'Project'}]}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="warehouse">Warehouse</SelectItem>
<SelectItem value="project">Project</SelectItem>
</SelectContent>
</Select>
{errors.location_type && <p className="text-sm text-red-500 mt-1">{errors.location_type}</p>}
</div>
<div>
<Label>Direction *</Label>
<Select value={data.direction} onValueChange={(v) => updateForm('direction', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="in">Add Stock (In)</SelectItem>
<SelectItem value="out">Remove Stock (Out)</SelectItem>
</SelectContent>
</Select>
{errors.direction && <p className="text-sm text-red-500 mt-1">{errors.direction}</p>}
</div>
</div>
<div>
<Label>{data.location_type === 'warehouse' ? 'Warehouse' : 'Project'} *</Label>
<Select value={data.location_ulid} onValueChange={(v) => updateForm('location_ulid', v)}>
<SelectTrigger><SelectValue placeholder="Select specific location" /></SelectTrigger>
<SelectContent>
{data.location_type === 'warehouse'
? warehouses.map(w => <SelectItem key={w.ulid} value={w.ulid}>{w.name}</SelectItem>)
: projects.map(p => <SelectItem key={p.ulid} value={p.ulid}>{p.name}</SelectItem>)
}
</SelectContent>
</Select>
{errors.location_ulid && <p className="text-sm text-red-500 mt-1">{errors.location_ulid}</p>}
</div>
</>
)}
<div>
<Label>{uiConfig.handlerLabel} *</Label>
<Select value={data.handler_id} onValueChange={(v) => updateForm('handler_id', v)} items={personnelItems}>
<SelectTrigger><SelectValue placeholder="Select Project Manager" /></SelectTrigger>
<SelectContent>{projectManagers.map(pm => <SelectItem key={pm.id} value={String(pm.id)}>{pm.name}</SelectItem>)}</SelectContent>
</Select>
{errors.handler_id && <p className="text-sm text-red-500 mt-1">{errors.handler_id}</p>}
</div>
</CardContent>
</Card>
<Card className="shadow-sm flex-1 flex flex-col">
<CardHeader className="bg-gray-50/50 border-b flex flex-row items-center justify-between py-4">
<div>
<CardTitle className="text-lg">Cart Items</CardTitle>
<CardDescription>Materials to process.</CardDescription>
</div>
<Badge variant="secondary" className="px-3 py-1 text-sm font-medium">
{data.items.length} items
</Badge>
</CardHeader>
<CardContent className="pt-4 flex-1 flex flex-col">
{typeof errors.items === 'string' && <p className="text-sm text-red-500 mb-4">{errors.items}</p>}
{data.items.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 border-2 border-dashed rounded-lg bg-gray-50">
<PackageOpen className="h-10 w-10 text-gray-400 mb-3" />
<p className="text-sm text-gray-500">Cart is empty.<br/>Select materials from the catalog.</p>
</div>
) : (
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2">
{data.items.map((item, idx) => {
const mat = materials.find(m => m.ulid === item.material_ulid);
const unallocatedStock = getAvailableStock(item.material_ulid, 'unallocated');
const errorQty = (errors as any)[`items.${idx}.quantity`];
return (
<div key={idx} className="flex flex-col gap-2 p-3 bg-white border shadow-sm rounded-md relative group transition-all hover:bg-gray-50">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{mat?.name || 'Unknown'}</p>
<p className="text-xs text-gray-500 uppercase tracking-wide">{mat?.sku || 'No SKU'}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-600 h-8 w-8 -mt-1 -mr-1"
onClick={() => removeLineItem(idx)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="mt-2 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-gray-600 shrink-0">Quantity</Label>
<div className="relative">
<Input
type="number" step="0.01" min="0.01"
className="w-24 text-right pr-8 font-medium h-9"
value={item.quantity}
onChange={(e) => updateLineItem(idx, 'quantity', e.target.value)}
/>
<span className="absolute right-3 top-2.5 text-xs text-gray-500">{mat?.unit}</span>
</div>
</div>
{unallocatedStock !== null && (
<div className="text-xs text-right">
<span className="text-gray-500">Available: </span>
<span className={`font-semibold ${Number(item.quantity) > unallocatedStock ? 'text-red-600' : 'text-green-600'}`}>
{unallocatedStock}
</span>
</div>
)}
</div>
{errorQty && <p className="text-xs text-red-500 mt-1">{errorQty}</p>}
</div>
);
})}
</div>
)}
</CardContent>
<div className="p-4 bg-gray-50/80 border-t space-y-4 rounded-b-xl">
<div>
<Label className="text-sm">{defaultType === 'adjust' ? 'Reason for Adjustment *' : 'Notes (Optional)'}</Label>
<Textarea
value={defaultType === 'adjust' ? data.reason : data.notes}
onChange={(e) => updateForm(defaultType === 'adjust' ? 'reason' : 'notes', e.target.value)}
rows={2}
className="mt-1 resize-none bg-white"
placeholder={defaultType === 'adjust' ? "e.g. Audit discrepancy..." : "Remarks..."}
/>
{(errors.reason || errors.notes) && <p className="text-sm text-red-500 mt-1">{errors.reason || errors.notes}</p>}
</div>
<Button
className="w-full h-12 text-base font-medium shadow-sm hover:shadow-md transition-shadow"
onClick={submitOperation}
disabled={processing || data.items.length === 0}
>
{processing ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : uiConfig.icon}
<span className="ml-2">Submit {uiConfig.title}</span>
</Button>
</div>
</Card>
</div>
{/* RIGHT PANE: Catalog */}
<div className="lg:col-span-7 h-full flex flex-col">
<Card className="h-full flex flex-col shadow-sm border-gray-200/60 sticky top-6">
<CardHeader className="pb-4 pt-5 px-5">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
Material Catalog
</CardTitle>
<CardDescription>Click materials to add to cart</CardDescription>
</div>
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="Search name or SKU..."
className="pl-9 h-10 border-gray-300 focus:border-blue-500 focus:ring-blue-200 transition-all"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<Tabs value={stockFilter} onValueChange={(v: any) => setStockFilter(v)} className="w-full">
<TabsList className="w-full bg-gray-100 p-1 rounded-lg">
<TabsTrigger value="all" className="flex-1 py-2 text-sm">All Materials</TabsTrigger>
<TabsTrigger value="unallocated" className="flex-1 py-2 text-sm relative">
Unallocated Stock
</TabsTrigger>
<TabsTrigger value="allocated" className="flex-1 py-2 text-sm">Allocated Stock</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent className="bg-gray-50/50 p-5 flex-1 overflow-y-auto min-h-[500px]">
{!isSourceSelected ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-70 mt-20">
<div className="bg-white p-6 rounded-full shadow-sm mb-4">
<ArrowUpFromLine className="h-10 w-10 text-blue-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1">Select a Source Location</h3>
<p className="text-gray-500 max-w-sm">
Please configure the form panel on the left to see available materials for this operation.
</p>
</div>
) : filteredMaterials.length === 0 ? (
<div className="text-center py-20 text-gray-500">
No materials match your search or filter configuration.
</div>
) : (
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4 auto-rows-max">
{filteredMaterials.map(m => {
const unallocated = getAvailableStock(m.ulid, 'unallocated') ?? 0;
const allocated = getAvailableStock(m.ulid, 'allocated') ?? 0;
const hasStock = unallocated > 0 || allocated > 0;
const isSelected = data.items.some(i => i.material_ulid === m.ulid);
return (
<div
key={m.ulid}
onClick={() => { if (hasStock || unallocated === null) addToCart(m.ulid) }}
className={`
relative flex flex-col bg-white border rounded-xl overflow-hidden transition-all duration-200
${(!hasStock && unallocated !== null) ? 'opacity-50 cursor-not-allowed bg-gray-50' : 'cursor-pointer hover:shadow-md hover:border-blue-300 transform hover:-translate-y-0.5'}
${isSelected ? 'ring-2 ring-blue-500 border-blue-500' : 'border-gray-200'}
`}
>
{(!hasStock && unallocated !== null) && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-red-100 text-red-600 font-bold px-3 py-1 rounded w-[80%] text-center text-sm shadow-sm z-10 -rotate-12">
OUT OF STOCK
</div>
)}
{isSelected && (
<div className="absolute top-0 right-0 bg-blue-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-bl-lg z-10">
IN CART
</div>
)}
<div className="p-4 flex-1">
<div className="font-semibold text-gray-900 leading-tight mb-1 truncate" title={m.name}>{m.name}</div>
<div className="text-xs text-gray-500 uppercase font-medium">{m.sku || 'No SKU'}</div>
</div>
<div className="bg-gray-50/80 px-4 py-3 border-t grid grid-cols-2 gap-2 text-xs">
<div className="flex flex-col">
<span className="text-gray-400 font-medium">Unallocated</span>
<span className={`font-bold text-sm ${unallocated > 0 ? 'text-green-600' : 'text-gray-700'}`}>
{unallocated} {m.unit}
</span>
</div>
<div className="flex flex-col border-l pl-2">
<span className="text-gray-400 font-medium">Allocated</span>
<span className={`font-bold text-sm ${allocated > 0 ? 'text-amber-600' : 'text-gray-700'}`}>
{allocated} {m.unit}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}