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