diff --git a/cannamanage-frontend/e2e/sprint10-system-test.spec.ts b/cannamanage-frontend/e2e/sprint10-system-test.spec.ts new file mode 100644 index 0000000..a55ead8 --- /dev/null +++ b/cannamanage-frontend/e2e/sprint10-system-test.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "@playwright/test" + +test("Sprint 10 — /finance/import redirects unauthenticated user to /login with callbackUrl", async ({ + page, +}) => { + await page.goto("http://localhost:3000/finance/import", { + waitUntil: "domcontentloaded", + }) + await page.waitForURL(/\/login/, { timeout: 15000 }) + const url = page.url() + console.log("Redirected to:", url) + expect(url).toContain("/login") + expect(url).toContain("callbackUrl") + expect(decodeURIComponent(url)).toContain("/finance/import") +}) diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index 67372b2..aa2e569 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -958,6 +958,108 @@ "noPayments": "Noch keine Zahlungen vorhanden" } }, + "bankImport": { + "title": "Kontoauszug-Import", + "subtitle": "Bankauszüge hochladen und automatisch Mitgliedsbeiträgen zuordnen", + "default": "Standard", + "steps": { + "upload": "Hochladen", + "map": "Spalten zuordnen", + "review": "Treffer prüfen", + "complete": "Abschließen" + }, + "upload": { + "title": "Kontoauszug hochladen", + "help": "Unterstützte Formate: MT940 (.sta, .mt940, .txt), CAMT.053 (.xml), CSV (.csv). Maximale Dateigröße: 10 MB.", + "dropzone": "Datei hierher ziehen oder klicken zum Auswählen", + "formats": "MT940 • CAMT.053 • CSV", + "useMapping": "Gespeicherte CSV-Vorlage verwenden", + "autoDetect": "Automatisch erkennen" + }, + "map": { + "title": "CSV-Spalten zuordnen", + "help": "Geben Sie an, welche Spalte welche Information enthält (0-basiert). Vorlage kann für künftige Imports gespeichert werden.", + "templateName": "Vorlagenname", + "templateNamePlaceholder": "z.B. Sparkasse Köln Export", + "dateColumn": "Datums-Spalte", + "amountColumn": "Betrags-Spalte", + "referenceColumn": "Verwendungszweck-Spalte", + "counterpartyColumn": "Empfänger-Spalte", + "delimiter": "Trennzeichen", + "dateFormat": "Datumsformat", + "saveAsDefault": "Als Standard-Vorlage speichern" + }, + "review": { + "title": "Treffer prüfen", + "transactions": "Transaktionen", + "resumed": "Fortgesetzt", + "progress": "Bearbeitungsfortschritt", + "sortByConfidence": "Sortieren: Konfidenz", + "sortByAmount": "Sortieren: Betrag", + "sortByDate": "Sortieren: Datum", + "autoMatchedReady": "automatische Treffer bereit zur Bestätigung", + "date": "Datum", + "counterparty": "Empfänger", + "reference": "Verwendungszweck", + "amount": "Betrag", + "matchStatus": "Status", + "actions": "Aktionen", + "searchMembers": "Mitglied suchen…", + "noMembersFound": "Keine Mitglieder gefunden", + "noTransactions": "Keine Transaktionen vorhanden", + "expenseSkipReason": "Ausgabe — kein Mitgliedsbeitrag" + }, + "complete": { + "title": "Import abschließen", + "help": "Nach Versiegeln können keine Änderungen mehr vorgenommen werden (GoBD §147 AO).", + "totalTransactions": "Gesamt", + "confirmed": "Bestätigt", + "skipped": "Übersprungen", + "remaining": "Verbleibend", + "warning": "Verbleibende Transaktionen werden automatisch als übersprungen markiert.", + "sealedMessage": "Sitzung wurde versiegelt. Buchungen sind unveränderlich." + }, + "history": { + "title": "Import-Historie", + "date": "Datum", + "file": "Datei", + "format": "Format", + "status": "Status", + "transactions": "Bestätigt", + "actions": "Aktionen" + }, + "status": { + "MATCHED": "Übereinstimmung", + "SUGGESTED": "Vorschlag", + "UNMATCHED": "Kein Treffer", + "CONFIRMED": "Bestätigt", + "SKIPPED": "Übersprungen" + }, + "sessionStatus": { + "PENDING": "Ausstehend", + "IN_REVIEW": "In Prüfung", + "COMPLETED": "Abgeschlossen", + "FAILED": "Fehlgeschlagen", + "CANCELLED": "Abgebrochen" + }, + "actions": { + "remove": "Entfernen", + "uploadAndParse": "Hochladen & analysieren", + "saveAndContinue": "Speichern & weiter", + "skip": "Überspringen", + "skipExpense": "Als Ausgabe überspringen", + "confirm": "Bestätigen", + "confirmAll": "Alle bestätigen", + "assignMember": "Mitglied zuweisen", + "proceedToComplete": "Weiter zum Abschluss", + "sealSession": "Sitzung versiegeln", + "newImport": "Neuer Import", + "resume": "Fortsetzen" + }, + "errors": { + "uploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen." + } + }, "documents": { "title": "Dokumentenarchiv", "description": "Vereinsdokumente verwalten und archivieren", diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index 8d4f077..33808c2 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -958,6 +958,117 @@ "noPayments": "No payments yet" } }, + "bankImport": { + "title": "Bank Statement Import", + "subtitle": "Import bank statements, match transactions to members, and confirm fee payments", + "steps": { + "upload": "Upload", + "map": "Map Columns", + "review": "Review", + "complete": "Complete" + }, + "upload": { + "title": "Upload bank statement", + "description": "Supported formats: MT940 (.sta, .mt940, .txt), CAMT.053 (.xml), CSV (.csv)", + "dropzone": "Drag a file here or click to select", + "selectFile": "Select file", + "format": "Format", + "mapping": "CSV Template (optional)", + "noMapping": "Auto-detect", + "upload": "Upload", + "uploading": "Uploading…" + }, + "map": { + "title": "Map CSV columns", + "description": "Specify which columns contain which data", + "dateColumn": "Date column", + "amountColumn": "Amount column", + "referenceColumn": "Reference column", + "counterpartyColumn": "Counterparty column", + "delimiter": "Delimiter", + "dateFormat": "Date format", + "saveAsTemplate": "Save as default template", + "templateName": "Template name", + "continue": "Continue", + "saving": "Saving…" + }, + "review": { + "title": "Review transactions", + "description": "Confirm matches or assign members manually", + "matched": "Matched", + "suggested": "Suggested", + "unmatched": "Unmatched", + "skipped": "Skipped", + "total": "Total", + "progress": "Review progress", + "confirmAll": "Confirm all matched", + "confirmingAll": "Confirming…", + "sortBy": "Sort by", + "sortConfidence": "Confidence", + "sortAmount": "Amount", + "sortDate": "Date", + "date": "Date", + "amount": "Amount", + "counterparty": "Counterparty", + "reference": "Reference", + "match": "Match", + "actions": "Actions", + "noTransactions": "No transactions found", + "resume": "Resume" + }, + "complete": { + "title": "Complete session", + "description": "Seal the session — sealed sessions are immutable (GoBD §147 AO)", + "summary": "Summary", + "confirmed": "Confirmed", + "skipped": "Skipped", + "open": "Open", + "seal": "Seal session", + "sealing": "Sealing…", + "sealed": "Session sealed", + "sealedDescription": "This session is now immutable and audit-proof.", + "back": "Back to Finance" + }, + "history": { + "title": "Import history", + "description": "Previous import sessions", + "noSessions": "No imports yet", + "filename": "Filename", + "uploaded": "Uploaded", + "status": "Status", + "resume": "Resume" + }, + "status": { + "MATCHED": "Matched", + "SUGGESTED": "Suggested", + "UNMATCHED": "Unmatched", + "CONFIRMED": "Confirmed", + "SKIPPED": "Skipped" + }, + "sessionStatus": { + "PENDING": "Pending", + "IN_REVIEW": "In Review", + "COMPLETED": "Completed", + "FAILED": "Failed", + "CANCELLED": "Cancelled" + }, + "actions": { + "confirm": "Confirm", + "assign": "Assign", + "skip": "Skip", + "searchMember": "Search member…", + "noMembersFound": "No members found", + "cancel": "Cancel" + }, + "errors": { + "uploadFailed": "Upload failed", + "noFile": "Please select a file", + "confirmFailed": "Confirm failed", + "assignFailed": "Assign failed", + "skipFailed": "Skip failed", + "completeFailed": "Complete failed" + } + }, "documents": { "title": "Document Archive", "description": "Manage and archive club documents", diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/finance/import/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/finance/import/page.tsx new file mode 100644 index 0000000..c1ff441 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/finance/import/page.tsx @@ -0,0 +1,1069 @@ +"use client" + +import { useMemo, useRef, useState } from "react" +import { + formatBankAmountCents, + getMatchStatusColor, + useAssignMember, + useCompleteSession, + useConfirmAll, + useConfirmTransaction, + useCreateMapping, + useCsvMappings, + useImportSession, + useImportSessions, + useSkipTransaction, + useTransactions, + useUploadStatement, +} from "@/services/bank-import" +import { useMembersQuery } from "@/services/members" +import { useTranslations } from "next-intl" +import { + AlertCircle, + ArrowLeft, + ArrowRight, + CheckCircle2, + FileText, + Loader2, + Upload, + XCircle, +} from "lucide-react" + +import type { + BankImportSession, + BankTransaction, + MatchStatus, +} from "@/services/bank-import" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Progress } from "@/components/ui/progress" +import { Select } from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +type WizardStep = "upload" | "map" | "review" | "complete" + +export default function BankImportPage() { + const t = useTranslations("bankImport") + const [step, setStep] = useState("upload") + const [sessionId, setSessionId] = useState(null) + const [resumeSessionId, setResumeSessionId] = useState(null) + + const { data: sessions } = useImportSessions() + + const handleResume = (id: string) => { + setSessionId(id) + setResumeSessionId(id) + setStep("review") + } + + const handleReset = () => { + setStep("upload") + setSessionId(null) + setResumeSessionId(null) + } + + return ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ + + + + + {step === "upload" && ( + { + setSessionId(id) + setStep(requiresMapping ? "map" : "review") + }} + /> + )} + {step === "map" && sessionId && ( + setStep("review")} /> + )} + {step === "review" && sessionId && ( + setStep("complete")} + resumed={!!resumeSessionId} + /> + )} + {step === "complete" && sessionId && ( + + )} + + + + +
+ ) +} + +// === Step Indicator === + +function StepIndicator({ current }: { current: WizardStep }) { + const t = useTranslations("bankImport") + const steps: { id: WizardStep; label: string }[] = [ + { id: "upload", label: t("steps.upload") }, + { id: "map", label: t("steps.map") }, + { id: "review", label: t("steps.review") }, + { id: "complete", label: t("steps.complete") }, + ] + const currentIndex = steps.findIndex((s) => s.id === current) + + return ( +
+ {steps.map((s, idx) => { + const isActive = idx === currentIndex + const isDone = idx < currentIndex + return ( +
+
+ {isDone ? : idx + 1} +
+ + {s.label} + + {idx < steps.length - 1 && ( +
+ )} +
+ ) + })} +
+ ) +} + +// === Upload Step === + +function UploadStep({ + onUploaded, +}: { + onUploaded: (sessionId: string, requiresMapping: boolean) => void +}) { + const t = useTranslations("bankImport") + const fileInputRef = useRef(null) + const [file, setFile] = useState(null) + const [mappingId, setMappingId] = useState("") + const [dragActive, setDragActive] = useState(false) + const [error, setError] = useState(null) + + const { data: mappings } = useCsvMappings() + const upload = useUploadStatement() + + const detectedFormat = useMemo(() => { + if (!file) return null + const name = file.name.toLowerCase() + if (name.endsWith(".csv")) return "CSV" + if (name.endsWith(".xml")) return "CAMT053" + if ( + name.endsWith(".sta") || + name.endsWith(".mt940") || + name.endsWith(".txt") + ) + return "MT940" + return null + }, [file]) + + const isCsv = detectedFormat === "CSV" + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragActive(false) + const dropped = e.dataTransfer.files?.[0] + if (dropped) setFile(dropped) + } + + const handleUpload = async () => { + if (!file) return + setError(null) + try { + const session = await upload.mutateAsync({ + file, + mappingId: isCsv && mappingId ? mappingId : undefined, + }) + // If CSV without mapping and parser failed, we'd need MapStep — for now + // backend auto-detects defaults. Skip MapStep when mappingId provided or non-CSV. + const requiresMapping = isCsv && !mappingId && session.status === "FAILED" + onUploaded(session.id, requiresMapping) + } catch (err) { + setError(err instanceof Error ? err.message : t("errors.uploadFailed")) + } + } + + return ( +
+
+

