feat(sprint10): Phase 4+5 — Frontend import wizard + integration testing
Phase 4 — Frontend Import Wizard:
- bank-import.ts service: types (BankImportSession, BankTransaction,
CsvColumnMapping, ImportSessionStatus, MatchStatus) + 12 React Query hooks
(sessions, transactions, mappings, upload/confirm/skip/assign/complete)
- /finance/import page: 4-step wizard (Upload -> Map -> Review -> Confirm)
* Drag-and-drop upload with bank format auto-detect (MT940/CAMT.053/CSV)
* CSV column mapping editor (saves as reusable mapping)
* Review table with color-coded MATCHED/SUGGESTED/UNMATCHED/CONFIRMED rows,
confidence % badges, member-assign Combobox, skip/confirm/bulk-confirm
* Completion summary + import history table with resume action
- de.json + en.json: full bankImport.* namespace (steps, upload, map, review,
complete, history, status, sessionStatus, actions, errors)
- Navigation: Finanzen converted to nested submenu (Uebersicht + Import)
Phase 5 — Integration Testing:
- docker compose down -v + up -d --build (clean rebuild)
- Playwright e2e/sprint10-system-test.spec.ts: verifies /finance/import
unauthenticated -> /login?callbackUrl=%2Ffinance%2Fimport (PASS)
- Backend health + frontend route registration verified
Bugfix bundled (blocked backend startup):
- PaymentRepository: countOverdueByClubId* queries referenced non-existent
Payment.dueDate column (regression from Sprint 9 Phase 6, commit 57f418f).
Switched to Payment.periodTo (the implicit due date for billing periods).
This commit is contained in:
@@ -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")
|
||||||
|
})
|
||||||
@@ -958,6 +958,108 @@
|
|||||||
"noPayments": "Noch keine Zahlungen vorhanden"
|
"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": {
|
"documents": {
|
||||||
"title": "Dokumentenarchiv",
|
"title": "Dokumentenarchiv",
|
||||||
"description": "Vereinsdokumente verwalten und archivieren",
|
"description": "Vereinsdokumente verwalten und archivieren",
|
||||||
|
|||||||
@@ -958,6 +958,117 @@
|
|||||||
"noPayments": "No payments yet"
|
"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": {
|
"documents": {
|
||||||
"title": "Document Archive",
|
"title": "Document Archive",
|
||||||
"description": "Manage and archive club documents",
|
"description": "Manage and archive club documents",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,8 +56,17 @@ export const navigationsData: NavigationType[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Finanzen",
|
title: "Finanzen",
|
||||||
href: "/finance",
|
|
||||||
iconName: "Wallet",
|
iconName: "Wallet",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Übersicht",
|
||||||
|
href: "/finance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Import",
|
||||||
|
href: "/finance/import",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Versammlungen",
|
title: "Versammlungen",
|
||||||
|
|||||||
@@ -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<BankImportSession[]>("/finance/import/sessions"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportSession(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["bank-import", "sessions", id],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<BankImportSession>(`/finance/import/sessions/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactions(
|
||||||
|
sessionId: string | undefined,
|
||||||
|
status?: MatchStatus
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["bank-import", "transactions", sessionId, status],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<BankTransaction[]>(
|
||||||
|
`/finance/import/sessions/${sessionId}/transactions`,
|
||||||
|
{ params: { status } }
|
||||||
|
),
|
||||||
|
enabled: !!sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCsvMappings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["bank-import", "csv-mappings"],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<CsvColumnMapping[]>("/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<BankTransaction>(
|
||||||
|
`/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<BulkConfirmResponse>(
|
||||||
|
`/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<BankTransaction>(
|
||||||
|
`/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<BankTransaction>(
|
||||||
|
`/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<BankImportSession>(
|
||||||
|
`/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<CsvColumnMapping>("/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<void>(`/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: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -42,11 +42,13 @@ public interface PaymentRepository extends JpaRepository<Payment, UUID> {
|
|||||||
"WHERE p.clubId = :clubId AND p.memberId = :memberId AND p.status = 'PAID'")
|
"WHERE p.clubId = :clubId AND p.memberId = :memberId AND p.status = 'PAID'")
|
||||||
Long sumPaidByMember(@Param("clubId") UUID clubId, @Param("memberId") UUID memberId);
|
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 " +
|
@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);
|
long countOverdueByClubId(@Param("clubId") UUID clubId, @Param("now") LocalDate now);
|
||||||
|
|
||||||
@Query("SELECT COUNT(p) FROM Payment p WHERE p.clubId = :clubId " +
|
@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);
|
long countOverdueByClubIdAndDaysPast(@Param("clubId") UUID clubId, @Param("cutoff") LocalDate cutoff);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user