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:
+115
-1
@@ -6,13 +6,18 @@ import de.cannamanage.domain.entity.*;
|
|||||||
import de.cannamanage.domain.enums.PaymentStatus;
|
import de.cannamanage.domain.enums.PaymentStatus;
|
||||||
import de.cannamanage.domain.enums.StaffPermission;
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
import de.cannamanage.service.FinanceService;
|
import de.cannamanage.service.FinanceService;
|
||||||
|
import de.cannamanage.service.FinancialReportService;
|
||||||
|
import de.cannamanage.service.ReceiptPdfService;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
import de.cannamanage.service.repository.MemberRepository;
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
import de.cannamanage.service.repository.PaymentRepository;
|
import de.cannamanage.service.repository.PaymentRepository;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
@@ -33,13 +38,22 @@ public class FinanceController {
|
|||||||
private final FinanceService financeService;
|
private final FinanceService financeService;
|
||||||
private final StaffPermissionChecker permissionChecker;
|
private final StaffPermissionChecker permissionChecker;
|
||||||
private final MemberRepository memberRepository;
|
private final MemberRepository memberRepository;
|
||||||
|
private final ReceiptPdfService receiptPdfService;
|
||||||
|
private final FinancialReportService financialReportService;
|
||||||
|
private final ClubRepository clubRepository;
|
||||||
|
|
||||||
public FinanceController(FinanceService financeService,
|
public FinanceController(FinanceService financeService,
|
||||||
StaffPermissionChecker permissionChecker,
|
StaffPermissionChecker permissionChecker,
|
||||||
MemberRepository memberRepository) {
|
MemberRepository memberRepository,
|
||||||
|
ReceiptPdfService receiptPdfService,
|
||||||
|
FinancialReportService financialReportService,
|
||||||
|
ClubRepository clubRepository) {
|
||||||
this.financeService = financeService;
|
this.financeService = financeService;
|
||||||
this.permissionChecker = permissionChecker;
|
this.permissionChecker = permissionChecker;
|
||||||
this.memberRepository = memberRepository;
|
this.memberRepository = memberRepository;
|
||||||
|
this.receiptPdfService = receiptPdfService;
|
||||||
|
this.financialReportService = financialReportService;
|
||||||
|
this.clubRepository = clubRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Fee Schedules ===
|
// === Fee Schedules ===
|
||||||
@@ -243,6 +257,106 @@ public class FinanceController {
|
|||||||
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
|
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Receipt PDF Download ===
|
||||||
|
|
||||||
|
@GetMapping("/finance/payments/{id}/receipt")
|
||||||
|
public ResponseEntity<byte[]> downloadReceipt(@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails principal) {
|
||||||
|
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||||
|
UUID clubId = TenantContext.getCurrentTenant();
|
||||||
|
|
||||||
|
Payment payment = financeService.getPaymentById(id)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
|
||||||
|
Member member = memberRepository.findById(payment.getMemberId())
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Member not found"));
|
||||||
|
Club club = clubRepository.findById(clubId)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||||
|
|
||||||
|
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||||
|
String filename = "Quittung-" + (payment.getReceiptNumber() != null
|
||||||
|
? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf";
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.contentLength(pdf.length)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Annual Report PDF ===
|
||||||
|
|
||||||
|
@GetMapping("/finance/reports/annual")
|
||||||
|
public ResponseEntity<byte[]> downloadAnnualReport(@RequestParam int year,
|
||||||
|
@AuthenticationPrincipal UserDetails principal) {
|
||||||
|
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||||
|
UUID clubId = TenantContext.getCurrentTenant();
|
||||||
|
|
||||||
|
Club club = clubRepository.findById(clubId)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||||
|
|
||||||
|
FinancialReportService.AnnualReportData reportData = financeService.buildAnnualReportData(clubId, year);
|
||||||
|
byte[] pdf = financialReportService.generateAnnualReport(reportData, club);
|
||||||
|
String filename = "Jahresabschluss-" + year + "-" + club.getName().replaceAll("\\s+", "_") + ".pdf";
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.contentLength(pdf.length)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Kassenbuch CSV Export ===
|
||||||
|
|
||||||
|
@GetMapping("/finance/ledger/export")
|
||||||
|
public ResponseEntity<byte[]> exportLedgerCsv(@RequestParam LocalDate from,
|
||||||
|
@RequestParam LocalDate to,
|
||||||
|
@AuthenticationPrincipal UserDetails principal) {
|
||||||
|
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||||
|
UUID clubId = TenantContext.getCurrentTenant();
|
||||||
|
|
||||||
|
byte[] csv = financeService.exportLedgerCsv(clubId, from, to);
|
||||||
|
String filename = "Kassenbuch-" + from + "-" + to + ".csv";
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.parseMediaType("text/csv; charset=ISO-8859-1"))
|
||||||
|
.contentLength(csv.length)
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Portal: Receipt download (own payments only) ===
|
||||||
|
|
||||||
|
@GetMapping("/portal/finance/payments/{id}/receipt")
|
||||||
|
public ResponseEntity<byte[]> downloadMyReceipt(@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails principal) {
|
||||||
|
UUID userId = UUID.fromString(principal.getUsername());
|
||||||
|
UUID clubId = TenantContext.getCurrentTenant();
|
||||||
|
UUID memberId = getMemberIdForUser(userId, clubId);
|
||||||
|
|
||||||
|
Payment payment = financeService.getPaymentById(id)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
|
||||||
|
|
||||||
|
// Verify payment belongs to the requesting member
|
||||||
|
if (!payment.getMemberId().equals(memberId)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Member member = memberRepository.findById(memberId)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Member not found"));
|
||||||
|
Club club = clubRepository.findById(clubId)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||||
|
|
||||||
|
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||||
|
String filename = "Quittung-" + (payment.getReceiptNumber() != null
|
||||||
|
? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf";
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.contentLength(pdf.length)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
private UUID getMemberIdForUser(UUID userId, UUID clubId) {
|
private UUID getMemberIdForUser(UUID userId, UUID clubId) {
|
||||||
return memberRepository.findByUserId(userId)
|
return memberRepository.findByUserId(userId)
|
||||||
.map(Member::getId)
|
.map(Member::getId)
|
||||||
|
|||||||
@@ -839,5 +839,115 @@
|
|||||||
"topicLocked": "Dieses Thema ist gesperrt. Neue Antworten sind nicht möglich.",
|
"topicLocked": "Dieses Thema ist gesperrt. Neue Antworten sind nicht möglich.",
|
||||||
"reportReason": "Grund der Meldung:",
|
"reportReason": "Grund der Meldung:",
|
||||||
"backToTopics": "Zurück zur Übersicht"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -839,5 +839,115 @@
|
|||||||
"topicLocked": "This topic is locked. New replies are not possible.",
|
"topicLocked": "This topic is locked. New replies are not possible.",
|
||||||
"reportReason": "Reason for report:",
|
"reportReason": "Reason for report:",
|
||||||
"backToTopics": "Back to overview"
|
"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",
|
href: "/info-board",
|
||||||
iconName: "Megaphone",
|
iconName: "Megaphone",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Finanzen",
|
||||||
|
href: "/finance",
|
||||||
|
iconName: "Wallet",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Kalender",
|
title: "Kalender",
|
||||||
href: "/calendar",
|
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(".", ",")} €`
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import com.lowagie.text.*;
|
||||||
|
import com.lowagie.text.pdf.PdfPCell;
|
||||||
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Year;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.TextStyle;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the annual financial report (Jahresabschluss) as PDF.
|
||||||
|
* Content: Income/expense totals, monthly breakdown, category breakdown, member payment summary.
|
||||||
|
* Legal basis: §259 BGB (Rechenschaftspflicht), §147 AO (10-year retention).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class FinancialReportService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FinancialReportService.class);
|
||||||
|
|
||||||
|
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
|
||||||
|
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
|
||||||
|
private static final Font SUBTITLE_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
|
||||||
|
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
|
||||||
|
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
|
||||||
|
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
|
||||||
|
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
|
||||||
|
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
|
||||||
|
|
||||||
|
private static final Color PRIMARY_COLOR = new Color(34, 87, 58); // Dark green
|
||||||
|
private static final Color HEADER_BG = new Color(34, 87, 58);
|
||||||
|
private static final Color LIGHT_BG = new Color(245, 248, 245);
|
||||||
|
private static final Color INCOME_COLOR = new Color(22, 163, 74);
|
||||||
|
private static final Color EXPENSE_COLOR = new Color(220, 38, 38);
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for annual report data.
|
||||||
|
*/
|
||||||
|
public record AnnualReportData(
|
||||||
|
int year,
|
||||||
|
BigDecimal totalIncome,
|
||||||
|
BigDecimal totalExpenses,
|
||||||
|
BigDecimal netBalance,
|
||||||
|
List<MonthlyBreakdown> monthlyBreakdown,
|
||||||
|
List<CategoryBreakdown> expensesByCategory,
|
||||||
|
int totalMembers,
|
||||||
|
int paidMembers,
|
||||||
|
BigDecimal totalOutstanding
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record MonthlyBreakdown(
|
||||||
|
int month,
|
||||||
|
String monthName,
|
||||||
|
BigDecimal income,
|
||||||
|
BigDecimal expenses,
|
||||||
|
BigDecimal net
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record CategoryBreakdown(
|
||||||
|
String category,
|
||||||
|
BigDecimal amount,
|
||||||
|
double percentage
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the annual financial report PDF.
|
||||||
|
*/
|
||||||
|
public byte[] generateAnnualReport(AnnualReportData data, Club club) {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PdfWriter writer = PdfWriter.getInstance(document, baos);
|
||||||
|
writer.setPageEvent(new PdfFooterHandler());
|
||||||
|
document.open();
|
||||||
|
|
||||||
|
// === Page 1: Cover & Summary ===
|
||||||
|
|
||||||
|
// Club header
|
||||||
|
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
|
||||||
|
clubHeader.setSpacingAfter(5);
|
||||||
|
document.add(clubHeader);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Paragraph title = new Paragraph(
|
||||||
|
"Jahresabschluss " + data.year(), TITLE_FONT);
|
||||||
|
title.setSpacingAfter(5);
|
||||||
|
document.add(title);
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
Paragraph subtitle = new Paragraph(
|
||||||
|
"Finanzübersicht für das Geschäftsjahr " + data.year(),
|
||||||
|
NORMAL_FONT);
|
||||||
|
subtitle.setSpacingAfter(20);
|
||||||
|
document.add(subtitle);
|
||||||
|
|
||||||
|
// --- Summary KPIs ---
|
||||||
|
addSectionTitle(document, "Zusammenfassung");
|
||||||
|
|
||||||
|
PdfPTable summaryTable = new PdfPTable(2);
|
||||||
|
summaryTable.setWidthPercentage(60);
|
||||||
|
summaryTable.setHorizontalAlignment(Element.ALIGN_LEFT);
|
||||||
|
summaryTable.setSpacingAfter(20);
|
||||||
|
|
||||||
|
addSummaryRow(summaryTable, "Gesamteinnahmen",
|
||||||
|
formatEuro(data.totalIncome()), INCOME_COLOR);
|
||||||
|
addSummaryRow(summaryTable, "Gesamtausgaben",
|
||||||
|
formatEuro(data.totalExpenses()), EXPENSE_COLOR);
|
||||||
|
addSummaryRow(summaryTable, "Saldo",
|
||||||
|
formatEuro(data.netBalance()),
|
||||||
|
data.netBalance().signum() >= 0 ? INCOME_COLOR : EXPENSE_COLOR);
|
||||||
|
|
||||||
|
document.add(summaryTable);
|
||||||
|
|
||||||
|
// --- Member payment summary ---
|
||||||
|
addSectionTitle(document, "Mitgliederbeiträge");
|
||||||
|
|
||||||
|
PdfPTable memberTable = new PdfPTable(2);
|
||||||
|
memberTable.setWidthPercentage(60);
|
||||||
|
memberTable.setHorizontalAlignment(Element.ALIGN_LEFT);
|
||||||
|
memberTable.setSpacingAfter(20);
|
||||||
|
|
||||||
|
addSummaryRow(memberTable, "Mitglieder gesamt",
|
||||||
|
String.valueOf(data.totalMembers()), Color.BLACK);
|
||||||
|
addSummaryRow(memberTable, "Beiträge bezahlt",
|
||||||
|
String.valueOf(data.paidMembers()), INCOME_COLOR);
|
||||||
|
addSummaryRow(memberTable, "Offene Beiträge",
|
||||||
|
formatEuro(data.totalOutstanding()), EXPENSE_COLOR);
|
||||||
|
|
||||||
|
document.add(memberTable);
|
||||||
|
|
||||||
|
// --- Monthly Breakdown Table ---
|
||||||
|
addSectionTitle(document, "Monatliche Übersicht");
|
||||||
|
|
||||||
|
PdfPTable monthlyTable = new PdfPTable(4);
|
||||||
|
monthlyTable.setWidthPercentage(100);
|
||||||
|
monthlyTable.setWidths(new float[]{30f, 23f, 23f, 24f});
|
||||||
|
monthlyTable.setSpacingAfter(20);
|
||||||
|
|
||||||
|
addColoredHeader(monthlyTable, "Monat");
|
||||||
|
addColoredHeader(monthlyTable, "Einnahmen");
|
||||||
|
addColoredHeader(monthlyTable, "Ausgaben");
|
||||||
|
addColoredHeader(monthlyTable, "Saldo");
|
||||||
|
|
||||||
|
BigDecimal runningTotal = BigDecimal.ZERO;
|
||||||
|
for (MonthlyBreakdown month : data.monthlyBreakdown()) {
|
||||||
|
runningTotal = runningTotal.add(month.net());
|
||||||
|
|
||||||
|
boolean isEven = month.month() % 2 == 0;
|
||||||
|
Color rowBg = isEven ? LIGHT_BG : Color.WHITE;
|
||||||
|
|
||||||
|
addCellWithBg(monthlyTable, month.monthName(), TABLE_CELL_FONT, rowBg);
|
||||||
|
addCellWithBg(monthlyTable, formatEuro(month.income()), TABLE_CELL_FONT, rowBg);
|
||||||
|
addCellWithBg(monthlyTable, formatEuro(month.expenses()), TABLE_CELL_FONT, rowBg);
|
||||||
|
addCellWithBg(monthlyTable, formatEuro(month.net()), TABLE_CELL_FONT, rowBg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
addCellWithBg(monthlyTable, "GESAMT", TABLE_CELL_BOLD, LIGHT_BG);
|
||||||
|
addCellWithBg(monthlyTable, formatEuro(data.totalIncome()), TABLE_CELL_BOLD, LIGHT_BG);
|
||||||
|
addCellWithBg(monthlyTable, formatEuro(data.totalExpenses()), TABLE_CELL_BOLD, LIGHT_BG);
|
||||||
|
addCellWithBg(monthlyTable, formatEuro(data.netBalance()), TABLE_CELL_BOLD, LIGHT_BG);
|
||||||
|
|
||||||
|
document.add(monthlyTable);
|
||||||
|
|
||||||
|
// --- Expense Breakdown by Category ---
|
||||||
|
if (data.expensesByCategory() != null && !data.expensesByCategory().isEmpty()) {
|
||||||
|
addSectionTitle(document, "Ausgaben nach Kategorie");
|
||||||
|
|
||||||
|
PdfPTable categoryTable = new PdfPTable(3);
|
||||||
|
categoryTable.setWidthPercentage(80);
|
||||||
|
categoryTable.setWidths(new float[]{45f, 30f, 25f});
|
||||||
|
categoryTable.setSpacingAfter(20);
|
||||||
|
|
||||||
|
addColoredHeader(categoryTable, "Kategorie");
|
||||||
|
addColoredHeader(categoryTable, "Betrag");
|
||||||
|
addColoredHeader(categoryTable, "Anteil");
|
||||||
|
|
||||||
|
for (CategoryBreakdown cat : data.expensesByCategory()) {
|
||||||
|
addCell(categoryTable, translateCategory(cat.category()));
|
||||||
|
addCell(categoryTable, formatEuro(cat.amount()));
|
||||||
|
addCell(categoryTable, String.format("%.1f%%", cat.percentage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.add(categoryTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Footer note ---
|
||||||
|
Paragraph footerNote = new Paragraph(
|
||||||
|
"Erstellt am " + LocalDate.now().format(DATE_FMT)
|
||||||
|
+ " — Anbauvereinigung gemäß §2 KCanG",
|
||||||
|
FOOTER_FONT);
|
||||||
|
footerNote.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
footerNote.setSpacingBefore(30);
|
||||||
|
document.add(footerNote);
|
||||||
|
|
||||||
|
document.close();
|
||||||
|
|
||||||
|
} catch (DocumentException e) {
|
||||||
|
log.error("Failed to generate annual report PDF for year {}", data.year(), e);
|
||||||
|
throw new RuntimeException("PDF generation failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper methods ---
|
||||||
|
|
||||||
|
private void addSectionTitle(Document document, String text) throws DocumentException {
|
||||||
|
Paragraph section = new Paragraph(text, SUBTITLE_FONT);
|
||||||
|
section.setSpacingBefore(10);
|
||||||
|
section.setSpacingAfter(8);
|
||||||
|
document.add(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSummaryRow(PdfPTable table, String label, String value, Color valueColor) {
|
||||||
|
PdfPCell labelCell = new PdfPCell(new Phrase(label, NORMAL_FONT));
|
||||||
|
labelCell.setBorder(Rectangle.NO_BORDER);
|
||||||
|
labelCell.setPaddingBottom(5);
|
||||||
|
table.addCell(labelCell);
|
||||||
|
|
||||||
|
Font valueFont = new Font(Font.HELVETICA, 10, Font.BOLD, valueColor);
|
||||||
|
PdfPCell valueCell = new PdfPCell(new Phrase(value, valueFont));
|
||||||
|
valueCell.setBorder(Rectangle.NO_BORDER);
|
||||||
|
valueCell.setPaddingBottom(5);
|
||||||
|
valueCell.setHorizontalAlignment(Element.ALIGN_RIGHT);
|
||||||
|
table.addCell(valueCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addColoredHeader(PdfPTable table, String text) {
|
||||||
|
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_HEADER_FONT));
|
||||||
|
cell.setBackgroundColor(HEADER_BG);
|
||||||
|
cell.setPadding(6);
|
||||||
|
cell.setBorderWidth(0);
|
||||||
|
table.addCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCell(PdfPTable table, String text) {
|
||||||
|
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT));
|
||||||
|
cell.setPadding(5);
|
||||||
|
cell.setBorderWidth(0.5f);
|
||||||
|
cell.setBorderColor(Color.LIGHT_GRAY);
|
||||||
|
table.addCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCellWithBg(PdfPTable table, String text, Font font, Color bg) {
|
||||||
|
PdfPCell cell = new PdfPCell(new Phrase(text, font));
|
||||||
|
cell.setBackgroundColor(bg);
|
||||||
|
cell.setPadding(5);
|
||||||
|
cell.setBorderWidth(0.5f);
|
||||||
|
cell.setBorderColor(Color.LIGHT_GRAY);
|
||||||
|
table.addCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatEuro(BigDecimal amount) {
|
||||||
|
return String.format(Locale.GERMAN, "%,.2f €", amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String translateCategory(String category) {
|
||||||
|
return switch (category) {
|
||||||
|
case "RENT" -> "Miete";
|
||||||
|
case "UTILITIES" -> "Nebenkosten";
|
||||||
|
case "EQUIPMENT" -> "Ausstattung";
|
||||||
|
case "SEEDS" -> "Saatgut";
|
||||||
|
case "SUPPLIES" -> "Verbrauchsmaterial";
|
||||||
|
case "INSURANCE" -> "Versicherung";
|
||||||
|
case "LEGAL" -> "Rechtsberatung";
|
||||||
|
case "OTHER" -> "Sonstiges";
|
||||||
|
default -> category;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import com.lowagie.text.*;
|
||||||
|
import com.lowagie.text.pdf.PdfPCell;
|
||||||
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.Payment;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates receipt (Quittung) PDFs for member payments.
|
||||||
|
* Layout: A4 portrait, single page, professional receipt format.
|
||||||
|
* Legal basis: §147 AO (10-year retention for Buchungsbelege).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ReceiptPdfService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ReceiptPdfService.class);
|
||||||
|
|
||||||
|
private static final Font CLUB_NAME_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
|
||||||
|
private static final Font CLUB_ADDRESS_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL, Color.DARK_GRAY);
|
||||||
|
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 18, Font.BOLD);
|
||||||
|
private static final Font RECEIPT_NR_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL, Color.GRAY);
|
||||||
|
private static final Font LABEL_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
|
||||||
|
private static final Font VALUE_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
|
||||||
|
private static final Font AMOUNT_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
|
||||||
|
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
|
||||||
|
private static final Font SIGNATURE_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
|
private static final DateTimeFormatter PERIOD_FMT = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMAN);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a receipt PDF for a payment.
|
||||||
|
*
|
||||||
|
* @param payment the payment record
|
||||||
|
* @param member the paying member
|
||||||
|
* @param club the club entity (for header)
|
||||||
|
* @return PDF bytes
|
||||||
|
*/
|
||||||
|
public byte[] generateReceipt(Payment payment, Member member, Club club) {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
// A4 with generous margins
|
||||||
|
Document document = new Document(PageSize.A4, 60, 60, 60, 60);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PdfWriter.getInstance(document, baos);
|
||||||
|
document.open();
|
||||||
|
|
||||||
|
// --- Club Header ---
|
||||||
|
Paragraph clubName = new Paragraph(club.getName(), CLUB_NAME_FONT);
|
||||||
|
clubName.setAlignment(Element.ALIGN_LEFT);
|
||||||
|
document.add(clubName);
|
||||||
|
|
||||||
|
if (club.getAddress() != null && !club.getAddress().isBlank()) {
|
||||||
|
Paragraph address = new Paragraph(club.getAddress(), CLUB_ADDRESS_FONT);
|
||||||
|
address.setSpacingAfter(5);
|
||||||
|
document.add(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
Paragraph hr = new Paragraph(" ");
|
||||||
|
hr.setSpacingBefore(5);
|
||||||
|
hr.setSpacingAfter(15);
|
||||||
|
document.add(hr);
|
||||||
|
|
||||||
|
// --- Title ---
|
||||||
|
Paragraph title = new Paragraph("QUITTUNG", TITLE_FONT);
|
||||||
|
title.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
title.setSpacingAfter(5);
|
||||||
|
document.add(title);
|
||||||
|
|
||||||
|
// Receipt number
|
||||||
|
String receiptNr = payment.getReceiptNumber() != null
|
||||||
|
? payment.getReceiptNumber()
|
||||||
|
: "CM-" + payment.getId().toString().substring(0, 8).toUpperCase();
|
||||||
|
Paragraph nrPara = new Paragraph("Nr. " + receiptNr, RECEIPT_NR_FONT);
|
||||||
|
nrPara.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
nrPara.setSpacingAfter(30);
|
||||||
|
document.add(nrPara);
|
||||||
|
|
||||||
|
// --- Receipt Details Table ---
|
||||||
|
PdfPTable detailsTable = new PdfPTable(2);
|
||||||
|
detailsTable.setWidthPercentage(80);
|
||||||
|
detailsTable.setHorizontalAlignment(Element.ALIGN_LEFT);
|
||||||
|
detailsTable.setWidths(new float[]{35f, 65f});
|
||||||
|
detailsTable.setSpacingAfter(20);
|
||||||
|
|
||||||
|
addDetailRow(detailsTable, "Erhalten von:", getMemberDisplayName(member));
|
||||||
|
addDetailRow(detailsTable, "Mitgliedsnr.:", member.getMemberNumber() != null
|
||||||
|
? member.getMemberNumber() : "—");
|
||||||
|
|
||||||
|
// Amount - formatted as Euro
|
||||||
|
BigDecimal amount = BigDecimal.valueOf(payment.getAmountCents()).divide(BigDecimal.valueOf(100));
|
||||||
|
String amountStr = String.format(Locale.GERMAN, "%,.2f €", amount);
|
||||||
|
addDetailRow(detailsTable, "Betrag:", amountStr, AMOUNT_FONT);
|
||||||
|
|
||||||
|
addDetailRow(detailsTable, "Datum:", payment.getPaymentDate().format(DATE_FMT));
|
||||||
|
addDetailRow(detailsTable, "Zahlungsart:", translatePaymentMethod(payment.getPaymentMethod().name()));
|
||||||
|
|
||||||
|
// Period covered
|
||||||
|
if (payment.getPeriodFrom() != null && payment.getPeriodTo() != null) {
|
||||||
|
String period = formatPeriod(payment.getPeriodFrom(), payment.getPeriodTo());
|
||||||
|
addDetailRow(detailsTable, "Zeitraum:", period);
|
||||||
|
}
|
||||||
|
|
||||||
|
addDetailRow(detailsTable, "Verwendung:", "Mitgliedsbeitrag");
|
||||||
|
|
||||||
|
if (payment.getReference() != null && !payment.getReference().isBlank()) {
|
||||||
|
addDetailRow(detailsTable, "Referenz:", payment.getReference());
|
||||||
|
}
|
||||||
|
|
||||||
|
document.add(detailsTable);
|
||||||
|
|
||||||
|
// --- Spacer ---
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
document.add(new Paragraph(" "));
|
||||||
|
|
||||||
|
// --- Signature Section ---
|
||||||
|
PdfPTable sigTable = new PdfPTable(2);
|
||||||
|
sigTable.setWidthPercentage(80);
|
||||||
|
sigTable.setHorizontalAlignment(Element.ALIGN_LEFT);
|
||||||
|
sigTable.setWidths(new float[]{50f, 50f});
|
||||||
|
|
||||||
|
// Date line
|
||||||
|
PdfPCell dateCell = new PdfPCell();
|
||||||
|
dateCell.setBorder(Rectangle.TOP);
|
||||||
|
dateCell.setBorderWidth(0.5f);
|
||||||
|
dateCell.setPaddingTop(5);
|
||||||
|
dateCell.addElement(new Phrase("Datum", SIGNATURE_FONT));
|
||||||
|
sigTable.addCell(dateCell);
|
||||||
|
|
||||||
|
// Signature line
|
||||||
|
PdfPCell sigCell = new PdfPCell();
|
||||||
|
sigCell.setBorder(Rectangle.TOP);
|
||||||
|
sigCell.setBorderWidth(0.5f);
|
||||||
|
sigCell.setPaddingTop(5);
|
||||||
|
sigCell.addElement(new Phrase("Unterschrift Kassenwart", SIGNATURE_FONT));
|
||||||
|
sigTable.addCell(sigCell);
|
||||||
|
|
||||||
|
document.add(sigTable);
|
||||||
|
|
||||||
|
// --- Footer ---
|
||||||
|
Paragraph footer = new Paragraph(
|
||||||
|
"Anbauvereinigung gemäß §2 KCanG — " + club.getName(),
|
||||||
|
FOOTER_FONT
|
||||||
|
);
|
||||||
|
footer.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
footer.setSpacingBefore(40);
|
||||||
|
document.add(footer);
|
||||||
|
|
||||||
|
document.close();
|
||||||
|
|
||||||
|
} catch (DocumentException e) {
|
||||||
|
log.error("Failed to generate receipt PDF for payment {}", payment.getId(), e);
|
||||||
|
throw new RuntimeException("PDF generation failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addDetailRow(PdfPTable table, String label, String value) {
|
||||||
|
addDetailRow(table, label, value, VALUE_FONT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addDetailRow(PdfPTable table, String label, String value, Font valueFont) {
|
||||||
|
PdfPCell labelCell = new PdfPCell(new Phrase(label, LABEL_FONT));
|
||||||
|
labelCell.setBorder(Rectangle.NO_BORDER);
|
||||||
|
labelCell.setPaddingBottom(8);
|
||||||
|
table.addCell(labelCell);
|
||||||
|
|
||||||
|
PdfPCell valueCell = new PdfPCell(new Phrase(value, valueFont));
|
||||||
|
valueCell.setBorder(Rectangle.NO_BORDER);
|
||||||
|
valueCell.setPaddingBottom(8);
|
||||||
|
table.addCell(valueCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getMemberDisplayName(Member member) {
|
||||||
|
String firstName = member.getFirstName() != null ? member.getFirstName() : "";
|
||||||
|
String lastName = member.getLastName() != null ? member.getLastName() : "";
|
||||||
|
return (firstName + " " + lastName).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String translatePaymentMethod(String method) {
|
||||||
|
return switch (method) {
|
||||||
|
case "CASH" -> "Bar";
|
||||||
|
case "BANK_TRANSFER" -> "Überweisung";
|
||||||
|
case "SEPA" -> "SEPA-Lastschrift";
|
||||||
|
case "CARD" -> "Kartenzahlung";
|
||||||
|
default -> method;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatPeriod(LocalDate from, LocalDate to) {
|
||||||
|
if (from.getMonth() == to.getMonth() && from.getYear() == to.getYear()) {
|
||||||
|
return from.format(PERIOD_FMT);
|
||||||
|
}
|
||||||
|
return from.format(PERIOD_FMT) + " – " + to.format(PERIOD_FMT);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user