feat(sprint8): Phase 2 — Treasury frontend + PDF receipts

Backend:
- ReceiptPdfService: Generates Quittung PDF per payment (OpenPDF, A4)
- FinancialReportService: Annual financial report PDF (Jahresabschluss)
- FinanceController: Added receipt download, annual report, CSV export endpoints
- Portal receipt download with member ownership verification

Frontend:
- src/services/finance.ts: Complete React Query service (types, hooks, mutations)
- /finance: Dashboard with KPI cards, recent transactions, outstanding members
- /finance/payments: Payment list with filtering, void, receipt download
- /finance/kassenbuch: Kassenbuch ledger with date range, CSV export
- /finance/fee-schedules: Fee schedule CRUD with interval management
- /finance/reports: Annual report PDF download
- /portal/finance: Member self-service balance + payment history + receipts

Navigation & i18n:
- Added Finanzen (Wallet icon) to admin sidebar
- Portal finance page for member payments
- Comprehensive de.json + en.json finance keys (~100 translations)
This commit is contained in:
Patrick Plate
2026-06-15 08:24:43 +02:00
parent 721503b231
commit 3211ade5be
13 changed files with 2419 additions and 1 deletions
+110
View File
@@ -839,5 +839,115 @@
"topicLocked": "Dieses Thema ist gesperrt. Neue Antworten sind nicht möglich.",
"reportReason": "Grund der Meldung:",
"backToTopics": "Zurück zur Übersicht"
},
"finance": {
"title": "Vereinsfinanzen",
"subtitle": "Übersicht über Einnahmen, Ausgaben und Mitgliedsbeiträge",
"totalBalance": "Gesamtsaldo",
"incomeThisMonth": "Einnahmen (Monat)",
"expensesThisMonth": "Ausgaben (Monat)",
"outstandingMembers": "Offene Beiträge",
"outstandingTitle": "Säumige Mitglieder",
"monthsOverdue": "Monate überfällig",
"noOutstanding": "Keine offenen Beiträge",
"recentTransactions": "Letzte Buchungen",
"noTransactions": "Keine Buchungen vorhanden",
"recordPayment": "Zahlung erfassen",
"recordExpense": "Ausgabe erfassen",
"payments": "Zahlungen",
"kassenbuch": "Kassenbuch",
"feeSchedules": "Beitragsordnung",
"reports": "Berichte",
"exportCsv": "CSV exportieren",
"from": "Von",
"to": "Bis",
"date": "Datum",
"type": "Typ",
"description": "Beschreibung",
"income": "Einnahme",
"expense": "Ausgabe",
"balance": "Saldo",
"incomeLabel": "Einnahme",
"expenseLabel": "Ausgabe",
"loading": "Laden...",
"previous": "Zurück",
"next": "Weiter",
"member": "Mitglied",
"amount": "Betrag",
"period": "Zeitraum",
"paymentMethod": "Zahlungsart",
"actions": "Aktionen",
"filterByStatus": "Nach Status filtern",
"allStatuses": "Alle",
"noPayments": "Keine Zahlungen vorhanden",
"voidReason": "Grund für die Stornierung:",
"memberIdPlaceholder": "Mitglieds-ID eingeben",
"periodFrom": "Zeitraum von",
"periodTo": "Zeitraum bis",
"reference": "Referenz",
"referencePlaceholder": "z.B. Überweisungsreferenz",
"expenseReferencePlaceholder": "z.B. Rechnungsnummer",
"saving": "Wird gespeichert...",
"category": "Kategorie",
"descriptionPlaceholder": "z.B. Stromrechnung Juni",
"bankTransfer": "Überweisung",
"cash": "Bar",
"sepa": "SEPA-Lastschrift",
"card": "Kartenzahlung",
"createFeeSchedule": "Beitragssatz erstellen",
"scheduleName": "Bezeichnung",
"scheduleNamePlaceholder": "z.B. Monatsbeitrag Standard",
"intervalLabel": "Intervall",
"scheduleDescPlaceholder": "Optionale Beschreibung",
"default": "Standard",
"deactivate": "Deaktivieren",
"noFeeSchedules": "Keine Beitragssätze vorhanden",
"annualReport": "Jahresabschluss",
"annualReportDescription": "Vollständiger Finanzbericht mit Einnahmen, Ausgaben und Mitgliederbeiträgen.",
"auditorReport": "Kassenprüfbericht",
"auditorReportDescription": "Bericht für den Kassenprüfer mit allen Transaktionsdetails.",
"downloadPdf": "PDF herunterladen",
"comingSoon": "Demnächst verfügbar",
"status": {
"label": "Status",
"paid": "Bezahlt",
"pending": "Ausstehend",
"overdue": "Überfällig",
"voided": "Storniert"
},
"method": {
"BANK_TRANSFER": "Überweisung",
"CASH": "Bar",
"SEPA": "SEPA-Lastschrift",
"CARD": "Kartenzahlung"
},
"interval": {
"MONTHLY": "Monatlich",
"QUARTERLY": "Vierteljährlich",
"YEARLY": "Jährlich",
"ONE_TIME": "Einmalig"
},
"categories": {
"rent": "Miete",
"utilities": "Nebenkosten",
"equipment": "Ausstattung",
"seeds": "Saatgut",
"supplies": "Verbrauchsmaterial",
"insurance": "Versicherung",
"legal": "Rechtsberatung",
"other": "Sonstiges"
},
"portal": {
"title": "Meine Zahlungen",
"currentBalance": "Aktueller Saldo",
"allPaid": "Alle Beiträge bezahlt",
"paymentDue": "Zahlung fällig",
"noFeeAssigned": "Kein Beitragssatz zugewiesen",
"feeSchedule": "Beitragssatz",
"noFee": "Keiner",
"lastPayment": "Letzte Zahlung",
"paymentHistory": "Zahlungshistorie",
"noPayments": "Noch keine Zahlungen vorhanden"
}
}
}
+110
View File
@@ -839,5 +839,115 @@
"topicLocked": "This topic is locked. New replies are not possible.",
"reportReason": "Reason for report:",
"backToTopics": "Back to overview"
},
"finance": {
"title": "Club Finances",
"subtitle": "Overview of income, expenses, and member contributions",
"totalBalance": "Total Balance",
"incomeThisMonth": "Income (Month)",
"expensesThisMonth": "Expenses (Month)",
"outstandingMembers": "Outstanding Fees",
"outstandingTitle": "Outstanding Members",
"monthsOverdue": "months overdue",
"noOutstanding": "No outstanding fees",
"recentTransactions": "Recent Transactions",
"noTransactions": "No transactions available",
"recordPayment": "Record Payment",
"recordExpense": "Record Expense",
"payments": "Payments",
"kassenbuch": "Cash Book",
"feeSchedules": "Fee Schedules",
"reports": "Reports",
"exportCsv": "Export CSV",
"from": "From",
"to": "To",
"date": "Date",
"type": "Type",
"description": "Description",
"income": "Income",
"expense": "Expense",
"balance": "Balance",
"incomeLabel": "Income",
"expenseLabel": "Expense",
"loading": "Loading...",
"previous": "Previous",
"next": "Next",
"member": "Member",
"amount": "Amount",
"period": "Period",
"paymentMethod": "Payment Method",
"actions": "Actions",
"filterByStatus": "Filter by status",
"allStatuses": "All",
"noPayments": "No payments available",
"voidReason": "Reason for voiding:",
"memberIdPlaceholder": "Enter member ID",
"periodFrom": "Period from",
"periodTo": "Period to",
"reference": "Reference",
"referencePlaceholder": "e.g. transfer reference",
"expenseReferencePlaceholder": "e.g. invoice number",
"saving": "Saving...",
"category": "Category",
"descriptionPlaceholder": "e.g. Electricity bill June",
"bankTransfer": "Bank Transfer",
"cash": "Cash",
"sepa": "SEPA Direct Debit",
"card": "Card Payment",
"createFeeSchedule": "Create Fee Schedule",
"scheduleName": "Name",
"scheduleNamePlaceholder": "e.g. Standard Monthly Fee",
"intervalLabel": "Interval",
"scheduleDescPlaceholder": "Optional description",
"default": "Default",
"deactivate": "Deactivate",
"noFeeSchedules": "No fee schedules available",
"annualReport": "Annual Report",
"annualReportDescription": "Complete financial report with income, expenses, and member contributions.",
"auditorReport": "Auditor Report",
"auditorReportDescription": "Report for the auditor with all transaction details.",
"downloadPdf": "Download PDF",
"comingSoon": "Coming soon",
"status": {
"label": "Status",
"paid": "Paid",
"pending": "Pending",
"overdue": "Overdue",
"voided": "Voided"
},
"method": {
"BANK_TRANSFER": "Bank Transfer",
"CASH": "Cash",
"SEPA": "SEPA Direct Debit",
"CARD": "Card Payment"
},
"interval": {
"MONTHLY": "Monthly",
"QUARTERLY": "Quarterly",
"YEARLY": "Yearly",
"ONE_TIME": "One-time"
},
"categories": {
"rent": "Rent",
"utilities": "Utilities",
"equipment": "Equipment",
"seeds": "Seeds",
"supplies": "Supplies",
"insurance": "Insurance",
"legal": "Legal",
"other": "Other"
},
"portal": {
"title": "My Payments",
"currentBalance": "Current Balance",
"allPaid": "All fees paid",
"paymentDue": "Payment due",
"noFeeAssigned": "No fee schedule assigned",
"feeSchedule": "Fee Schedule",
"noFee": "None",
"lastPayment": "Last payment",
"paymentHistory": "Payment History",
"noPayments": "No payments yet"
}
}
}
@@ -0,0 +1,181 @@
"use client"
import { useState } from "react"
import {
formatAmountCents,
useCreateFeeScheduleMutation,
useDeactivateFeeScheduleMutation,
useFeeSchedulesQuery,
} from "@/services/finance"
import { useTranslations } from "next-intl"
import { Plus, Power } from "lucide-react"
import type { CreateFeeScheduleRequest, FeeInterval } from "@/services/finance"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
export default function FeeSchedulesPage() {
const t = useTranslations("finance")
const { data: schedules, isLoading } = useFeeSchedulesQuery()
const createMutation = useCreateFeeScheduleMutation()
const deactivateMutation = useDeactivateFeeScheduleMutation()
const [showCreate, setShowCreate] = useState(false)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("feeSchedules")}</h1>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("createFeeSchedule")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("createFeeSchedule")}</DialogTitle>
</DialogHeader>
<FeeScheduleForm
onSubmit={(data) => {
createMutation.mutate(data, {
onSuccess: () => setShowCreate(false),
})
}}
isLoading={createMutation.isPending}
/>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{schedules?.map((schedule) => (
<Card key={schedule.id}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">{schedule.name}</CardTitle>
{schedule.isDefault && (
<Badge variant="secondary">{t("default")}</Badge>
)}
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="text-2xl font-bold">
{formatAmountCents(schedule.amountCents)}
</div>
<div className="text-sm text-muted-foreground">
{t(`interval.${schedule.interval}`)}
</div>
{schedule.description && (
<p className="text-sm text-muted-foreground">
{schedule.description}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => deactivateMutation.mutate(schedule.id)}
disabled={!schedule.active}
>
<Power className="mr-1 h-3 w-3" />
{t("deactivate")}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{!schedules?.length && !isLoading && (
<p className="col-span-full text-center text-muted-foreground">
{t("noFeeSchedules")}
</p>
)}
</div>
</div>
)
}
function FeeScheduleForm({
onSubmit,
isLoading,
}: {
onSubmit: (data: CreateFeeScheduleRequest) => void
isLoading: boolean
}) {
const t = useTranslations("finance")
const [name, setName] = useState("")
const [amount, setAmount] = useState("")
const [interval, setInterval] = useState<FeeInterval>("MONTHLY")
const [description, setDescription] = useState("")
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({
name,
amountCents: Math.round(parseFloat(amount) * 100),
interval,
description: description || undefined,
})
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>{t("scheduleName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("scheduleNamePlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("amount")}</Label>
<Input
type="number"
step="0.01"
min="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="30.00"
required
/>
</div>
<div className="space-y-2">
<Label>{t("intervalLabel")}</Label>
<Select
value={interval}
onChange={(e) => setInterval(e.target.value as FeeInterval)}
>
<option value="MONTHLY">{t("interval.MONTHLY")}</option>
<option value="QUARTERLY">{t("interval.QUARTERLY")}</option>
<option value="YEARLY">{t("interval.YEARLY")}</option>
<option value="ONE_TIME">{t("interval.ONE_TIME")}</option>
</Select>
</div>
<div className="space-y-2">
<Label>{t("description")}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("scheduleDescPlaceholder")}
/>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? t("saving") : t("createFeeSchedule")}
</Button>
</form>
)
}
@@ -0,0 +1,157 @@
"use client"
import { useState } from "react"
import {
formatAmountCents,
getKassenbuchCsvDownloadUrl,
useLedgerQuery,
} from "@/services/finance"
import { endOfMonth, format, startOfMonth } from "date-fns"
import { useTranslations } from "next-intl"
import { Download, TrendingDown, TrendingUp } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default function KassenbuchPage() {
const t = useTranslations("finance")
const now = new Date()
const [from, setFrom] = useState(format(startOfMonth(now), "yyyy-MM-dd"))
const [to, setTo] = useState(format(endOfMonth(now), "yyyy-MM-dd"))
const [page, setPage] = useState(0)
const { data: ledger, isLoading } = useLedgerQuery(from, to, {
page,
size: 50,
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("kassenbuch")}</h1>
<a href={getKassenbuchCsvDownloadUrl(from, to)}>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
{t("exportCsv")}
</Button>
</a>
</div>
{/* Date range picker */}
<Card>
<CardContent className="flex items-end gap-4 pt-6">
<div className="space-y-2">
<Label>{t("from")}</Label>
<Input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t("to")}</Label>
<Input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Ledger table */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">{t("date")}</th>
<th className="p-3 text-left font-medium">{t("type")}</th>
<th className="p-3 text-left font-medium">
{t("description")}
</th>
<th className="p-3 text-right font-medium">{t("income")}</th>
<th className="p-3 text-right font-medium">{t("expense")}</th>
<th className="p-3 text-right font-medium">{t("balance")}</th>
</tr>
</thead>
<tbody>
{ledger?.content?.map((entry) => (
<tr key={entry.id} className="border-b">
<td className="p-3">{entry.transactionDate}</td>
<td className="p-3">
<span className="flex items-center gap-1">
{entry.transactionType === "INCOME" ? (
<TrendingUp className="h-3 w-3 text-green-600" />
) : (
<TrendingDown className="h-3 w-3 text-red-600" />
)}
{entry.transactionType === "INCOME"
? t("incomeLabel")
: t("expenseLabel")}
</span>
</td>
<td className="p-3">{entry.description}</td>
<td className="p-3 text-right text-green-600">
{entry.transactionType === "INCOME"
? formatAmountCents(entry.amountCents)
: ""}
</td>
<td className="p-3 text-right text-red-600">
{entry.transactionType === "EXPENSE"
? formatAmountCents(entry.amountCents)
: ""}
</td>
<td className="p-3 text-right font-medium">
{entry.runningBalanceCents != null
? formatAmountCents(entry.runningBalanceCents)
: "—"}
</td>
</tr>
))}
{(!ledger?.content || ledger.content.length === 0) && (
<tr>
<td
colSpan={6}
className="p-6 text-center text-muted-foreground"
>
{isLoading ? t("loading") : t("noTransactions")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Pagination */}
{ledger && ledger.totalPages > 1 && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
{t("previous")}
</Button>
<span className="flex items-center px-3 text-sm text-muted-foreground">
{page + 1} / {ledger.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= ledger.totalPages - 1}
onClick={() => setPage(page + 1)}
>
{t("next")}
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,457 @@
"use client"
import { useState } from "react"
import {
formatAmountCents,
getKassenbuchCsvDownloadUrl,
useFinancialSummaryQuery,
useLedgerQuery,
useOutstandingMembersQuery,
useRecordExpenseMutation,
useRecordPaymentMutation,
} from "@/services/finance"
import { endOfMonth, format, startOfMonth } from "date-fns"
import { useTranslations } from "next-intl"
import {
AlertTriangle,
Banknote,
Download,
Plus,
TrendingDown,
TrendingUp,
} from "lucide-react"
import type {
ExpenseCategory,
PaymentMethod,
RecordExpenseRequest,
RecordPaymentRequest,
} from "@/services/finance"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
export default function FinancePage() {
const t = useTranslations("finance")
const now = new Date()
const monthStart = format(startOfMonth(now), "yyyy-MM-dd")
const monthEnd = format(endOfMonth(now), "yyyy-MM-dd")
const { data: summary } = useFinancialSummaryQuery(monthStart, monthEnd)
const { data: outstanding } = useOutstandingMembersQuery()
const { data: ledger } = useLedgerQuery(monthStart, monthEnd, {
page: 0,
size: 10,
})
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
const [showExpenseDialog, setShowExpenseDialog] = useState(false)
const recordPayment = useRecordPaymentMutation()
const recordExpense = useRecordExpenseMutation()
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="flex gap-2">
<Dialog open={showPaymentDialog} onOpenChange={setShowPaymentDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("recordPayment")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("recordPayment")}</DialogTitle>
</DialogHeader>
<PaymentForm
onSubmit={(data) => {
recordPayment.mutate(data, {
onSuccess: () => setShowPaymentDialog(false),
})
}}
isLoading={recordPayment.isPending}
/>
</DialogContent>
</Dialog>
<Dialog open={showExpenseDialog} onOpenChange={setShowExpenseDialog}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
{t("recordExpense")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("recordExpense")}</DialogTitle>
</DialogHeader>
<ExpenseForm
onSubmit={(data) => {
recordExpense.mutate(data, {
onSuccess: () => setShowExpenseDialog(false),
})
}}
isLoading={recordExpense.isPending}
/>
</DialogContent>
</Dialog>
</div>
</div>
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{t("totalBalance")}
</CardTitle>
<Banknote className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{summary ? formatAmountCents(summary.netBalanceCents) : "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{t("incomeThisMonth")}
</CardTitle>
<TrendingUp className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{summary ? formatAmountCents(summary.incomeThisMonthCents) : "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{t("expensesThisMonth")}
</CardTitle>
<TrendingDown className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{summary
? formatAmountCents(summary.expensesThisMonthCents)
: "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{t("outstandingMembers")}
</CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">
{summary ? summary.outstandingMembersCount : "—"}
</div>
</CardContent>
</Card>
</div>
{/* Recent Transactions & Outstanding */}
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("recentTransactions")}</CardTitle>
<a href={getKassenbuchCsvDownloadUrl(monthStart, monthEnd)}>
<Button variant="ghost" size="sm">
<Download className="mr-2 h-4 w-4" />
CSV
</Button>
</a>
</CardHeader>
<CardContent>
{ledger?.content && ledger.content.length > 0 ? (
<div className="space-y-2">
{ledger.content.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div
className={`rounded-full p-1.5 ${
entry.transactionType === "INCOME"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{entry.transactionType === "INCOME" ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
</div>
<div>
<p className="text-sm font-medium">
{entry.description}
</p>
<p className="text-xs text-muted-foreground">
{entry.transactionDate}
</p>
</div>
</div>
<span
className={`text-sm font-medium ${
entry.transactionType === "INCOME"
? "text-green-600"
: "text-red-600"
}`}
>
{entry.transactionType === "INCOME" ? "+" : "-"}
{formatAmountCents(entry.amountCents)}
</span>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground">
{t("noTransactions")}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("outstandingTitle")}</CardTitle>
</CardHeader>
<CardContent>
{outstanding && outstanding.length > 0 ? (
<div className="space-y-3">
{outstanding.slice(0, 5).map((member) => (
<div
key={member.memberId}
className="flex items-center justify-between"
>
<div>
<p className="text-sm font-medium">{member.memberName}</p>
<p className="text-xs text-muted-foreground">
{member.monthsOverdue} {t("monthsOverdue")}
</p>
</div>
<Badge variant="destructive">
{formatAmountCents(member.outstandingCents)}
</Badge>
</div>
))}
</div>
) : (
<p className="text-center text-sm text-muted-foreground">
{t("noOutstanding")}
</p>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function PaymentForm({
onSubmit,
isLoading,
}: {
onSubmit: (data: RecordPaymentRequest) => void
isLoading: boolean
}) {
const t = useTranslations("finance")
const [memberId, setMemberId] = useState("")
const [amount, setAmount] = useState("")
const [method, setMethod] = useState<PaymentMethod>("BANK_TRANSFER")
const [periodFrom, setPeriodFrom] = useState("")
const [periodTo, setPeriodTo] = useState("")
const [reference, setReference] = useState("")
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({
memberId,
amountCents: Math.round(parseFloat(amount) * 100),
paymentMethod: method,
periodFrom: periodFrom || undefined,
periodTo: periodTo || undefined,
reference: reference || undefined,
})
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>{t("member")}</Label>
<Input
value={memberId}
onChange={(e) => setMemberId(e.target.value)}
placeholder={t("memberIdPlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("amount")}</Label>
<Input
type="number"
step="0.01"
min="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="30.00"
required
/>
</div>
<div className="space-y-2">
<Label>{t("paymentMethod")}</Label>
<Select
value={method}
onChange={(e) => setMethod(e.target.value as PaymentMethod)}
>
<option value="BANK_TRANSFER">{t("bankTransfer")}</option>
<option value="CASH">{t("cash")}</option>
<option value="SEPA">{t("sepa")}</option>
<option value="CARD">{t("card")}</option>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t("periodFrom")}</Label>
<Input
type="date"
value={periodFrom}
onChange={(e) => setPeriodFrom(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t("periodTo")}</Label>
<Input
type="date"
value={periodTo}
onChange={(e) => setPeriodTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t("reference")}</Label>
<Input
value={reference}
onChange={(e) => setReference(e.target.value)}
placeholder={t("referencePlaceholder")}
/>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? t("saving") : t("recordPayment")}
</Button>
</form>
)
}
function ExpenseForm({
onSubmit,
isLoading,
}: {
onSubmit: (data: RecordExpenseRequest) => void
isLoading: boolean
}) {
const t = useTranslations("finance")
const [category, setCategory] = useState<ExpenseCategory>("OTHER")
const [amount, setAmount] = useState("")
const [description, setDescription] = useState("")
const [reference, setReference] = useState("")
const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd"))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({
category,
amountCents: Math.round(parseFloat(amount) * 100),
description,
reference: reference || undefined,
transactionDate: date || undefined,
})
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>{t("category")}</Label>
<Select
value={category}
onChange={(e) => setCategory(e.target.value as ExpenseCategory)}
>
<option value="RENT">{t("categories.rent")}</option>
<option value="UTILITIES">{t("categories.utilities")}</option>
<option value="EQUIPMENT">{t("categories.equipment")}</option>
<option value="SEEDS">{t("categories.seeds")}</option>
<option value="SUPPLIES">{t("categories.supplies")}</option>
<option value="INSURANCE">{t("categories.insurance")}</option>
<option value="LEGAL">{t("categories.legal")}</option>
<option value="OTHER">{t("categories.other")}</option>
</Select>
</div>
<div className="space-y-2">
<Label>{t("amount")}</Label>
<Input
type="number"
step="0.01"
min="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="85.00"
required
/>
</div>
<div className="space-y-2">
<Label>{t("description")}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("date")}</Label>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>{t("reference")}</Label>
<Input
value={reference}
onChange={(e) => setReference(e.target.value)}
placeholder={t("expenseReferencePlaceholder")}
/>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? t("saving") : t("recordExpense")}
</Button>
</form>
)
}
@@ -0,0 +1,169 @@
"use client"
import { useState } from "react"
import {
formatAmountCents,
getReceiptDownloadUrl,
usePaymentsQuery,
useVoidPaymentMutation,
} from "@/services/finance"
import { useTranslations } from "next-intl"
import { Ban, Receipt } from "lucide-react"
import type { PaymentStatus } from "@/services/finance"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Select } from "@/components/ui/select"
export default function PaymentsPage() {
const t = useTranslations("finance")
const [statusFilter, setStatusFilter] = useState<string>("ALL")
const [page, setPage] = useState(0)
const { data: payments, isLoading } = usePaymentsQuery({
status:
statusFilter === "ALL" ? undefined : (statusFilter as PaymentStatus),
page,
size: 20,
})
const voidMutation = useVoidPaymentMutation()
const handleVoid = (id: string) => {
const reason = prompt(t("voidReason"))
if (reason) {
voidMutation.mutate({ id, reason })
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("payments")}</h1>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-[180px]"
>
<option value="ALL">{t("allStatuses")}</option>
<option value="PAID">{t("status.paid")}</option>
<option value="PENDING">{t("status.pending")}</option>
<option value="OVERDUE">{t("status.overdue")}</option>
<option value="VOIDED">{t("status.voided")}</option>
</Select>
</div>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">{t("member")}</th>
<th className="p-3 text-left font-medium">{t("amount")}</th>
<th className="p-3 text-left font-medium">{t("period")}</th>
<th className="p-3 text-left font-medium">
{t("paymentMethod")}
</th>
<th className="p-3 text-left font-medium">{t("date")}</th>
<th className="p-3 text-left font-medium">
{t("status.label")}
</th>
<th className="p-3 text-left font-medium">{t("actions")}</th>
</tr>
</thead>
<tbody>
{payments?.content?.map((payment) => (
<tr key={payment.id} className="border-b">
<td className="p-3">
{payment.memberName || payment.memberId.substring(0, 8)}
</td>
<td className="p-3 font-medium">
{formatAmountCents(payment.amountCents)}
</td>
<td className="p-3 text-muted-foreground">
{payment.periodFrom && payment.periodTo
? `${payment.periodFrom} ${payment.periodTo}`
: "—"}
</td>
<td className="p-3">
{t(`method.${payment.paymentMethod}`)}
</td>
<td className="p-3">{payment.paymentDate}</td>
<td className="p-3">
<Badge
variant={payment.voided ? "destructive" : "default"}
>
{payment.voided ? t("status.voided") : t("status.paid")}
</Badge>
</td>
<td className="p-3">
<div className="flex gap-1">
{!payment.voided && (
<>
<a
href={getReceiptDownloadUrl(payment.id)}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="sm">
<Receipt className="h-4 w-4" />
</Button>
</a>
<Button
variant="ghost"
size="sm"
onClick={() => handleVoid(payment.id)}
>
<Ban className="h-4 w-4 text-red-500" />
</Button>
</>
)}
</div>
</td>
</tr>
))}
{(!payments?.content || payments.content.length === 0) && (
<tr>
<td
colSpan={7}
className="p-6 text-center text-muted-foreground"
>
{isLoading ? t("loading") : t("noPayments")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{payments && payments.totalPages > 1 && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
{t("previous")}
</Button>
<span className="flex items-center px-3 text-sm text-muted-foreground">
{page + 1} / {payments.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= payments.totalPages - 1}
onClick={() => setPage(page + 1)}
>
{t("next")}
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,80 @@
"use client"
import { useState } from "react"
import { getAnnualReportDownloadUrl } from "@/services/finance"
import { useTranslations } from "next-intl"
import { Download, FileText } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Select } from "@/components/ui/select"
export default function ReportsPage() {
const t = useTranslations("finance")
const currentYear = new Date().getFullYear()
const [selectedYear, setSelectedYear] = useState(String(currentYear))
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t("reports")}</h1>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{t("annualReport")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("annualReportDescription")}
</p>
<div className="flex items-end gap-4">
<Select
value={selectedYear}
onChange={(e) => setSelectedYear(e.target.value)}
>
{years.map((year) => (
<option key={year} value={String(year)}>
{year}
</option>
))}
</Select>
<a
href={getAnnualReportDownloadUrl(parseInt(selectedYear))}
target="_blank"
rel="noopener noreferrer"
>
<Button>
<Download className="mr-2 h-4 w-4" />
{t("downloadPdf")}
</Button>
</a>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{t("auditorReport")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("auditorReportDescription")}
</p>
<Button variant="outline" disabled>
<Download className="mr-2 h-4 w-4" />
{t("comingSoon")}
</Button>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,138 @@
"use client"
import {
formatAmountCents,
getPortalReceiptDownloadUrl,
useMyBalanceQuery,
useMyPaymentsQuery,
} from "@/services/finance"
import { useTranslations } from "next-intl"
import { AlertCircle, CheckCircle, Clock, Receipt } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function PortalFinancePage() {
const t = useTranslations("finance")
const { data: balance } = useMyBalanceQuery()
const { data: payments } = useMyPaymentsQuery({ page: 0, size: 20 })
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t("portal.title")}</h1>
{/* Balance Cards */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{t("portal.currentBalance")}
</CardTitle>
{balance?.status === "CURRENT" ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : balance?.status === "OVERDUE" ? (
<AlertCircle className="h-5 w-5 text-red-600" />
) : (
<Clock className="h-5 w-5 text-muted-foreground" />
)}
</CardHeader>
<CardContent>
<div
className={`text-2xl font-bold ${
balance?.outstandingCents === 0
? "text-green-600"
: (balance?.outstandingCents ?? 0) > 0
? "text-red-600"
: ""
}`}
>
{balance ? formatAmountCents(balance.outstandingCents) : "—"}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{balance?.status === "CURRENT"
? t("portal.allPaid")
: balance?.status === "OVERDUE"
? t("portal.paymentDue")
: t("portal.noFeeAssigned")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{t("portal.feeSchedule")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-lg font-medium">
{balance?.feeScheduleName || t("portal.noFee")}
</div>
{balance?.lastPaymentDate && (
<p className="mt-1 text-xs text-muted-foreground">
{t("portal.lastPayment")}: {balance.lastPaymentDate}
</p>
)}
</CardContent>
</Card>
</div>
{/* Payment History */}
<Card>
<CardHeader>
<CardTitle>{t("portal.paymentHistory")}</CardTitle>
</CardHeader>
<CardContent>
{payments?.content && payments.content.length > 0 ? (
<div className="space-y-3">
{payments.content.map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className="rounded-full bg-green-100 p-1.5 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">
{formatAmountCents(payment.amountCents)}
</p>
<p className="text-xs text-muted-foreground">
{payment.paymentDate}
{payment.periodFrom &&
payment.periodTo &&
`${payment.periodFrom} ${payment.periodTo}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={payment.voided ? "destructive" : "default"}>
{payment.voided ? t("status.voided") : t("status.paid")}
</Badge>
{!payment.voided && (
<a
href={getPortalReceiptDownloadUrl(payment.id)}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="sm">
<Receipt className="h-4 w-4" />
</Button>
</a>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-center text-sm text-muted-foreground">
{t("portal.noPayments")}
</p>
)}
</CardContent>
</Card>
</div>
)
}
@@ -39,6 +39,11 @@ export const navigationsData: NavigationType[] = [
href: "/info-board",
iconName: "Megaphone",
},
{
title: "Finanzen",
href: "/finance",
iconName: "Wallet",
},
{
title: "Kalender",
href: "/calendar",
@@ -0,0 +1,399 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export type PaymentMethod = "CASH" | "BANK_TRANSFER" | "SEPA" | "CARD"
export type PaymentStatus = "PAID" | "PENDING" | "OVERDUE" | "VOIDED"
export type FeeInterval = "MONTHLY" | "QUARTERLY" | "YEARLY" | "ONE_TIME"
export type ExpenseCategory =
| "RENT"
| "UTILITIES"
| "EQUIPMENT"
| "SEEDS"
| "SUPPLIES"
| "INSURANCE"
| "LEGAL"
| "OTHER"
export type TransactionType = "INCOME" | "EXPENSE"
export interface FeeSchedule {
id: string
tenantId: string
name: string
amountCents: number
interval: FeeInterval
description: string | null
active: boolean
isDefault: boolean
createdAt: string
updatedAt: string
}
export interface MemberFeeAssignment {
id: string
tenantId: string
memberId: string
feeScheduleId: string
validFrom: string
validUntil: string | null
notes: string | null
createdAt: string
}
export interface Payment {
id: string
tenantId: string
memberId: string
memberName?: string
amountCents: number
paymentDate: string
paymentMethod: PaymentMethod
reference: string | null
periodFrom: string | null
periodTo: string | null
receiptNumber: string | null
notes: string | null
voided: boolean
voidedReason: string | null
voidedAt: string | null
recordedBy: string
createdAt: string
}
export interface LedgerEntry {
id: string
tenantId: string
transactionDate: string
transactionType: TransactionType
category: string | null
description: string
amountCents: number
reference: string | null
relatedPaymentId: string | null
relatedExpenseId: string | null
runningBalanceCents?: number
recordedBy: string
createdAt: string
}
export interface FinancialSummary {
totalIncomeCents: number
totalExpenseCents: number
netBalanceCents: number
incomeThisMonthCents: number
expensesThisMonthCents: number
outstandingMembersCount: number
totalOutstandingCents: number
}
export interface MemberBalance {
memberId: string
memberName: string
feeScheduleName: string | null
totalDueCents: number
totalPaidCents: number
outstandingCents: number
lastPaymentDate: string | null
status: "CURRENT" | "OVERDUE" | "NO_FEE"
}
export interface OutstandingMember {
memberId: string
memberName: string
memberNumber: string | null
outstandingCents: number
lastPaymentDate: string | null
monthsOverdue: number
}
// --- Request Types ---
export interface CreateFeeScheduleRequest {
name: string
amountCents: number
interval: FeeInterval
description?: string
isDefault?: boolean
}
export interface UpdateFeeScheduleRequest {
name?: string
amountCents?: number
interval?: FeeInterval
description?: string
isDefault?: boolean
}
export interface AssignFeeRequest {
feeScheduleId: string
validFrom: string
}
export interface RecordPaymentRequest {
memberId: string
amountCents: number
paymentMethod: PaymentMethod
periodFrom?: string
periodTo?: string
reference?: string
notes?: string
}
export interface VoidPaymentRequest {
reason: string
}
export interface RecordExpenseRequest {
category: ExpenseCategory
amountCents: number
description: string
reference?: string
transactionDate?: string
}
// --- Response Types ---
export interface PaginatedResponse<T> {
content: T[]
totalElements: number
totalPages: number
number: number
size: number
}
// --- Admin Query Hooks ---
export function useFeeSchedulesQuery() {
return useQuery({
queryKey: ["finance", "fee-schedules"],
queryFn: () => apiClient<FeeSchedule[]>("/finance/fee-schedules"),
})
}
export function usePaymentsQuery(options?: {
memberId?: string
status?: PaymentStatus
page?: number
size?: number
}) {
return useQuery({
queryKey: ["finance", "payments", options],
queryFn: () => {
const params = new URLSearchParams()
if (options?.memberId) params.set("memberId", options.memberId)
if (options?.status) params.set("status", options.status)
params.set("page", String(options?.page ?? 0))
params.set("size", String(options?.size ?? 20))
return apiClient<PaginatedResponse<Payment>>(
`/finance/payments?${params}`
)
},
})
}
export function useLedgerQuery(
from: string,
to: string,
options?: { page?: number; size?: number }
) {
return useQuery({
queryKey: ["finance", "ledger", from, to, options],
queryFn: () => {
const params = new URLSearchParams()
params.set("from", from)
params.set("to", to)
params.set("page", String(options?.page ?? 0))
params.set("size", String(options?.size ?? 50))
return apiClient<PaginatedResponse<LedgerEntry>>(
`/finance/ledger?${params}`
)
},
enabled: !!from && !!to,
})
}
export function useFinancialSummaryQuery(from: string, to: string) {
return useQuery({
queryKey: ["finance", "summary", from, to],
queryFn: () =>
apiClient<FinancialSummary>(`/finance/summary?from=${from}&to=${to}`),
enabled: !!from && !!to,
})
}
export function useOutstandingMembersQuery() {
return useQuery({
queryKey: ["finance", "outstanding"],
queryFn: () => apiClient<OutstandingMember[]>("/finance/outstanding"),
})
}
export function useMemberBalanceQuery(memberId: string | undefined) {
return useQuery({
queryKey: ["finance", "balance", memberId],
queryFn: () =>
apiClient<MemberBalance>(`/finance/members/${memberId}/balance`),
enabled: !!memberId,
})
}
// --- Portal Query Hooks ---
export function useMyPaymentsQuery(options?: { page?: number; size?: number }) {
return useQuery({
queryKey: ["portal", "finance", "payments", options],
queryFn: () => {
const params = new URLSearchParams()
params.set("page", String(options?.page ?? 0))
params.set("size", String(options?.size ?? 20))
return apiClient<PaginatedResponse<Payment>>(
`/portal/finance/payments?${params}`
)
},
})
}
export function useMyBalanceQuery() {
return useQuery({
queryKey: ["portal", "finance", "balance"],
queryFn: () => apiClient<MemberBalance>("/portal/finance/balance"),
})
}
// --- Mutation Hooks ---
export function useCreateFeeScheduleMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateFeeScheduleRequest) =>
apiClient<FeeSchedule>("/finance/fee-schedules", {
method: "POST",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finance", "fee-schedules"] })
},
})
}
export function useUpdateFeeScheduleMutation(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateFeeScheduleRequest) =>
apiClient<FeeSchedule>(`/finance/fee-schedules/${id}`, {
method: "PUT",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finance", "fee-schedules"] })
},
})
}
export function useDeactivateFeeScheduleMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient<void>(`/finance/fee-schedules/${id}/deactivate`, {
method: "POST",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finance", "fee-schedules"] })
},
})
}
export function useAssignFeeMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
memberId,
...data
}: AssignFeeRequest & { memberId: string }) =>
apiClient<MemberFeeAssignment>(
`/finance/members/${memberId}/assign-fee`,
{
method: "POST",
body: data,
}
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finance"] })
},
})
}
export function useRecordPaymentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: RecordPaymentRequest) =>
apiClient<Payment>("/finance/payments", {
method: "POST",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finance"] })
},
})
}
export function useVoidPaymentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
apiClient<Payment>(`/finance/payments/${id}/void`, {
method: "POST",
body: { reason } as VoidPaymentRequest,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finance"] })
},
})
}
export function useRecordExpenseMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: RecordExpenseRequest) =>
apiClient<LedgerEntry>("/finance/expenses", {
method: "POST",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finance"] })
},
})
}
// --- Utility: PDF Download ---
export function getReceiptDownloadUrl(paymentId: string): string {
return `/api/backend/finance/payments/${paymentId}/receipt`
}
export function getPortalReceiptDownloadUrl(paymentId: string): string {
return `/api/backend/portal/finance/payments/${paymentId}/receipt`
}
export function getAnnualReportDownloadUrl(year: number): string {
return `/api/backend/finance/reports/annual?year=${year}`
}
export function getKassenbuchCsvDownloadUrl(from: string, to: string): string {
return `/api/backend/finance/ledger/export?from=${from}&to=${to}`
}
// --- Utility: Amount formatting ---
export function formatAmountCents(cents: number): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(cents / 100)
}
export function formatAmountCentsShort(cents: number): string {
return `${(cents / 100).toFixed(2).replace(".", ",")}`
}