708 lines
40 KiB
TypeScript
708 lines
40 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 { Badge } from '@/Components/ui/badge';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/Components/ui/dialog';
|
|
import { PageProps } from '@/types';
|
|
import {
|
|
ChevronLeft, ClipboardList, Loader2, Plus, Save, X, Search, ChevronDown, CheckCircle2,
|
|
Lock, Package, Sparkles, AlertCircle, Info, Layers
|
|
} from 'lucide-react';
|
|
import { FormEvent, useMemo, useState, useEffect } from 'react';
|
|
import { MaterialCatalogModal, MaterialOption, MaterialGroupOption } from './MaterialCatalogModal';
|
|
|
|
interface RemainingEstimate {
|
|
material_ulid: string;
|
|
material_name: string;
|
|
unit: string;
|
|
quantity: number;
|
|
unit_cost: number;
|
|
estimated_qty?: number;
|
|
remaining_qty?: number;
|
|
}
|
|
|
|
interface ProjectOption {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
code: string;
|
|
client_name?: string | null;
|
|
location?: string | null;
|
|
status?: string | null;
|
|
start_date?: string | null;
|
|
target_end_date?: string | null;
|
|
contract_value?: string | number | null;
|
|
contractor?: { id: number; ulid: string; company_name: string } | null;
|
|
has_remaining_estimates?: boolean;
|
|
remaining_estimates?: RemainingEstimate[];
|
|
}
|
|
|
|
interface ItemEntry {
|
|
material_ulid: string;
|
|
quantity: string;
|
|
unit_cost: string;
|
|
is_unestimated?: boolean;
|
|
}
|
|
|
|
interface Props extends PageProps {
|
|
requisition?: any; // If editing
|
|
materials: MaterialOption[];
|
|
materialGroups: MaterialGroupOption[];
|
|
projects: ProjectOption[];
|
|
selectedProject?: ProjectOption | null;
|
|
prefilledItems?: RemainingEstimate[];
|
|
hasRemainingEstimates?: boolean;
|
|
}
|
|
|
|
interface ProjectLookupProps {
|
|
projects: ProjectOption[];
|
|
selectedId: string;
|
|
onSelect: (ulid: string) => void;
|
|
}
|
|
|
|
function ProjectLookup({ projects, selectedId, onSelect }: ProjectLookupProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [tempSelectedId, setTempSelectedId] = useState(selectedId);
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
if (!search) return projects;
|
|
const lowerSearch = search.toLowerCase();
|
|
return projects.filter(p =>
|
|
p.name.toLowerCase().includes(lowerSearch) ||
|
|
p.code.toLowerCase().includes(lowerSearch)
|
|
);
|
|
}, [projects, search]);
|
|
|
|
const handleConfirm = () => {
|
|
onSelect(tempSelectedId);
|
|
setOpen(false);
|
|
};
|
|
|
|
const handleOpenChange = (newOpen: boolean) => {
|
|
setOpen(newOpen);
|
|
if (newOpen) {
|
|
setTempSelectedId(selectedId);
|
|
setSearch('');
|
|
}
|
|
};
|
|
|
|
const selectedProject = projects.find(p => p.ulid === selectedId);
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
onClick={() => handleOpenChange(true)}
|
|
className={`w-full justify-between font-normal mt-1 border-slate-250 ${!selectedId ? 'text-muted-foreground' : ''}`}
|
|
type="button"
|
|
>
|
|
{selectedProject ? (
|
|
<span className="truncate font-medium text-slate-800">{selectedProject.code} - {selectedProject.name}</span>
|
|
) : (
|
|
"Select Project"
|
|
)}
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50 ml-2" />
|
|
</Button>
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="sm:max-w-5xl lg:max-w-6xl w-full max-h-[85vh] flex flex-col p-0 gap-0">
|
|
<DialogHeader className="px-6 py-5 border-b">
|
|
<DialogTitle>Select Project</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="px-6 py-5 border-b bg-slate-50/50">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
|
<Input
|
|
placeholder="Search by project name or code..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-9 bg-white border-slate-200"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 max-h-[50vh]">
|
|
{filteredProjects.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-500">
|
|
No projects found matching your search.
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredProjects.map((proj) => {
|
|
const isSelected = tempSelectedId === proj.ulid;
|
|
return (
|
|
<div
|
|
key={proj.id}
|
|
onClick={() => setTempSelectedId(proj.ulid)}
|
|
className={`relative flex flex-col justify-between p-4 rounded-xl border-2 transition-all cursor-pointer select-none text-left ${
|
|
isSelected
|
|
? 'border-indigo-600 bg-indigo-50/30 shadow-sm ring-1 ring-indigo-600/20'
|
|
: 'border-slate-100 bg-white hover:border-slate-300 hover:shadow-sm'
|
|
}`}
|
|
>
|
|
<div>
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<Badge variant="outline" className="font-mono text-[10px] tracking-wider text-slate-500 border-slate-200">
|
|
{proj.code}
|
|
</Badge>
|
|
{isSelected && (
|
|
<CheckCircle2 className="h-4 w-4 text-indigo-600 shrink-0" />
|
|
)}
|
|
</div>
|
|
<h4 className="font-semibold text-slate-900 text-sm line-clamp-1 mb-1" title={proj.name}>
|
|
{proj.name}
|
|
</h4>
|
|
<p className="text-xs text-slate-500 line-clamp-1 mb-3">
|
|
Client: {proj.client_name || 'N/A'}
|
|
</p>
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{proj.has_remaining_estimates ? (
|
|
<Badge variant="secondary" className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200">
|
|
{proj.remaining_estimates?.length ?? 0} Est. Items Available
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="text-[10px] bg-slate-100 text-slate-600 border-slate-200">
|
|
Estimates Exhausted
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{proj.contract_value && (
|
|
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between">
|
|
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-wider whitespace-nowrap">Contract Value</span>
|
|
<span className="text-xs font-semibold text-slate-800 font-mono whitespace-nowrap">
|
|
{new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(proj.contract_value))}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="mx-0 mb-0 px-6 py-4 border-t bg-slate-50/30 flex justify-end gap-2">
|
|
<Button variant="outline" type="button" onClick={() => setOpen(false)}>Cancel</Button>
|
|
<Button type="button" onClick={handleConfirm} disabled={!tempSelectedId} className="bg-indigo-600 hover:bg-indigo-700 text-white">Confirm Selection</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function Form({
|
|
requisition,
|
|
materials,
|
|
materialGroups,
|
|
projects = [],
|
|
selectedProject,
|
|
prefilledItems = [],
|
|
hasRemainingEstimates = false
|
|
}: Props) {
|
|
const isEditing = !!requisition;
|
|
const [catalogModalOpen, setCatalogModalOpen] = useState(false);
|
|
const [activeRowIndex, setActiveRowIndex] = useState<number | null>(null);
|
|
|
|
const initialProjectUlid = requisition?.project?.ulid || selectedProject?.ulid || '';
|
|
const activeProject = projects.find(p => p.ulid === initialProjectUlid);
|
|
const activeHasRemaining = activeProject?.has_remaining_estimates ?? hasRemainingEstimates ?? (prefilledItems && prefilledItems.length > 0);
|
|
const activeRemainingEstimates = activeProject?.remaining_estimates ?? prefilledItems ?? [];
|
|
|
|
const initialRequisitionType = requisition?.requisition_type || (activeHasRemaining ? 'estimated' : 'unestimated');
|
|
|
|
const { data, setData, post, put, processing, errors } = useForm({
|
|
project_ulid: initialProjectUlid,
|
|
requisition_type: initialRequisitionType,
|
|
notes: requisition?.notes || '',
|
|
items: (requisition?.items || (
|
|
initialRequisitionType === 'estimated' && activeRemainingEstimates.length > 0
|
|
? activeRemainingEstimates.map(i => ({
|
|
material_ulid: i.material_ulid,
|
|
quantity: String(i.quantity),
|
|
unit_cost: String(i.unit_cost),
|
|
is_unestimated: false,
|
|
}))
|
|
: [{ material_ulid: '', quantity: '', unit_cost: '', is_unestimated: initialRequisitionType === 'unestimated' }]
|
|
)).map((i: any) => ({
|
|
material_ulid: i.material?.ulid || i.material_ulid || '',
|
|
quantity: i.quantity || '',
|
|
unit_cost: i.unit_cost || '',
|
|
is_unestimated: i.is_unestimated ?? (initialRequisitionType === 'unestimated'),
|
|
})) as ItemEntry[],
|
|
});
|
|
|
|
const addItem = () => setData('items', [...data.items, { material_ulid: '', quantity: '', unit_cost: '', is_unestimated: data.requisition_type === 'unestimated' }]);
|
|
const removeItem = (idx: number) => setData('items', data.items.filter((_, i) => i !== idx));
|
|
|
|
const updateItem = (idx: number, field: keyof ItemEntry, value: any) => {
|
|
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 || '',
|
|
is_unestimated: data.requisition_type === 'unestimated',
|
|
}));
|
|
|
|
const updatedItems = [...data.items];
|
|
|
|
if (activeRowIndex !== null) {
|
|
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 {
|
|
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) => {
|
|
const newItems: ItemEntry[] = [{
|
|
material_ulid: group.ulid,
|
|
quantity: String(quantityMultiplier),
|
|
unit_cost: '0',
|
|
is_unestimated: data.requisition_type === 'unestimated',
|
|
}];
|
|
|
|
const updatedItems = [...data.items];
|
|
if (activeRowIndex !== null) {
|
|
if (!updatedItems[activeRowIndex].material_ulid && !updatedItems[activeRowIndex].quantity) {
|
|
updatedItems.splice(activeRowIndex, 1, ...newItems);
|
|
} else {
|
|
updatedItems.push(...newItems);
|
|
}
|
|
} else {
|
|
updatedItems.push(...newItems);
|
|
}
|
|
setData('items', updatedItems);
|
|
};
|
|
|
|
const handleProjectChange = (ulid: string) => {
|
|
const proj = projects.find(p => p.ulid === ulid);
|
|
const hasRem = proj?.has_remaining_estimates ?? false;
|
|
const newType = hasRem ? 'estimated' : 'unestimated';
|
|
|
|
setData(prev => ({
|
|
...prev,
|
|
project_ulid: ulid,
|
|
requisition_type: newType,
|
|
items: hasRem && proj?.remaining_estimates && proj.remaining_estimates.length > 0
|
|
? proj.remaining_estimates.map(i => ({
|
|
material_ulid: i.material_ulid,
|
|
quantity: String(i.quantity),
|
|
unit_cost: String(i.unit_cost),
|
|
is_unestimated: false,
|
|
}))
|
|
: [{ material_ulid: '', quantity: '', unit_cost: '', is_unestimated: true }]
|
|
}));
|
|
|
|
router.get(route('requisitions.create'), { project_ulid: ulid }, {
|
|
preserveState: true,
|
|
replace: true
|
|
});
|
|
};
|
|
|
|
const handleModeSelect = (mode: 'estimated' | 'unestimated') => {
|
|
if (mode === 'estimated' && !activeHasRemaining) {
|
|
return; // Locked
|
|
}
|
|
|
|
if (mode === 'estimated') {
|
|
const itemsToPopulate = activeRemainingEstimates.map(i => ({
|
|
material_ulid: i.material_ulid,
|
|
quantity: String(i.quantity),
|
|
unit_cost: String(i.unit_cost),
|
|
is_unestimated: false,
|
|
}));
|
|
|
|
setData(prev => ({
|
|
...prev,
|
|
requisition_type: 'estimated',
|
|
items: itemsToPopulate.length > 0 ? itemsToPopulate : [{ material_ulid: '', quantity: '', unit_cost: '', is_unestimated: false }]
|
|
}));
|
|
} else {
|
|
setData(prev => ({
|
|
...prev,
|
|
requisition_type: 'unestimated',
|
|
items: [{ material_ulid: '', quantity: '', unit_cost: '', is_unestimated: true }]
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Calculate total cost and unestimated cost in real time
|
|
const totalCost = useMemo(() => {
|
|
return data.items.reduce((sum, item) => {
|
|
const q = parseFloat(item.quantity) || 0;
|
|
const c = parseFloat(item.unit_cost) || 0;
|
|
return sum + (q * c);
|
|
}, 0);
|
|
}, [data.items]);
|
|
|
|
const unestimatedCost = useMemo(() => {
|
|
return data.items.reduce((sum, item) => {
|
|
if (data.requisition_type === 'unestimated' || item.is_unestimated) {
|
|
const q = parseFloat(item.quantity) || 0;
|
|
const c = parseFloat(item.unit_cost) || 0;
|
|
return sum + (q * c);
|
|
}
|
|
return sum;
|
|
}, 0);
|
|
}, [data.items, data.requisition_type]);
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
e.preventDefault();
|
|
const payloadItems = data.items
|
|
.filter(i => i.material_ulid && i.quantity)
|
|
.map(i => ({
|
|
material_ulid: i.material_ulid,
|
|
quantity: parseFloat(i.quantity),
|
|
unit_cost: parseFloat(i.unit_cost) || 0,
|
|
is_unestimated: data.requisition_type === 'unestimated' || !!i.is_unestimated,
|
|
}));
|
|
|
|
const payload = {
|
|
project_ulid: data.project_ulid,
|
|
requisition_type: data.requisition_type,
|
|
notes: data.notes,
|
|
items: payloadItems as any,
|
|
};
|
|
|
|
if (isEditing) {
|
|
router.put(route('requisitions.update', requisition.ulid), payload);
|
|
} else {
|
|
router.post(route('requisitions.store'), payload);
|
|
}
|
|
};
|
|
|
|
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 className="shadow-sm border-slate-200">
|
|
<CardHeader className="bg-slate-50/50 border-b pb-4">
|
|
<CardTitle className="text-base text-slate-800 flex items-center gap-2">
|
|
<Layers className="h-5 w-5 text-indigo-600" /> Requisition Information
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6 pt-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<Label htmlFor="project_ulid" className="font-semibold text-slate-700">Project <span className="text-red-500">*</span></Label>
|
|
{isEditing ? (
|
|
<div className="mt-1 border border-slate-200 rounded-md px-3 py-2 bg-slate-50 text-slate-700 text-sm font-medium">
|
|
{requisition?.project?.code} - {requisition?.project?.name}
|
|
</div>
|
|
) : (
|
|
<ProjectLookup
|
|
projects={projects}
|
|
selectedId={data.project_ulid}
|
|
onSelect={handleProjectChange}
|
|
/>
|
|
)}
|
|
{errors.project_ulid && <p className="mt-1 text-sm text-red-500">{errors.project_ulid}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="notes" className="font-semibold text-slate-700">Optional Notes</Label>
|
|
<Input id="notes" className="mt-1 border-slate-200" value={data.notes || ''} onChange={e => setData('notes', e.target.value)} placeholder="E.g. urgent delivery for concrete foundation..." />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mode Selection Cards (Estimated vs Unestimated Materials) */}
|
|
{data.project_ulid && (
|
|
<div className="space-y-2 pt-2">
|
|
<Label className="text-sm font-semibold text-slate-800">Select Requisition Mode <span className="text-red-500">*</span></Label>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
|
|
{/* Option 1: Estimated Materials */}
|
|
<div
|
|
onClick={() => handleModeSelect('estimated')}
|
|
className={`relative p-4 rounded-xl border-2 transition-all select-none ${
|
|
data.requisition_type === 'estimated'
|
|
? 'border-indigo-600 bg-indigo-50/40 shadow-sm ring-1 ring-indigo-600/30'
|
|
: activeHasRemaining
|
|
? 'border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50/50 cursor-pointer'
|
|
: 'border-slate-200 bg-slate-100/70 opacity-80 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`h-10 w-10 rounded-lg flex items-center justify-center ${
|
|
data.requisition_type === 'estimated' ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600'
|
|
}`}>
|
|
<Package className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold text-slate-900 text-sm flex items-center gap-2">
|
|
Estimated Materials
|
|
{data.requisition_type === 'estimated' && (
|
|
<CheckCircle2 className="h-4 w-4 text-indigo-600 inline" />
|
|
)}
|
|
</h4>
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
Requisition from the project's baseline estimation
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{activeHasRemaining ? (
|
|
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200 font-medium text-[11px]">
|
|
{activeRemainingEstimates.length} items remaining
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="bg-slate-200 text-slate-600 border-slate-300 font-medium text-[11px] flex items-center gap-1">
|
|
<Lock className="h-3 w-3" /> Locked
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{!activeHasRemaining && (
|
|
<div className="mt-3 pt-2.5 border-t border-slate-200/80 flex items-start gap-1.5 text-xs text-amber-700 font-medium">
|
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5 text-amber-600" />
|
|
<span>All estimated materials have already been requested on prior MRs. Please select <strong>Unestimated Materials</strong> to request additional items.</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Option 2: Unestimated Materials */}
|
|
<div
|
|
onClick={() => handleModeSelect('unestimated')}
|
|
className={`relative p-4 rounded-xl border-2 transition-all cursor-pointer select-none ${
|
|
data.requisition_type === 'unestimated'
|
|
? 'border-indigo-600 bg-indigo-50/40 shadow-sm ring-1 ring-indigo-600/30'
|
|
: 'border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50/50'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`h-10 w-10 rounded-lg flex items-center justify-center ${
|
|
data.requisition_type === 'unestimated' ? 'bg-indigo-600 text-white' : 'bg-purple-50 text-purple-600'
|
|
}`}>
|
|
<Sparkles className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold text-slate-900 text-sm flex items-center gap-2">
|
|
Unestimated Materials
|
|
{data.requisition_type === 'unestimated' && (
|
|
<CheckCircle2 className="h-4 w-4 text-indigo-600 inline" />
|
|
)}
|
|
</h4>
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
Request missed, supplemental, or urgent items
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200 font-medium text-[11px]">
|
|
Supplemental
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="mt-3 pt-2.5 border-t border-slate-100 flex items-center gap-1.5 text-xs text-indigo-700 font-medium">
|
|
<Info className="h-4 w-4 shrink-0 text-indigo-600" />
|
|
<span>Prices will be automatically added to the Project Capitalization Cap.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Materials Table Section */}
|
|
<div className="pt-4 border-t space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm font-semibold text-slate-800">
|
|
{data.requisition_type === 'estimated' ? 'Estimated Items to Requisition' : 'Unestimated Materials Request List'}
|
|
</Label>
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
{data.requisition_type === 'estimated'
|
|
? 'Populated with remaining unrequisitioned project balances.'
|
|
: 'Add materials from catalog that are required for project execution.'}
|
|
</p>
|
|
</div>
|
|
|
|
<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.5 h-4 w-4" /> Browse Catalog
|
|
</Button>
|
|
<Button type="button" variant="outline" size="sm" onClick={addItem}>
|
|
<Plus className="mr-1.5 h-4 w-4" /> Add Row
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{errors.items && <p className="text-sm text-red-500">{errors.items}</p>}
|
|
|
|
<div className="space-y-3">
|
|
{data.items.map((item, idx) => {
|
|
const selectedMat = materials.find(m => m.ulid === item.material_ulid);
|
|
return (
|
|
<div key={idx} className="flex flex-wrap md:flex-nowrap items-end gap-2 rounded-xl border border-slate-200 p-4 bg-slate-50/50 hover:bg-slate-50 transition-colors">
|
|
<div className="flex-1 w-full md:w-auto min-w-[220px]">
|
|
<Label className="text-xs font-medium text-slate-600">Material <span className="text-red-500">*</span></Label>
|
|
{item.material_ulid ? (
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<div className="flex-1 border border-slate-200 rounded-lg px-3 py-2 bg-white text-sm font-medium text-slate-800 flex items-center justify-between">
|
|
<span className="truncate">{selectedMat?.name || 'Unknown Material'}</span>
|
|
{selectedMat?.unit && (
|
|
<span className="text-xs text-slate-400 font-normal ml-2">({selectedMat.unit})</span>
|
|
)}
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => openCatalogModal(idx)} className="h-9 px-3">
|
|
Change
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full justify-start mt-1 text-slate-500 h-9 bg-white border-slate-200"
|
|
onClick={() => openCatalogModal(idx)}
|
|
>
|
|
<Search className="h-4 w-4 mr-2 text-slate-400" />
|
|
Select Material from Catalog...
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-28">
|
|
<Label className="text-xs font-medium text-slate-600">Quantity <span className="text-red-500">*</span></Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0.01"
|
|
value={item.quantity}
|
|
onChange={e => updateItem(idx, 'quantity', e.target.value)}
|
|
className="mt-1 bg-white border-slate-200"
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-36">
|
|
<Label className="text-xs font-medium text-slate-600">Unit Cost (PHP)</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={item.unit_cost}
|
|
onChange={e => updateItem(idx, 'unit_cost', e.target.value)}
|
|
className="mt-1 bg-white border-slate-200 font-mono text-sm"
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-32 hidden md:block text-right pr-2">
|
|
<Label className="text-xs text-slate-400 block mb-1">Line Total</Label>
|
|
<div className="text-sm font-semibold font-mono text-slate-800 py-1.5">
|
|
{new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(
|
|
(parseFloat(item.quantity) || 0) * (parseFloat(item.unit_cost) || 0)
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{data.items.length > 1 && (
|
|
<div className="w-10 flex justify-end pb-0.5">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeItem(idx)} className="text-slate-400 hover:text-red-600">
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Financial Summary Footer */}
|
|
<div className="mt-4 p-4 rounded-xl bg-slate-100/80 border border-slate-200 flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
|
|
<div>
|
|
<span className="text-xs text-slate-500 font-medium">Requisition Total Cost:</span>
|
|
<div className="text-lg font-bold font-mono text-slate-900">
|
|
{new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(totalCost)}
|
|
</div>
|
|
</div>
|
|
|
|
{unestimatedCost > 0 && (
|
|
<div className="text-right">
|
|
<span className="text-xs text-purple-700 font-medium flex items-center gap-1">
|
|
<Sparkles className="h-3.5 w-3.5" /> Added to Project Capitalization:
|
|
</span>
|
|
<div className="text-sm font-bold font-mono text-purple-900">
|
|
+{new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(unestimatedCost)}
|
|
</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)} className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm">
|
|
{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>
|
|
);
|
|
}
|