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:
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user