Initial commit - ERP System
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Download, PenTool, Send, Check, X, Printer, FilePlus2 } from 'lucide-react';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
QUOTE: 'Angebot', ORDER_CONFIRMATION: 'Auftragsbestätigung',
|
||||
DELIVERY_NOTE: 'Lieferschein', INVOICE: 'Rechnung', CREDIT_NOTE: 'Rechnungskorrektur'
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
DRAFT: 'Entwurf', SENT: 'Gesendet', ACCEPTED: 'Angenommen',
|
||||
REJECTED: 'Abgelehnt', DELIVERED: 'Geliefert', PAID: 'Bezahlt',
|
||||
CANCELLED: 'Storniert', ARCHIVED: 'Archiviert'
|
||||
};
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-slate-100 text-slate-600', SENT: 'bg-blue-100 text-blue-700',
|
||||
ACCEPTED: 'bg-emerald-100 text-emerald-700', REJECTED: 'bg-red-100 text-red-700',
|
||||
DELIVERED: 'bg-amber-100 text-amber-700', PAID: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-slate-200 text-slate-500', ARCHIVED: 'bg-slate-100 text-slate-400'
|
||||
};
|
||||
|
||||
export default function SalesDocDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { toast, confirm } = useToast();
|
||||
const { data: session } = useSession();
|
||||
const perms = (session?.user as any)?.permissions || [];
|
||||
const canManage = perms.includes('SALES_MANAGE');
|
||||
const [doc, setDoc] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSignature, setShowSignature] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDrawing = useRef(false);
|
||||
|
||||
useEffect(() => { fetchDoc(); }, [params.id]);
|
||||
|
||||
const fetchDoc = async () => {
|
||||
const res = await fetch(`/api/sales/${params.id}`);
|
||||
if (res.ok) setDoc(await res.json());
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const updateStatus = async (status: string) => {
|
||||
const res = await fetch(`/api/sales/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// If quote was accepted, auto-AB was created → show info
|
||||
if (data.followUpId) {
|
||||
toast(`Auftragsbestätigung ${data.followUpNumber} wurde automatisch erstellt & versendet. Angebot archiviert.`, 'success');
|
||||
} else {
|
||||
toast(`Status: ${statusLabels[status]}`, 'success');
|
||||
}
|
||||
fetchDoc();
|
||||
}
|
||||
};
|
||||
|
||||
const createFollowUp = async () => {
|
||||
const followUpLabels: Record<string, string> = { ORDER_CONFIRMATION: 'Lieferschein', DELIVERY_NOTE: 'Rechnung', INVOICE: 'Rechnungskorrektur' };
|
||||
const nextLabel = followUpLabels[doc.type] || 'Folgebeleg';
|
||||
const msg = doc.type === 'INVOICE'
|
||||
? `Rechnungskorrektur erstellen? Die Rechnung ${doc.number} wird storniert.`
|
||||
: `Aus dieser ${typeLabels[doc.type]} einen ${nextLabel} erstellen?`;
|
||||
const ok = await confirm({ title: `${nextLabel} erstellen`, message: msg, danger: doc.type === 'INVOICE' });
|
||||
if (!ok) return;
|
||||
const res = await fetch(`/api/sales/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'CREATE_FOLLOWUP' })
|
||||
});
|
||||
if (res.ok) {
|
||||
const newDoc = await res.json();
|
||||
toast(`${nextLabel} ${newDoc.number} erstellt.`, 'success');
|
||||
router.push(`/sales/${newDoc.id}`);
|
||||
} else {
|
||||
toast('Fehler beim Erstellen', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Signature Canvas
|
||||
const startDraw = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
isDrawing.current = true;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = 'touches' in e ? e.touches[0].clientX - rect.left : e.clientX - rect.left;
|
||||
const y = 'touches' in e ? e.touches[0].clientY - rect.top : e.clientY - rect.top;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
};
|
||||
const draw = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDrawing.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = 'touches' in e ? e.touches[0].clientX - rect.left : e.clientX - rect.left;
|
||||
const y = 'touches' in e ? e.touches[0].clientY - rect.top : e.clientY - rect.top;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#1e293b';
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
};
|
||||
const endDraw = () => { isDrawing.current = false; };
|
||||
const clearSignature = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
const saveSignature = async () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const res = await fetch(`/api/sales/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ signatureData: dataUrl })
|
||||
});
|
||||
if (res.ok) { toast('Unterschrift gespeichert', 'success'); setShowSignature(false); fetchDoc(); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-slate-500 font-medium">Lade...</div>;
|
||||
if (!doc) return <div className="p-8 text-red-600 font-medium">Beleg nicht gefunden.</div>;
|
||||
|
||||
const nextActions: Record<string, { label: string, status: string, icon: any }[]> = {
|
||||
DRAFT: [{ label: 'Als gesendet markieren', status: 'SENT', icon: Send }],
|
||||
SENT: doc.type === 'ORDER_CONFIRMATION' ? [] :
|
||||
doc.type === 'INVOICE' ? [{ label: 'Bezahlt', status: 'PAID', icon: Check }] :
|
||||
doc.type === 'CREDIT_NOTE' ? [] :
|
||||
[{ label: 'Angenommen', status: 'ACCEPTED', icon: Check }, { label: 'Abgelehnt', status: 'REJECTED', icon: X }],
|
||||
ACCEPTED: doc.type === 'DELIVERY_NOTE' ? [{ label: 'Geliefert', status: 'DELIVERED', icon: Check }] : [],
|
||||
};
|
||||
const actions = nextActions[doc.status] || [];
|
||||
|
||||
// Can create follow-up? AB → LS, LS → RE, RE → RK
|
||||
const canCreateFollowUp = canManage && (
|
||||
(doc.type === 'ORDER_CONFIRMATION' && doc.status === 'SENT') ||
|
||||
(doc.type === 'DELIVERY_NOTE' && ['SENT', 'ACCEPTED', 'DELIVERED'].includes(doc.status)) ||
|
||||
(doc.type === 'INVOICE' && ['SENT', 'PAID'].includes(doc.status))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6 pb-12 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => router.push('/sales')} className="p-2 hover:bg-slate-200 rounded-lg text-slate-600 transition">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{typeLabels[doc.type]} {doc.number}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${statusColors[doc.status]}`}>{statusLabels[doc.status]}</span>
|
||||
{doc.previousStatus && ['ARCHIVED', 'CANCELLED'].includes(doc.status) && (
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${statusColors[doc.previousStatus] || 'bg-slate-100 text-slate-500'}`}>
|
||||
vorher: {statusLabels[doc.previousStatus] || doc.previousStatus}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-slate-500">{new Date(doc.createdAt).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage && !doc.signatureData && !['INVOICE', 'ORDER_CONFIRMATION'].includes(doc.type) && !['CANCELLED', 'ARCHIVED'].includes(doc.status) && (
|
||||
<button onClick={() => setShowSignature(true)} className="bg-amber-500 text-white px-4 py-2 rounded-lg hover:bg-amber-600 transition flex items-center gap-2 text-sm font-medium">
|
||||
<PenTool className="w-4 h-4" /> Unterschreiben
|
||||
</button>
|
||||
)}
|
||||
{canManage && actions.map(a => (
|
||||
<button key={a.status} onClick={() => updateStatus(a.status)} className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 text-sm font-medium">
|
||||
<a.icon className="w-4 h-4" /> {a.label}
|
||||
</button>
|
||||
))}
|
||||
{canCreateFollowUp && (
|
||||
<button onClick={createFollowUp} className={`${doc.type === 'INVOICE' ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'} text-white px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm font-medium`}>
|
||||
<FilePlus2 className="w-4 h-4" /> {{ ORDER_CONFIRMATION: 'Lieferschein erstellen', DELIVERY_NOTE: 'Rechnung erstellen', INVOICE: 'Rechnungskorrektur' }[doc.type]}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => window.print()} className="bg-slate-100 text-slate-700 px-4 py-2 rounded-lg hover:bg-slate-200 transition flex items-center gap-2 text-sm font-medium">
|
||||
<Printer className="w-4 h-4" /> Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
{/* Document Body (print-friendly) */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 print:shadow-none print:border-none" id="print-area">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{typeLabels[doc.type]}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Nr. {doc.number}</p>
|
||||
<p className="text-sm text-slate-500">Datum: {new Date(doc.createdAt).toLocaleDateString('de-DE')}</p>
|
||||
{doc.validUntil && <p className="text-sm text-slate-500">Gültig bis: {new Date(doc.validUntil).toLocaleDateString('de-DE')}</p>}
|
||||
</div>
|
||||
<div className="text-right text-sm text-slate-600">
|
||||
<p className="font-semibold">{doc.customer?.companyName}</p>
|
||||
<p>{doc.customer?.firstName} {doc.customer?.lastName}</p>
|
||||
<p>{doc.customer?.address}</p>
|
||||
<p>{doc.customer?.zipCode} {doc.customer?.city}</p>
|
||||
<p>{doc.customer?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm mb-6">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-slate-200 text-slate-600">
|
||||
<th className="text-left py-3 font-medium">Pos.</th>
|
||||
<th className="text-left py-3 font-medium">Beschreibung</th>
|
||||
<th className="text-right py-3 font-medium">Menge</th>
|
||||
<th className="text-right py-3 font-medium">Einzelpreis</th>
|
||||
<th className="text-right py-3 font-medium">Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{doc.items?.map((item: any, idx: number) => (
|
||||
<tr key={item.id}>
|
||||
<td className="py-3 text-slate-400">{idx + 1}</td>
|
||||
<td className="py-3 font-medium text-slate-800">{item.description}</td>
|
||||
<td className="py-3 text-right text-slate-600">{item.quantity}</td>
|
||||
<td className="py-3 text-right text-slate-600">{item.unitPrice.toFixed(2)} €</td>
|
||||
<td className="py-3 text-right font-semibold text-slate-800">{item.total.toFixed(2)} €</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="w-64 space-y-2 text-sm">
|
||||
<div className="flex justify-between text-slate-600"><span>Netto</span><span>{doc.subtotal.toFixed(2)} €</span></div>
|
||||
<div className="flex justify-between text-slate-600"><span>MwSt (19%)</span><span>{doc.taxAmount.toFixed(2)} €</span></div>
|
||||
<div className="flex justify-between text-lg font-bold text-slate-900 pt-2 border-t-2 border-slate-200"><span>Gesamt</span><span>{doc.total.toFixed(2)} €</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.notes && (
|
||||
<div className="mt-6 p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<p className="text-xs font-semibold text-slate-500 mb-1">Notizen</p>
|
||||
<p className="text-sm text-slate-700 whitespace-pre-wrap">{doc.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc.signatureData && (
|
||||
<div className="mt-6 pt-4 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Digitale Unterschrift ({new Date(doc.signedAt).toLocaleDateString('de-DE')})</p>
|
||||
<img src={doc.signatureData} alt="Unterschrift" className="h-16 border border-slate-200 rounded-lg bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white p-5 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3">Details</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span className="text-slate-500">Erstellt von</span><span className="font-medium">{doc.createdBy?.firstName} {doc.createdBy?.lastName}</span></div>
|
||||
<div className="flex justify-between"><span className="text-slate-500">Typ</span><span className="font-medium">{typeLabels[doc.type]}</span></div>
|
||||
<div className="flex justify-between"><span className="text-slate-500">Positionen</span><span className="font-medium">{doc.items?.length}</span></div>
|
||||
{doc.signedAt && <div className="flex justify-between"><span className="text-slate-500">Unterschrieben</span><span className="font-medium text-emerald-600">✓</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
{canManage && !['CANCELLED', 'PAID', 'ARCHIVED'].includes(doc.status) && (
|
||||
<button onClick={async () => {
|
||||
const ok = await confirm({ title: 'Stornieren', message: 'Beleg wirklich stornieren?', danger: true });
|
||||
if (ok) updateStatus('CANCELLED');
|
||||
}} className="w-full bg-red-50 text-red-600 py-2.5 rounded-xl font-medium hover:bg-red-100 transition text-sm">
|
||||
Beleg stornieren
|
||||
</button>
|
||||
)}
|
||||
{doc.status === 'ARCHIVED' && (
|
||||
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 text-center">
|
||||
<p className="text-xs text-slate-400 font-medium">Dieser Beleg wurde archiviert.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signature Modal */}
|
||||
{showSignature && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white p-6 rounded-3xl shadow-2xl max-w-lg w-full">
|
||||
<h2 className="text-lg font-bold text-slate-900 mb-4">Digitale Unterschrift</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">Bitte unterschreiben Sie im Feld unten.</p>
|
||||
<canvas ref={canvasRef} width={460} height={200}
|
||||
className="w-full border-2 border-slate-300 rounded-xl cursor-crosshair bg-white touch-none"
|
||||
onMouseDown={startDraw} onMouseMove={draw} onMouseUp={endDraw} onMouseLeave={endDraw}
|
||||
onTouchStart={startDraw} onTouchMove={draw} onTouchEnd={endDraw}
|
||||
/>
|
||||
<div className="flex justify-between mt-4">
|
||||
<button onClick={clearSignature} className="text-sm text-slate-500 hover:text-slate-700 font-medium">Löschen</button>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowSignature(false)} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
|
||||
<button onClick={saveSignature} className="bg-indigo-600 text-white px-6 py-2 rounded-xl font-medium hover:bg-indigo-700 transition">Unterschreiben</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Plus, Trash2, Search } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
|
||||
const docTypes = [
|
||||
{ value: 'QUOTE', label: 'Angebot' },
|
||||
{ value: 'ORDER_CONFIRMATION', label: 'Auftragsbestätigung' },
|
||||
{ value: 'DELIVERY_NOTE', label: 'Lieferschein' },
|
||||
{ value: 'INVOICE', label: 'Rechnung' },
|
||||
];
|
||||
|
||||
export default function NewSalesDocPage() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState('QUOTE');
|
||||
const [customerId, setCustomerId] = useState<number | null>(null);
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
const [customerResults, setCustomerResults] = useState<any[]>([]);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [validUntil, setValidUntil] = useState('');
|
||||
const [items, setItems] = useState<any[]>([{ description: '', quantity: 1, unitPrice: 0, productId: null }]);
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/products').then(r => r.json()).then(setProducts).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const searchCustomers = async (q: string) => {
|
||||
setCustomerSearch(q);
|
||||
if (q.length < 2) { setCustomerResults([]); return; }
|
||||
const res = await fetch(`/api/customers/search?q=${encodeURIComponent(q)}`);
|
||||
if (res.ok) setCustomerResults(await res.json());
|
||||
};
|
||||
|
||||
const selectCustomer = (c: any) => {
|
||||
setSelectedCustomer(c);
|
||||
setCustomerId(c.id);
|
||||
setCustomerSearch(c.companyName || `${c.firstName} ${c.lastName}`);
|
||||
setCustomerResults([]);
|
||||
};
|
||||
|
||||
const updateItem = (idx: number, field: string, value: any) => {
|
||||
const newItems = [...items];
|
||||
newItems[idx] = { ...newItems[idx], [field]: value };
|
||||
// Auto-fill price from product
|
||||
if (field === 'productId' && value) {
|
||||
const prod = products.find(p => p.id === parseInt(value));
|
||||
if (prod) {
|
||||
newItems[idx].unitPrice = prod.salePrice;
|
||||
newItems[idx].description = prod.name;
|
||||
}
|
||||
}
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const addItem = () => setItems([...items, { description: '', quantity: 1, unitPrice: 0, productId: null }]);
|
||||
const removeItem = (idx: number) => setItems(items.filter((_, i) => i !== idx));
|
||||
|
||||
const subtotal = items.reduce((sum, i) => sum + (i.quantity * i.unitPrice), 0);
|
||||
const tax = subtotal * 0.19;
|
||||
const total = subtotal + tax;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!customerId) { toast('Bitte Kunde auswählen', 'error'); return; }
|
||||
if (items.length === 0 || !items[0].description) { toast('Bitte mindestens eine Position', 'error'); return; }
|
||||
setSaving(true);
|
||||
|
||||
const res = await fetch('/api/sales', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type, customerId, notes, validUntil: validUntil || null,
|
||||
items: items.map(i => ({
|
||||
description: i.description, quantity: parseFloat(i.quantity) || 1,
|
||||
unitPrice: parseFloat(i.unitPrice) || 0,
|
||||
productId: i.productId ? parseInt(i.productId) : null
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const doc = await res.json();
|
||||
toast('Beleg erstellt', 'success');
|
||||
router.push(`/sales/${doc.id}`);
|
||||
} else {
|
||||
toast('Fehler beim Erstellen', 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6 pb-12 animate-fade-in-up">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => router.push('/sales')} className="p-2 hover:bg-slate-200 rounded-lg text-slate-600 transition">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Neuen Beleg erstellen</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800 mb-4">Belegdaten</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Belegart *</label>
|
||||
<select className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white" value={type} onChange={e => setType(e.target.value)}>
|
||||
{docTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Gültig bis</label>
|
||||
<input type="date" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={validUntil} onChange={e => setValidUntil(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-2 relative">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Kunde *</label>
|
||||
<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="Kunde suchen..." className="w-full pl-9 border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={customerSearch} onChange={e => searchCustomers(e.target.value)} />
|
||||
</div>
|
||||
{customerResults.length > 0 && (
|
||||
<div className="absolute z-20 left-0 right-0 mt-1 bg-white border border-slate-200 rounded-xl shadow-xl max-h-48 overflow-y-auto">
|
||||
{customerResults.map(c => (
|
||||
<button key={c.id} onClick={() => selectCustomer(c)} className="w-full text-left px-4 py-3 hover:bg-indigo-50 transition text-sm">
|
||||
<span className="font-semibold">{c.companyName || `${c.firstName} ${c.lastName}`}</span>
|
||||
<span className="text-slate-400 ml-2">{c.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedCustomer && <p className="text-xs text-emerald-600 mt-1 font-medium">✓ {selectedCustomer.companyName || `${selectedCustomer.firstName} ${selectedCustomer.lastName}`}</p>}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Notizen</label>
|
||||
<textarea rows={2} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none resize-none" value={notes} onChange={e => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-slate-800">Positionen</h2>
|
||||
<button type="button" onClick={addItem} className="text-indigo-600 hover:text-indigo-800 transition flex items-center gap-1 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" /> Position
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-end p-3 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<div className="col-span-3">
|
||||
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Produkt</label>
|
||||
<select className="w-full border border-slate-300 p-2 rounded-lg text-sm bg-white" value={item.productId || ''} onChange={e => updateItem(idx, 'productId', e.target.value || null)}>
|
||||
<option value="">Freitext</option>
|
||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Beschreibung *</label>
|
||||
<input type="text" required className="w-full border border-slate-300 p-2 rounded-lg text-sm" value={item.description} onChange={e => updateItem(idx, 'description', e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Menge</label>
|
||||
<input type="number" step="0.01" min="0.01" className="w-full border border-slate-300 p-2 rounded-lg text-sm" value={item.quantity} onChange={e => updateItem(idx, 'quantity', e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Einzelpreis €</label>
|
||||
<input type="number" step="0.01" className="w-full border border-slate-300 p-2 rounded-lg text-sm" value={item.unitPrice} onChange={e => updateItem(idx, 'unitPrice', e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-center">
|
||||
{items.length > 1 && (
|
||||
<button type="button" onClick={() => removeItem(idx)} className="p-2 text-slate-400 hover:text-red-600 rounded-lg transition">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Summary */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 sticky top-6">
|
||||
<h2 className="font-semibold text-slate-800 mb-4">Zusammenfassung</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>Netto</span><span className="font-semibold">{subtotal.toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>MwSt (19%)</span><span className="font-semibold">{tax.toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold text-slate-900 pt-3 border-t border-slate-200">
|
||||
<span>Gesamt</span><span>{total.toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{items.filter(i => i.description).length} Position(en)</div>
|
||||
</div>
|
||||
<button onClick={handleSubmit} disabled={saving}
|
||||
className="w-full mt-6 bg-indigo-600 text-white py-3 rounded-xl font-bold hover:bg-indigo-700 transition disabled:opacity-50">
|
||||
{saving ? 'Erstelle...' : 'Beleg erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { FileText, Plus, Search, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
QUOTE: 'Angebot', ORDER_CONFIRMATION: 'Auftragsbestätigung',
|
||||
DELIVERY_NOTE: 'Lieferschein', INVOICE: 'Rechnung', CREDIT_NOTE: 'Rechnungskorrektur'
|
||||
};
|
||||
const typeColors: Record<string, string> = {
|
||||
QUOTE: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
ORDER_CONFIRMATION: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
DELIVERY_NOTE: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
INVOICE: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
CREDIT_NOTE: 'bg-red-50 text-red-700 border-red-200'
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
DRAFT: 'Entwurf', SENT: 'Gesendet', ACCEPTED: 'Angenommen',
|
||||
REJECTED: 'Abgelehnt', DELIVERED: 'Geliefert', PAID: 'Bezahlt',
|
||||
CANCELLED: 'Storniert', ARCHIVED: 'Archiviert'
|
||||
};
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-slate-100 text-slate-600', SENT: 'bg-blue-100 text-blue-700',
|
||||
ACCEPTED: 'bg-emerald-100 text-emerald-700', REJECTED: 'bg-red-100 text-red-700',
|
||||
DELIVERED: 'bg-amber-100 text-amber-700', PAID: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-slate-200 text-slate-500', ARCHIVED: 'bg-slate-100 text-slate-400'
|
||||
};
|
||||
|
||||
export default function SalesPage() {
|
||||
const [docs, setDocs] = useState<any[]>([]);
|
||||
const [filter, setFilter] = useState('ACTIVE');
|
||||
const [search, setSearch] = useState('');
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const perms = (session?.user as any)?.permissions || [];
|
||||
const canCreate = perms.includes('SALES_MANAGE');
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => { fetchDocs(); }, []);
|
||||
|
||||
const fetchDocs = async () => {
|
||||
const res = await fetch('/api/sales');
|
||||
if (res.ok) setDocs(await res.json());
|
||||
};
|
||||
|
||||
// Filter + Search logic
|
||||
const filtered = docs.filter(d => {
|
||||
// Type filter
|
||||
if (filter === 'ACTIVE' && (d.status === 'ARCHIVED' || d.status === 'CANCELLED')) return false;
|
||||
if (filter === 'ARCHIVED' && d.status !== 'ARCHIVED') return false;
|
||||
if (filter !== 'ALL' && filter !== 'ACTIVE' && filter !== 'ARCHIVED' && d.type !== filter) return false;
|
||||
|
||||
// Search
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
const customerName = d.customer?.companyName || `${d.customer?.firstName} ${d.customer?.lastName}`;
|
||||
return (
|
||||
d.number.toLowerCase().includes(q) ||
|
||||
customerName.toLowerCase().includes(q) ||
|
||||
(typeLabels[d.type] || '').toLowerCase().includes(q) ||
|
||||
(statusLabels[d.status] || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
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">
|
||||
<FileText className="w-6 h-6 text-indigo-600" /> Verkauf
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-1">Angebote, Auftragsbestätigungen, Lieferscheine & Rechnungen.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Live Search */}
|
||||
<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="Nr., Kunde, Typ suchen..." value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-9 pr-8 py-2 border border-slate-300 rounded-xl text-sm outline-none focus-ring w-64" />
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canCreate && (
|
||||
<button onClick={() => router.push('/sales/new')} 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">
|
||||
<Plus className="w-4 h-4" /> Neuer Beleg
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[
|
||||
{ key: 'ACTIVE', label: 'Aktiv' },
|
||||
{ key: 'ALL', label: 'Alle' },
|
||||
{ key: 'QUOTE', label: 'Angebote' },
|
||||
{ key: 'ORDER_CONFIRMATION', label: 'ABs' },
|
||||
{ key: 'DELIVERY_NOTE', label: 'Lieferscheine' },
|
||||
{ key: 'INVOICE', label: 'Rechnungen' },
|
||||
{ key: 'CREDIT_NOTE', label: 'Korrekturen' },
|
||||
{ key: 'ARCHIVED', label: 'Archiv' }
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${filter === f.key ? 'bg-indigo-600 text-white' : 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'}`}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="py-4 px-6">Nummer</th>
|
||||
<th className="py-4 px-6">Typ</th>
|
||||
<th className="py-4 px-6">Kunde</th>
|
||||
<th className="py-4 px-6">Status</th>
|
||||
<th className="py-4 px-6 text-right">Betrag</th>
|
||||
<th className="py-4 px-6">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filtered.map(d => (
|
||||
<tr key={d.id} onClick={() => router.push(`/sales/${d.id}`)}
|
||||
className={`hover:bg-indigo-50/40 transition-colors cursor-pointer ${d.status === 'ARCHIVED' ? 'opacity-60' : ''}`}>
|
||||
<td className="py-4 px-6 font-mono font-semibold text-slate-900">{d.number}</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${typeColors[d.type]}`}>{typeLabels[d.type]}</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-slate-700">{d.customer?.companyName || `${d.customer?.firstName} ${d.customer?.lastName}`}</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${statusColors[d.status]}`}>{statusLabels[d.status]}</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right font-semibold text-slate-900">{d.total.toFixed(2)} €</td>
|
||||
<td className="py-4 px-6 text-slate-500">{new Date(d.createdAt).toLocaleDateString('de-DE')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
{search ? `Keine Ergebnisse für "${search}"` : 'Keine Belege vorhanden.'}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length > 0 && (
|
||||
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 text-xs text-slate-500">
|
||||
{filtered.length} Beleg{filtered.length !== 1 ? 'e' : ''} angezeigt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user