Initial commit - ERP System
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user