{t("upload.title")}

+

{t("upload.help")}

+
+ +
{ + e.preventDefault() + setDragActive(true) + }} + onDragLeave={() => setDragActive(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + className={`flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-12 transition-colors ${ + dragActive + ? "border-primary bg-primary/5" + : "border-muted-foreground/25 hover:border-muted-foreground/50" + }`} + > + +
+

{t("upload.dropzone")}

+

{t("upload.formats")}

+
+ { + const f = e.target.files?.[0] + if (f) setFile(f) + }} + /> +
+ + {file && ( +
+
+ +
+

{file.name}

+

+ {(file.size / 1024).toFixed(1)} KB + {detectedFormat && ( + <> + {" • "} + + {detectedFormat} + + + )} +

+
+
+ +
+ )} + + {isCsv && mappings && mappings.length > 0 && ( +
+ + +
+ )} + + {error && ( +
+ + {error} +
+ )} + +
+ +
+
+ ) +} + +// === Map Step (CSV only, currently advisory) === + +function MapStep({ + sessionId, + onNext, +}: { + sessionId: string + onNext: () => void +}) { + const t = useTranslations("bankImport") + const { data: session } = useImportSession(sessionId) + const createMapping = useCreateMapping() + + const [form, setForm] = useState({ + name: "", + dateColumn: 0, + amountColumn: 1, + referenceColumn: 2, + counterpartyColumn: 3, + delimiter: ";", + dateFormat: "dd.MM.yyyy", + decimalSeparator: ",", + skipHeaderRows: 1, + isDefault: false, + }) + + const handleSave = async () => { + if (!form.name) return + await createMapping.mutateAsync(form) + onNext() + } + + return ( +
+
+

