Refactor financial transaction handling: Consolidate Einnahmen and Ausgaben into Buchung model, update routes and UI components, and add new migration scripts for database schema changes.
This commit is contained in:
+294
-110
@@ -16,6 +16,8 @@ type Transaction = {
|
||||
type: 'einlage' | 'entnahme';
|
||||
amount: number;
|
||||
description: string;
|
||||
isBusinessRecord: boolean;
|
||||
kategorie: string | null;
|
||||
};
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
@@ -29,6 +31,16 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
const buchungen = await prisma.buchung.findMany({
|
||||
where: { companyId: company.id },
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
account: true,
|
||||
type: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
isBusinessRecord: true,
|
||||
kategorie: true,
|
||||
},
|
||||
});
|
||||
|
||||
const transactions: Transaction[] = buchungen.map((b): Transaction => ({
|
||||
@@ -38,6 +50,8 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme',
|
||||
amount: Number(b.amount),
|
||||
description: b.description || '',
|
||||
isBusinessRecord: b.isBusinessRecord,
|
||||
kategorie: b.kategorie || null,
|
||||
}));
|
||||
|
||||
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
|
||||
@@ -59,22 +73,40 @@ export default function CompanyMoney() {
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||
const [isUmbuchung, setIsUmbuchung] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
account: 'kasse' as 'kasse' | 'bank',
|
||||
type: 'einlage' as 'einlage' | 'entnahme',
|
||||
amount: '',
|
||||
description: '',
|
||||
toAccount: 'bank' as 'kasse' | 'bank',
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setEditingTransaction(null);
|
||||
setIsUmbuchung(false);
|
||||
setForm({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
account: 'kasse',
|
||||
type: 'einlage',
|
||||
amount: '',
|
||||
description: '',
|
||||
toAccount: 'bank',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openCreateUmbuchung() {
|
||||
setEditingTransaction(null);
|
||||
setIsUmbuchung(true);
|
||||
setForm({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
account: 'kasse',
|
||||
type: 'umbuchung',
|
||||
amount: '',
|
||||
description: '',
|
||||
toAccount: 'bank',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}
|
||||
@@ -93,27 +125,46 @@ export default function CompanyMoney() {
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
date: form.date,
|
||||
account: form.account,
|
||||
type: form.type,
|
||||
amount: parseFloat(form.amount),
|
||||
description: form.description,
|
||||
};
|
||||
const payload = isUmbuchung
|
||||
? {
|
||||
date: form.date,
|
||||
account: form.account,
|
||||
type: 'umbuchung',
|
||||
toAccount: form.toAccount,
|
||||
amount: parseFloat(form.amount),
|
||||
description: form.description,
|
||||
}
|
||||
: {
|
||||
date: form.date,
|
||||
account: form.account,
|
||||
type: form.type,
|
||||
amount: parseFloat(form.amount),
|
||||
description: form.description,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingTransaction) {
|
||||
await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
|
||||
const res = await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(error.error || "Fehler beim Speichern");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await fetch(`/api/companies/${companyId}/money`, {
|
||||
const res = await fetch(`/api/companies/${companyId}/money`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(error.error || "Fehler beim Speichern");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDialogOpen(false);
|
||||
revalidate();
|
||||
@@ -158,6 +209,10 @@ export default function CompanyMoney() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={openCreateUmbuchung} variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
Umbuchung
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Transaktion
|
||||
@@ -187,7 +242,7 @@ export default function CompanyMoney() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
{/* Split-View: Kasse und Bank nebeneinander */}
|
||||
{sortedTransactions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-gray-400">
|
||||
@@ -199,80 +254,181 @@ export default function CompanyMoney() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Konto
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Betrag
|
||||
</th>
|
||||
<th className="px-3 py-2.5 w-16" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sortedTransactions.map((transaction) => (
|
||||
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
|
||||
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||
{transaction.date}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||
{transaction.account === 'kasse' ? 'Kasse' : 'Bank'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
|
||||
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-700">
|
||||
{transaction.description}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
||||
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
|
||||
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => openEdit(transaction)}
|
||||
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(transaction.id)}
|
||||
disabled={deleting === transaction.id}
|
||||
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
{deleting === transaction.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Kasse Tabelle */}
|
||||
<Card>
|
||||
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-700">Kasse</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Kategorie
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Betrag
|
||||
</th>
|
||||
<th className="px-3 py-2.5 w-16" />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sortedTransactions
|
||||
.filter((t) => t.account === 'kasse')
|
||||
.map((transaction) => (
|
||||
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
|
||||
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||
{transaction.date}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
|
||||
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-700">
|
||||
{transaction.description}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600 text-xs">
|
||||
{transaction.kategorie || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
||||
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
|
||||
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{transaction.isBusinessRecord ? (
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
Automatisch
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => openEdit(transaction)}
|
||||
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(transaction.id)}
|
||||
disabled={deleting === transaction.id}
|
||||
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
{deleting === transaction.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bank Tabelle */}
|
||||
<Card>
|
||||
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-700">Bank</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Kategorie
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Betrag
|
||||
</th>
|
||||
<th className="px-3 py-2.5 w-16" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sortedTransactions
|
||||
.filter((t) => t.account === 'bank')
|
||||
.map((transaction) => (
|
||||
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
|
||||
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||
{transaction.date}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
|
||||
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-700">
|
||||
{transaction.description}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600 text-xs">
|
||||
{transaction.kategorie || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
||||
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
|
||||
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{transaction.isBusinessRecord ? (
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
Automatisch
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => openEdit(transaction)}
|
||||
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(transaction.id)}
|
||||
disabled={deleting === transaction.id}
|
||||
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
{deleting === transaction.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog: Anlegen / Bearbeiten */}
|
||||
@@ -297,33 +453,61 @@ export default function CompanyMoney() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Konto <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.account}
|
||||
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="kasse">Kasse</option>
|
||||
<option value="bank">Bank</option>
|
||||
</select>
|
||||
</div>
|
||||
{isUmbuchung ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Von (Konto) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.account}
|
||||
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank', toAccount: e.target.value === 'kasse' ? 'bank' : 'kasse' }))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="kasse">Kasse</option>
|
||||
<option value="bank">Bank</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nach (Konto)
|
||||
</label>
|
||||
<div className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50">
|
||||
{form.toAccount === 'kasse' ? 'Kasse' : 'Bank'}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Konto <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.account}
|
||||
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="kasse">Kasse</option>
|
||||
<option value="bank">Bank</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Typ <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="einlage">Einlage</option>
|
||||
<option value="entnahme">Entnahme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Typ <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="einlage">Einnahme (Einlage)</option>
|
||||
<option value="entnahme">Ausgabe (Entnahme)</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -360,7 +544,7 @@ export default function CompanyMoney() {
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !formValid}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{editingTransaction ? "Speichern" : "Hinzufügen"}
|
||||
{editingTransaction ? "Speichern" : isUmbuchung ? "Umbuchung durchführen" : "Hinzufügen"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user