Files
2026-05-20 18:58:23 +00:00

256 lines
14 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { Package, Plus, X, Edit2, Trash2, Search, Image as ImageIcon, AlertTriangle } from 'lucide-react';
import { useToast } from '../components/ToastProvider';
import { useSession } from 'next-auth/react';
export default function ProductsPage() {
const [products, setProducts] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const { toast, confirm } = useToast();
const { data: session } = useSession();
const permissions = (session?.user as any)?.permissions || [];
const canEdit = permissions.includes('PURCHASING_MANAGE');
const canDelete = permissions.includes('DATA_DELETE');
const imageRef = useRef<HTMLInputElement>(null);
const [form, setForm] = useState({
name: '', description: '', sku: '', purchasePrice: '', salePrice: '',
stock: '', unit: 'Stk', category: '', trackStock: true
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
useEffect(() => { fetchProducts(); }, []);
const fetchProducts = async () => {
const res = await fetch('/api/products');
if (res.ok) setProducts(await res.json());
};
const handleEdit = (p: any) => {
setEditingId(p.id);
setForm({
name: p.name, description: p.description || '', sku: p.sku || '',
purchasePrice: p.purchasePrice.toString(), salePrice: p.salePrice.toString(),
stock: p.stock.toString(), unit: p.unit, category: p.category || '',
trackStock: p.trackStock !== false
});
setImagePreview(p.imagePath);
setImageFile(null);
setShowForm(true);
};
const handleNew = () => {
setEditingId(null);
setForm({ name: '', description: '', sku: '', purchasePrice: '', salePrice: '', stock: '', unit: 'Stk', category: '', trackStock: true });
setImagePreview(null);
setImageFile(null);
setShowForm(!showForm);
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const fd = new FormData();
Object.entries(form).forEach(([k, v]) => fd.append(k, String(v)));
if (editingId) { fd.append('id', editingId.toString()); fd.append('active', 'true'); }
if (imageFile) fd.append('image', imageFile);
const res = await fetch('/api/products', {
method: editingId ? 'PUT' : 'POST',
body: fd
});
if (res.ok) {
toast(editingId ? 'Produkt aktualisiert' : 'Produkt angelegt', 'success');
setShowForm(false);
fetchProducts();
} else {
const data = await res.json();
toast(data.error || 'Fehler', 'error');
}
};
const handleDelete = async (p: any) => {
const ok = await confirm({ title: 'Produkt löschen', message: `"${p.name}" wirklich löschen?`, danger: true });
if (!ok) return;
const res = await fetch(`/api/products?id=${p.id}`, { method: 'DELETE' });
if (res.ok) { toast('Gelöscht', 'success'); fetchProducts(); }
else toast('Fehler beim Löschen', 'error');
};
const filtered = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.sku && p.sku.toLowerCase().includes(search.toLowerCase())) ||
(p.category && p.category.toLowerCase().includes(search.toLowerCase()))
);
const availableStock = (p: any) => p.stock - p.reservedStock;
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Package className="w-6 h-6 text-indigo-600" /> Produkte & Lager
</h1>
<p className="text-slate-500 mt-1">Verwalte Artikel, Preise und Bestände.</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" placeholder="Suchen..." value={search} onChange={e => setSearch(e.target.value)}
className="pl-9 pr-4 py-2 border border-slate-300 rounded-xl text-sm outline-none focus-ring w-56" />
</div>
{canEdit && (
<button onClick={handleNew} className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 font-medium shadow-sm">
{showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{showForm ? 'Abbrechen' : 'Neues Produkt'}
</button>
)}
</div>
</div>
{showForm && (
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 animate-in fade-in slide-in-from-top-4 duration-200">
<h2 className="text-lg font-semibold text-slate-800 mb-4">{editingId ? 'Produkt bearbeiten' : 'Neues Produkt'}</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2 grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Produktname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.name} onChange={e => setForm({...form, name: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Artikelnr. (SKU)</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.sku} onChange={e => setForm({...form, sku: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.category} onChange={e => setForm({...form, category: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">EK-Preis ()</label>
<input type="number" step="0.01" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.purchasePrice} onChange={e => setForm({...form, purchasePrice: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">VK-Preis ()</label>
<input type="number" step="0.01" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.salePrice} onChange={e => setForm({...form, salePrice: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Bestand</label>
<input type="number" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.stock} onChange={e => setForm({...form, stock: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Einheit</label>
<select className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white" value={form.unit} onChange={e => setForm({...form, unit: e.target.value})}>
<option>Stk</option><option>Std</option><option>kg</option><option>m</option><option>Pauschal</option>
</select>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea rows={2} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none resize-none" value={form.description} onChange={e => setForm({...form, description: e.target.value})} />
</div>
<div className="col-span-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.trackStock} onChange={e => setForm({...form, trackStock: e.target.checked})} className="w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
<span className="text-sm font-medium text-slate-700">Bestand verfolgen</span>
</label>
<p className="text-xs text-slate-400 mt-0.5 ml-6">Deaktivieren für Dienstleistungen, Versand, Anfahrt etc.</p>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-3">
<div className="w-full aspect-square bg-slate-50 border-2 border-dashed border-slate-300 rounded-2xl flex items-center justify-center overflow-hidden cursor-pointer hover:border-indigo-400 transition" onClick={() => imageRef.current?.click()}>
{imagePreview ? (
<img src={imagePreview} alt="Vorschau" className="w-full h-full object-cover rounded-2xl" />
) : (
<div className="text-center text-slate-400"><ImageIcon className="w-8 h-8 mx-auto mb-1" /><span className="text-xs">Bild hochladen</span></div>
)}
</div>
<input ref={imageRef} type="file" accept="image/*" className="hidden" onChange={handleImageChange} />
</div>
<div className="md:col-span-3 flex justify-end gap-3 mt-2">
<button type="button" onClick={() => setShowForm(false)} className="px-6 py-2.5 rounded-lg text-slate-600 hover:bg-slate-100 font-medium">Abbrechen</button>
<button type="submit" className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium">
{editingId ? 'Speichern' : 'Produkt anlegen'}
</button>
</div>
</form>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(p => (
<div key={p.id} className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden hover-lift group relative">
<div className="aspect-video bg-slate-100 flex items-center justify-center overflow-hidden">
{p.imagePath ? (
<img src={p.imagePath} alt={p.name} className="w-full h-full object-cover" />
) : (
<Package className="w-10 h-10 text-slate-300" />
)}
</div>
<div className="p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="font-bold text-slate-900 text-sm">{p.name}</h3>
{p.sku && <p className="text-xs text-slate-400 font-mono">{p.sku}</p>}
</div>
<div className="flex gap-1">
{canEdit && (
<button onClick={() => handleEdit(p)} className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition">
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
{canDelete && (
<button onClick={() => handleDelete(p)} className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{p.category && <span className="inline-block mt-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-slate-100 text-slate-500">{p.category}</span>}
<div className="flex items-end justify-between mt-3 pt-3 border-t border-slate-100">
<div>
<p className="text-[10px] text-slate-400 uppercase">EK / VK</p>
<p className="text-sm font-semibold text-slate-700">{p.purchasePrice.toFixed(2)} / <span className="text-indigo-600">{p.salePrice.toFixed(2)} </span></p>
</div>
<div className="text-right">
{p.trackStock ? (
<>
<p className="text-[10px] text-slate-400 uppercase">Bestand</p>
<div className="flex items-center gap-1">
{availableStock(p) <= 0 && <AlertTriangle className="w-3 h-3 text-red-500" />}
<p className={`text-sm font-bold ${availableStock(p) <= 0 ? 'text-red-600' : availableStock(p) <= 5 ? 'text-amber-600' : 'text-emerald-600'}`}>
{availableStock(p)} {p.unit}
</p>
</div>
{p.reservedStock > 0 && <p className="text-[10px] text-amber-500">{p.reservedStock} reserviert</p>}
</>
) : (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-500">Dienstleistung</span>
)}
</div>
</div>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="col-span-full text-center py-12 text-slate-500">
{search ? 'Keine Produkte gefunden.' : 'Noch keine Produkte angelegt.'}
</div>
)}
</div>
</div>
);
}