Files
GSB-Construction/resources/js/Components/MaterialPickerModal.tsx

323 lines
16 KiB
TypeScript

import { useState, useMemo } from 'react';
import { router } from '@inertiajs/react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/Components/ui/dialog';
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import { Checkbox } from '@/Components/ui/checkbox';
import { Package, Search, ShoppingCart, AlertTriangle } from 'lucide-react';
interface MaterialOption {
id: number;
ulid: string;
name: string;
sku?: string;
unit: string;
unit_cost: string;
category?: string;
available_qty?: number;
on_hand_qty?: number;
allocated_qty?: number;
}
interface MaterialPickerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectUlid: string;
taskUlid: string;
availableMaterials: MaterialOption[];
existingMaterialIds: string[];
}
const formatCurrency = (v: string) =>
new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
const formatQty = (v: number) => new Intl.NumberFormat('en-PH', { maximumFractionDigits: 2 }).format(v);
export default function MaterialPickerModal({
open,
onOpenChange,
projectUlid,
taskUlid,
availableMaterials,
existingMaterialIds,
}: MaterialPickerModalProps) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const materials = useMemo(() => {
const q = search.toLowerCase().trim();
if (!q) return availableMaterials;
return availableMaterials.filter(m =>
m.name.toLowerCase().includes(q) ||
(m.sku && m.sku.toLowerCase().includes(q)) ||
(m.category && m.category.toLowerCase().includes(q))
);
}, [availableMaterials, search]);
const selectedCount = Object.keys(selected).length;
const toggleMaterial = (ulid: string) => {
setSelected(prev => {
const next = { ...prev };
if (next[ulid]) {
delete next[ulid];
} else {
next[ulid] = '1';
}
return next;
});
};
const setQty = (ulid: string, qty: string) => {
setSelected(prev => ({ ...prev, [ulid]: qty }));
};
const toggleAll = () => {
const selectableMaterials = materials.filter(m => !existingMaterialIds.includes(m.ulid));
const allSelected = selectableMaterials.every(m => selected[m.ulid]);
if (allSelected) {
setSelected(prev => {
const next = { ...prev };
selectableMaterials.forEach(m => delete next[m.ulid]);
return next;
});
} else {
setSelected(prev => {
const next = { ...prev };
selectableMaterials.forEach(m => {
if (!next[m.ulid]) next[m.ulid] = '1';
});
return next;
});
}
};
// Check if any selected qty exceeds available
const hasQtyError = useMemo(() => {
return Object.entries(selected).some(([ulid, qty]) => {
const mat = availableMaterials.find(m => m.ulid === ulid);
if (!mat || mat.available_qty == null) return false;
return Number(qty) > mat.available_qty;
});
}, [selected, availableMaterials]);
const handleSubmit = () => {
if (hasQtyError) return;
const entries = Object.entries(selected)
.filter(([, qty]) => Number(qty) > 0)
.map(([material_id, planned_qty]) => ({ material_id, planned_qty }));
if (entries.length === 0) return;
setSubmitting(true);
router.post(
route('projects.tasks.materials.storeBulk', [projectUlid, taskUlid]),
{ materials: entries },
{
preserveScroll: true,
onSuccess: () => {
setSelected({});
setSearch('');
onOpenChange(false);
},
onFinish: () => setSubmitting(false),
}
);
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setSelected({});
setSearch('');
}
onOpenChange(isOpen);
};
const selectableMaterials = materials.filter(m => !existingMaterialIds.includes(m.ulid));
const allVisibleSelected = selectableMaterials.length > 0 && selectableMaterials.every(m => selected[m.ulid]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5 text-gray-500" />
Add Materials to Task
{selectedCount > 0 && (
<Badge className="ml-1">{selectedCount} selected</Badge>
)}
</DialogTitle>
</DialogHeader>
{/* Search */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="Search by name, SKU, or category..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Table */}
<div className="overflow-y-auto flex-1 -mx-4 px-4">
{materials.length === 0 ? (
<div className="text-center py-10">
<Package className="mx-auto h-10 w-10 text-gray-300 mb-3" />
<p className="text-gray-500">
{search
? 'No materials match your search.'
: 'No materials on site. Dispatch materials from a warehouse first.'}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected}
onChange={toggleAll}
/>
</TableHead>
<TableHead>Material</TableHead>
<TableHead>Category</TableHead>
<TableHead>Unit</TableHead>
<TableHead className="text-right">Unit Cost</TableHead>
<TableHead className="text-right">On Site</TableHead>
<TableHead className="w-28 text-right">Planned Qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((m) => {
const isExisting = existingMaterialIds.includes(m.ulid);
const isChecked = !!selected[m.ulid];
const availableQty = m.available_qty ?? 0;
const enteredQty = isChecked ? Number(selected[m.ulid]) : 0;
const exceedsAvailable = isChecked && enteredQty > availableQty;
return (
<TableRow
key={m.id}
className={
isExisting
? 'opacity-40'
: exceedsAvailable
? 'bg-red-50/50'
: isChecked
? 'bg-blue-50/50'
: 'cursor-pointer hover:bg-gray-50'
}
onClick={() => {
if (!isExisting) toggleMaterial(m.ulid);
}}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isExisting || isChecked}
disabled={isExisting}
onChange={() => {
if (!isExisting) toggleMaterial(m.ulid);
}}
/>
</TableCell>
<TableCell className="font-medium text-sm">
{m.name}
{m.sku && (
<span className="ml-1.5 text-xs text-gray-400">{m.sku}</span>
)}
{isExisting && (
<Badge variant="outline" className="ml-2 text-xs">
Already added
</Badge>
)}
{(m as any).type === 'kit' && (m as any).components && (m as any).components.length > 0 && (
<div className="text-xs text-gray-500 mt-1 space-y-0.5 border-l-2 border-emerald-300 pl-2 ml-1 font-normal">
{(m as any).components.map((comp: any) => (
<div key={comp.id}>{comp.quantity * (enteredQty || 1)}x {comp.component?.name} <span className="opacity-60">(from {comp.quantity} / kit)</span></div>
))}
</div>
)}
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{m.category || '-'}</Badge>
</TableCell>
<TableCell className="text-sm">{m.unit}</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatCurrency(m.unit_cost)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
<span className={availableQty <= 0 ? 'text-red-500 font-medium' : ''}>
{formatQty(availableQty)}
</span>
{availableQty > 0 && availableQty <= (m.on_hand_qty ?? 0) * 0.1 && (
<Badge variant="outline" className="ml-1 text-[10px] text-amber-600 border-amber-300">
Low
</Badge>
)}
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{isChecked && !isExisting && (
<div className="flex flex-col items-end gap-0.5">
<Input
type="number"
step="0.01"
min="0.01"
max={availableQty}
value={selected[m.ulid]}
onChange={(e) => setQty(m.ulid, e.target.value)}
className={`w-24 ml-auto text-right h-8 text-sm ${exceedsAvailable ? 'border-red-400 focus-visible:ring-red-400' : ''}`}
/>
{exceedsAvailable && (
<span className="text-[10px] text-red-500 flex items-center gap-0.5">
<AlertTriangle className="h-2.5 w-2.5" />
Max {formatQty(availableQty)}
</span>
)}
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t pt-3">
<p className="text-sm text-gray-500">
{selectedCount > 0
? `${selectedCount} material${selectedCount > 1 ? 's' : ''} selected`
: 'Select materials to add'}
{hasQtyError && (
<span className="text-red-500 ml-2"> Fix quantity errors first</span>
)}
</p>
<Button
onClick={handleSubmit}
disabled={selectedCount === 0 || submitting || hasQtyError}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{submitting ? 'Adding...' : `Add ${selectedCount} Material${selectedCount !== 1 ? 's' : ''}`}
</Button>
</div>
</DialogContent>
</Dialog>
);
}