{t("map.title")}

+

{t("map.help")}

+
+ + {session?.errorMessage && ( +
+ + {session.errorMessage} +
+ )} + +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder={t("map.templateNamePlaceholder")} + /> +
+
+ + + setForm({ ...form, dateColumn: Number(e.target.value) }) + } + /> +
+
+ + + setForm({ ...form, amountColumn: Number(e.target.value) }) + } + /> +
+
+ + + setForm({ ...form, referenceColumn: Number(e.target.value) }) + } + /> +
+
+ + + setForm({ + ...form, + counterpartyColumn: Number(e.target.value), + }) + } + /> +
+
+ + setForm({ ...form, delimiter: e.target.value })} + maxLength={4} + /> +
+
+ + setForm({ ...form, dateFormat: e.target.value })} + /> +
+
+ + + +
+ + +
+
+ ) +} + +// === Review Step === + +function ReviewStep({ + sessionId, + onNext, + resumed, +}: { + sessionId: string + onNext: () => void + resumed: boolean +}) { + const t = useTranslations("bankImport") + const { data: session } = useImportSession(sessionId) + const { data: transactions } = useTransactions(sessionId) + const confirmAll = useConfirmAll(sessionId) + const [sortBy, setSortBy] = useState<"confidence" | "amount" | "date">( + "confidence" + ) + + const sorted = useMemo(() => { + if (!transactions) return [] + return [...transactions].sort((a, b) => { + if (sortBy === "confidence") { + return (b.matchConfidence ?? 0) - (a.matchConfidence ?? 0) + } + if (sortBy === "amount") { + return Math.abs(b.amountCents) - Math.abs(a.amountCents) + } + return a.bookingDate.localeCompare(b.bookingDate) + }) + }, [transactions, sortBy]) + + const counts = useMemo(() => { + if (!transactions) + return { matched: 0, suggested: 0, unmatched: 0, skipped: 0, total: 0 } + return transactions.reduce( + (acc, t) => { + acc.total++ + if (t.matchStatus === "MATCHED" || t.matchStatus === "CONFIRMED") + acc.matched++ + else if (t.matchStatus === "SUGGESTED") acc.suggested++ + else if (t.matchStatus === "UNMATCHED") acc.unmatched++ + else if (t.matchStatus === "SKIPPED") acc.skipped++ + return acc + }, + { matched: 0, suggested: 0, unmatched: 0, skipped: 0, total: 0 } + ) + }, [transactions]) + + const reviewProgress = + counts.total > 0 + ? Math.round(((counts.matched + counts.skipped) / counts.total) * 100) + : 0 + + return ( +
+
+
+

