Files
GSB-Construction/Modules/MaterialLogistics/resources/js/Pages/Inventory/LookupModals.tsx
Christopher Boyles 64d4b331a8
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
Additional Changes
2026-06-02 22:04:03 +08:00

311 lines
16 KiB
TypeScript

import { useState, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge';
import { Check, Search, Warehouse, FolderKanban, Receipt, Loader2 } from 'lucide-react';
interface WarehouseRef { id: number; ulid: string; name: string; code: string; }
interface ProjectRef {
id: number;
ulid: string;
name: string;
code: string;
pm?: { id: number; name: string } | null;
}
interface PORef {
id: number;
ulid: string;
document_number: string;
supplier: string | null;
}
// ==========================================
// 1. Warehouse Lookup Modal
// ==========================================
interface WarehouseLookupModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
warehouses: WarehouseRef[];
onSelect: (warehouse: WarehouseRef) => void;
selectedUlid?: string;
}
export function WarehouseLookupModal({ open, onOpenChange, warehouses, onSelect, selectedUlid }: WarehouseLookupModalProps) {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search) return warehouses;
const q = search.toLowerCase().trim();
return warehouses.filter(w =>
w.name.toLowerCase().includes(q) ||
w.code.toLowerCase().includes(q)
);
}, [warehouses, search]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 border-slate-200">
<DialogHeader className="px-6 py-4 border-b border-slate-100">
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-slate-800">
<Warehouse className="h-5 w-5 text-blue-600" />
Select Warehouse
</DialogTitle>
<DialogDescription className="text-xs text-slate-500">
Search and select a source or destination warehouse.
</DialogDescription>
</DialogHeader>
<div className="px-6 py-3 border-b border-slate-100 bg-slate-50/50">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<Input
placeholder="Search by warehouse name or code..."
className="h-9 pl-9 bg-white text-xs border-slate-250 focus-visible:ring-blue-500 focus-visible:border-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
{filtered.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-xs">
No warehouses found matching "{search}".
</div>
) : (
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-sm bg-white divide-y divide-slate-100">
{filtered.map(w => {
const isSelected = selectedUlid === w.ulid;
return (
<div
key={w.id}
onClick={() => {
onSelect(w);
onOpenChange(false);
}}
className={`flex items-center justify-between p-3.5 cursor-pointer transition-colors hover:bg-slate-50/80 ${
isSelected ? 'bg-blue-50/30 hover:bg-blue-50/50' : ''
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isSelected ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'}`}>
<Warehouse className="h-4 w-4" />
</div>
<div>
<span className="font-semibold text-slate-800 text-sm block">{w.name}</span>
<span className="text-[11px] text-slate-500 font-mono tracking-wide uppercase">{w.code}</span>
</div>
</div>
{isSelected && (
<Badge className="bg-blue-100 hover:bg-blue-100 text-blue-800 border-none text-[10px] font-medium flex items-center gap-0.5 shadow-none">
<Check className="h-2.5 w-2.5" /> Selected
</Badge>
)}
</div>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ==========================================
// 2. Project Lookup Modal
// ==========================================
interface ProjectLookupModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projects: ProjectRef[];
onSelect: (project: ProjectRef) => void;
selectedUlid?: string;
}
export function ProjectLookupModal({ open, onOpenChange, projects, onSelect, selectedUlid }: ProjectLookupModalProps) {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search) return projects;
const q = search.toLowerCase().trim();
return projects.filter(p =>
p.name.toLowerCase().includes(q) ||
p.code.toLowerCase().includes(q) ||
(p.pm && p.pm.name.toLowerCase().includes(q))
);
}, [projects, search]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 border-slate-200">
<DialogHeader className="px-6 py-4 border-b border-slate-100">
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-slate-800">
<FolderKanban className="h-5 w-5 text-emerald-600" />
Select Project
</DialogTitle>
<DialogDescription className="text-xs text-slate-500">
Search and select a project to associate with this inventory operation.
</DialogDescription>
</DialogHeader>
<div className="px-6 py-3 border-b border-slate-100 bg-slate-50/50">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<Input
placeholder="Search by project name, code, or PM..."
className="h-9 pl-9 bg-white text-xs border-slate-250 focus-visible:ring-emerald-500 focus-visible:border-emerald-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
{filtered.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-xs">
No projects found matching "{search}".
</div>
) : (
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-sm bg-white divide-y divide-slate-100">
{filtered.map(p => {
const isSelected = selectedUlid === p.ulid;
return (
<div
key={p.id}
onClick={() => {
onSelect(p);
onOpenChange(false);
}}
className={`flex items-center justify-between p-3.5 cursor-pointer transition-colors hover:bg-slate-50/80 ${
isSelected ? 'bg-emerald-50/30 hover:bg-emerald-50/50' : ''
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isSelected ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'}`}>
<FolderKanban className="h-4 w-4" />
</div>
<div>
<span className="font-semibold text-slate-800 text-sm block">{p.name}</span>
<span className="text-[11px] text-slate-500 block mt-0.5">
Code: <span className="font-mono">{p.code}</span> {p.pm ? `• PM: ${p.pm.name}` : ''}
</span>
</div>
</div>
{isSelected && (
<Badge className="bg-emerald-100 hover:bg-emerald-100 text-emerald-800 border-none text-[10px] font-medium flex items-center gap-0.5 shadow-none">
<Check className="h-2.5 w-2.5" /> Selected
</Badge>
)}
</div>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ==========================================
// 3. Purchase Order Lookup Modal
// ==========================================
interface PurchaseOrderLookupModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
purchaseOrders: PORef[];
onSelect: (po: PORef) => void;
selectedUlid?: string;
loading?: boolean;
}
export function PurchaseOrderLookupModal({ open, onOpenChange, purchaseOrders, onSelect, selectedUlid, loading }: PurchaseOrderLookupModalProps) {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search) return purchaseOrders;
const q = search.toLowerCase().trim();
return purchaseOrders.filter(po =>
po.document_number.toLowerCase().includes(q) ||
(po.supplier && po.supplier.toLowerCase().includes(q))
);
}, [purchaseOrders, search]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 border-slate-200">
<DialogHeader className="px-6 py-4 border-b border-slate-100">
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-slate-800">
<Receipt className="h-5 w-5 text-amber-600" />
Select Purchase Order
</DialogTitle>
<DialogDescription className="text-xs text-slate-500">
Select an approved Purchase Order issued to this project and warehouse.
</DialogDescription>
</DialogHeader>
<div className="px-6 py-3 border-b border-slate-100 bg-slate-50/50">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<Input
placeholder="Search by PO number or supplier..."
className="h-9 pl-9 bg-white text-xs border-slate-250 focus-visible:ring-amber-500 focus-visible:border-amber-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
<Loader2 className="h-6 w-6 animate-spin text-amber-500" />
<span className="text-xs">Loading approved purchase orders...</span>
</div>
) : filtered.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-xs">
No approved purchase orders found.
</div>
) : (
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-sm bg-white divide-y divide-slate-100">
{filtered.map(po => {
const isSelected = selectedUlid === po.ulid;
return (
<div
key={po.id}
onClick={() => {
onSelect(po);
onOpenChange(false);
}}
className={`flex items-center justify-between p-3.5 cursor-pointer transition-colors hover:bg-slate-50/80 ${
isSelected ? 'bg-amber-50/30 hover:bg-amber-50/50' : ''
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isSelected ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-500'}`}>
<Receipt className="h-4 w-4" />
</div>
<div>
<span className="font-semibold text-slate-800 text-sm block">PO: {po.document_number}</span>
{po.supplier && (
<span className="text-[11px] text-slate-500 block mt-0.5">Supplier: {po.supplier}</span>
)}
</div>
</div>
{isSelected && (
<Badge className="bg-amber-100 hover:bg-amber-100 text-amber-800 border-none text-[10px] font-medium flex items-center gap-0.5 shadow-none">
<Check className="h-2.5 w-2.5" /> Selected
</Badge>
)}
</div>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}