323 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|