{t("review.title")}

+

+ {session?.filename} — {counts.total} {t("review.transactions")} + {resumed && ( + + {t("review.resumed")} + + )} +

+
+ +
+ +
+ + + + +
+ +
+
+ + {t("review.progress")}: {reviewProgress}% + +
+ +
+ + {counts.matched > 0 && ( +
+ + {counts.matched} {t("review.autoMatchedReady")} + + +
+ )} + + + +
+
{/* no back from here — backend persists */} + +
+
+ ) +} + +function SummaryCard({ + label, + value, + color, +}: { + label: string + value: number + color: string +}) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function TransactionsTable({ + transactions, + sessionId, +}: { + transactions: BankTransaction[] + sessionId: string +}) { + const t = useTranslations("bankImport") + + if (transactions.length === 0) { + return ( +
+ {t("review.noTransactions")} +
+ ) + } + + return ( +
+ + + + {t("review.date")} + {t("review.counterparty")} + {t("review.reference")} + {t("review.amount")} + + {t("review.matchStatus")} + + {t("review.actions")} + + + + {transactions.map((tx) => ( + + ))} + +
+
+ ) +} + +function TransactionRow({ + tx, + sessionId, +}: { + tx: BankTransaction + sessionId: string +}) { + const t = useTranslations("bankImport") + const colors = getMatchStatusColor(tx.matchStatus) + const confirmMutation = useConfirmTransaction(sessionId) + const assignMutation = useAssignMember(sessionId) + const skipMutation = useSkipTransaction(sessionId) + + const isExpense = tx.amountCents < 0 + const isActionable = + tx.matchStatus !== "SKIPPED" && tx.matchStatus !== "CONFIRMED" + + return ( + + {tx.bookingDate} + + {tx.counterpartyName ?? "—"} + {tx.counterpartyIban && ( +
+ {tx.counterpartyIban} +
+ )} +
+ + {tx.referenceText ?? "—"} + + + {formatBankAmountCents(tx.amountCents, tx.currency ?? "EUR")} + + + + + + {isActionable && !isExpense && ( +
+ {(tx.matchStatus === "MATCHED" || tx.matchStatus === "SUGGESTED") && + tx.matchedMemberId && ( + + )} + {(tx.matchStatus === "SUGGESTED" || + tx.matchStatus === "UNMATCHED") && ( + + assignMutation.mutate({ txnId: tx.id, memberId }) + } + disabled={assignMutation.isPending} + /> + )} + +
+ )} + {isExpense && isActionable && ( + + )} +
+
+ ) +} + +function MatchStatusBadge({ + status, + confidence, +}: { + status: MatchStatus + confidence: number | null +}) { + const t = useTranslations("bankImport") + const variant: Record< + MatchStatus, + "default" | "secondary" | "outline" | "destructive" + > = { + MATCHED: "default", + CONFIRMED: "default", + SUGGESTED: "secondary", + UNMATCHED: "destructive", + SKIPPED: "outline", + } + return ( +
+ + {t(`status.${status}`)} + + {confidence !== null && ( + {confidence}% + )} +
+ ) +} + +function MemberAssignPopover({ + onAssign, + disabled, +}: { + onAssign: (memberId: string) => void + disabled?: boolean +}) { + const t = useTranslations("bankImport") + const [open, setOpen] = useState(false) + const [search, setSearch] = useState("") + const { data: members } = useMembersQuery({ + search: search || undefined, + size: 20, + }) + + return ( +
+ + {open && ( +
+ + + + {t("review.noMembersFound")} + + {members?.content?.map((m) => ( + { + onAssign(m.id) + setOpen(false) + setSearch("") + }} + > + + {m.firstName} {m.lastName} + + + {m.memberNumber} + + + ))} + + + +
+ )} +
+ ) +} + +// === Complete Step === + +function CompleteStep({ + sessionId, + onDone, +}: { + sessionId: string + onDone: () => void +}) { + const t = useTranslations("bankImport") + const { data: session } = useImportSession(sessionId) + const complete = useCompleteSession() + + const handleComplete = async () => { + await complete.mutateAsync(sessionId) + } + + const isCompleted = session?.status === "COMPLETED" + + return ( +
+
+

{t("complete.title")}

+

{t("complete.help")}

+
+ + {session && ( +
+ + + + +
+ )} + + {isCompleted ? ( +
+ + {t("complete.sealedMessage")} +
+ ) : ( +
+ + {t("complete.warning")} +
+ )} + +
+ {!isCompleted ? ( + + ) : ( + + )} +
+
+ ) +} + +// === Import History === + +function ImportHistoryCard({ + sessions, + onResume, +}: { + sessions: BankImportSession[] + onResume: (sessionId: string) => void +}) { + const t = useTranslations("bankImport") + + if (sessions.length === 0) { + return null + } + + return ( + + + {t("history.title")} + + +
+ + + + {t("history.date")} + {t("history.file")} + {t("history.format")} + {t("history.status")} + + {t("history.transactions")} + + + {t("history.actions")} + + + + + {sessions.map((s) => { + const isResumable = + s.status === "PENDING" || s.status === "IN_REVIEW" + return ( + + + {new Date(s.createdAt).toLocaleString("de-DE")} + + + {s.filename} + + + {s.format} + + + + + + {s.confirmedCount ?? 0}/{s.totalTransactions ?? 0} + + + {isResumable && ( + + )} + + + ) + })} + +
+
+
+
+ ) +} + +function SessionStatusBadge({ + status, +}: { + status: BankImportSession["status"] +}) { + const t = useTranslations("bankImport") + const variant: Record< + BankImportSession["status"], + "default" | "secondary" | "outline" | "destructive" + > = { + PENDING: "secondary", + IN_REVIEW: "secondary", + COMPLETED: "default", + FAILED: "destructive", + CANCELLED: "outline", + } + return {t(`sessionStatus.${status}`)} +} diff --git a/cannamanage-frontend/src/data/navigations.ts b/cannamanage-frontend/src/data/navigations.ts index 7d399c6..58ec1a9 100644 --- a/cannamanage-frontend/src/data/navigations.ts +++ b/cannamanage-frontend/src/data/navigations.ts @@ -56,8 +56,17 @@ export const navigationsData: NavigationType[] = [ items: [ { title: "Finanzen", - href: "/finance", iconName: "Wallet", + items: [ + { + title: "Übersicht", + href: "/finance", + }, + { + title: "Import", + href: "/finance/import", + }, + ], }, { title: "Versammlungen", diff --git a/cannamanage-frontend/src/services/bank-import.ts b/cannamanage-frontend/src/services/bank-import.ts new file mode 100644 index 0000000..ec6647b --- /dev/null +++ b/cannamanage-frontend/src/services/bank-import.ts @@ -0,0 +1,358 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { getSession } from "next-auth/react" + +import { apiClient } from "@/lib/api-client" + +// --- Types --- + +export type BankFormat = "MT940" | "CAMT053" | "CSV" +export type ImportSessionStatus = + | "PENDING" + | "IN_REVIEW" + | "COMPLETED" + | "FAILED" + | "CANCELLED" +export type MatchStatus = + | "MATCHED" + | "SUGGESTED" + | "UNMATCHED" + | "CONFIRMED" + | "SKIPPED" + +export interface BankImportSession { + id: string + clubId: string + filename: string + format: BankFormat + status: ImportSessionStatus + totalTransactions: number | null + matchedCount: number | null + confirmedCount: number | null + skippedCount: number | null + uploadedBy: string + errorMessage: string | null + createdAt: string + completedAt: string | null +} + +export interface BankTransaction { + id: string + sessionId: string + bookingDate: string + valueDate: string | null + amountCents: number + currency: string | null + referenceText: string | null + counterpartyName: string | null + counterpartyIban: string | null + bankReference: string | null + matchStatus: MatchStatus + matchConfidence: number | null + matchedMemberId: string | null + matchedPaymentId: string | null + skipReason: string | null +} + +export interface CsvColumnMapping { + id: string + clubId: string + name: string + dateColumn: number + amountColumn: number + referenceColumn: number + counterpartyColumn: number | null + ibanColumn: number | null + delimiter: string + dateFormat: string + decimalSeparator: string + skipHeaderRows: number + encoding: string + isDefault: boolean + createdAt: string +} + +export interface CreateMappingRequest { + name: string + dateColumn: number + amountColumn: number + referenceColumn: number + counterpartyColumn?: number | null + ibanColumn?: number | null + delimiter?: string + dateFormat?: string + decimalSeparator?: string + skipHeaderRows?: number + encoding?: string + isDefault?: boolean +} + +export interface BulkConfirmResponse { + confirmed: number + skipped: number + failed: number + total: number +} + +export interface ConfirmRequest { + memberId: string +} + +export interface AssignRequest { + memberId: string +} + +export interface SkipRequest { + reason?: string +} + +// --- Query Hooks --- + +export function useImportSessions() { + return useQuery({ + queryKey: ["bank-import", "sessions"], + queryFn: () => apiClient("/finance/import/sessions"), + }) +} + +export function useImportSession(id: string | undefined) { + return useQuery({ + queryKey: ["bank-import", "sessions", id], + queryFn: () => + apiClient(`/finance/import/sessions/${id}`), + enabled: !!id, + }) +} + +export function useTransactions( + sessionId: string | undefined, + status?: MatchStatus +) { + return useQuery({ + queryKey: ["bank-import", "transactions", sessionId, status], + queryFn: () => + apiClient( + `/finance/import/sessions/${sessionId}/transactions`, + { params: { status } } + ), + enabled: !!sessionId, + }) +} + +export function useCsvMappings() { + return useQuery({ + queryKey: ["bank-import", "csv-mappings"], + queryFn: () => + apiClient("/finance/import/csv-mappings"), + }) +} + +// --- Mutation Hooks --- + +/** + * Upload a bank statement file (multipart/form-data). + * Uses raw fetch + FormData — apiClient is JSON-only. + */ +export function useUploadStatement() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (input: { file: File; mappingId?: string }) => { + const session = await getSession() + const token = (session as { accessToken?: string })?.accessToken + const formData = new FormData() + formData.append("file", input.file) + + let url = "/api/backend/finance/import/sessions" + if (input.mappingId) { + url += `?mappingId=${encodeURIComponent(input.mappingId)}` + } + + const res = await fetch(url, { + method: "POST", + body: formData, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({ + code: `HTTP_${res.status}`, + message: res.statusText || "Upload failed", + })) + throw new Error(errorBody.message || "Upload failed") + } + return (await res.json()) as BankImportSession + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bank-import", "sessions"] }) + }, + }) +} + +export function useConfirmTransaction(sessionId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ txnId, memberId }: { txnId: string; memberId: string }) => + apiClient( + `/finance/import/sessions/${sessionId}/transactions/${txnId}/confirm`, + { method: "POST", body: { memberId } satisfies ConfirmRequest } + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["bank-import", "transactions", sessionId], + }) + queryClient.invalidateQueries({ + queryKey: ["bank-import", "sessions", sessionId], + }) + }, + }) +} + +export function useConfirmAll(sessionId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => + apiClient( + `/finance/import/sessions/${sessionId}/confirm-all`, + { method: "POST" } + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["bank-import", "transactions", sessionId], + }) + queryClient.invalidateQueries({ + queryKey: ["bank-import", "sessions", sessionId], + }) + }, + }) +} + +export function useAssignMember(sessionId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ txnId, memberId }: { txnId: string; memberId: string }) => + apiClient( + `/finance/import/sessions/${sessionId}/transactions/${txnId}/assign`, + { method: "POST", body: { memberId } satisfies AssignRequest } + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["bank-import", "transactions", sessionId], + }) + }, + }) +} + +export function useSkipTransaction(sessionId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ txnId, reason }: { txnId: string; reason?: string }) => + apiClient( + `/finance/import/sessions/${sessionId}/transactions/${txnId}/skip`, + { method: "POST", body: { reason } satisfies SkipRequest } + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["bank-import", "transactions", sessionId], + }) + queryClient.invalidateQueries({ + queryKey: ["bank-import", "sessions", sessionId], + }) + }, + }) +} + +export function useCompleteSession() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (sessionId: string) => + apiClient( + `/finance/import/sessions/${sessionId}/complete`, + { method: "POST" } + ), + onSuccess: (_, sessionId) => { + queryClient.invalidateQueries({ queryKey: ["bank-import", "sessions"] }) + queryClient.invalidateQueries({ + queryKey: ["bank-import", "sessions", sessionId], + }) + }, + }) +} + +export function useCreateMapping() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: CreateMappingRequest) => + apiClient("/finance/import/csv-mappings", { + method: "POST", + body: data, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["bank-import", "csv-mappings"], + }) + }, + }) +} + +export function useDeleteMapping() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiClient(`/finance/import/csv-mappings/${id}`, { + method: "DELETE", + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["bank-import", "csv-mappings"], + }) + }, + }) +} + +// --- Helpers --- + +export function formatBankAmountCents(cents: number, currency = "EUR"): string { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency, + }).format(cents / 100) +} + +export function getMatchStatusColor(status: MatchStatus): { + bg: string + text: string + border: string +} { + switch (status) { + case "MATCHED": + case "CONFIRMED": + return { + bg: "bg-green-50 dark:bg-green-950/30", + text: "text-green-700 dark:text-green-400", + border: "border-l-4 border-green-500", + } + case "SUGGESTED": + return { + bg: "bg-yellow-50 dark:bg-yellow-950/30", + text: "text-yellow-700 dark:text-yellow-400", + border: "border-l-4 border-yellow-500", + } + case "UNMATCHED": + return { + bg: "bg-red-50 dark:bg-red-950/30", + text: "text-red-700 dark:text-red-400", + border: "border-l-4 border-red-500", + } + case "SKIPPED": + return { + bg: "bg-gray-50 dark:bg-gray-900/30", + text: "text-gray-500 dark:text-gray-400", + border: "border-l-4 border-gray-400", + } + default: + return { + bg: "", + text: "", + border: "", + } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java index 6867ef7..e1b058c 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java @@ -42,11 +42,13 @@ public interface PaymentRepository extends JpaRepository { "WHERE p.clubId = :clubId AND p.memberId = :memberId AND p.status = 'PAID'") Long sumPaidByMember(@Param("clubId") UUID clubId, @Param("memberId") UUID memberId); + // Overdue = PENDING payment whose billing period has already ended (periodTo in the past). + // Payment entity has no explicit dueDate column; periodTo serves as the implicit due date. @Query("SELECT COUNT(p) FROM Payment p WHERE p.clubId = :clubId " + - "AND p.status = 'PENDING' AND p.dueDate < :now") + "AND p.status = 'PENDING' AND p.periodTo < :now") long countOverdueByClubId(@Param("clubId") UUID clubId, @Param("now") LocalDate now); @Query("SELECT COUNT(p) FROM Payment p WHERE p.clubId = :clubId " + - "AND p.status = 'PENDING' AND p.dueDate < :cutoff") + "AND p.status = 'PENDING' AND p.periodTo < :cutoff") long countOverdueByClubIdAndDaysPast(@Param("clubId") UUID clubId, @Param("cutoff") LocalDate cutoff); }