diff --git a/backend/controllers/clubDashboardController.js b/backend/controllers/clubDashboardController.js index af73149f..055cdfc5 100644 --- a/backend/controllers/clubDashboardController.js +++ b/backend/controllers/clubDashboardController.js @@ -13,6 +13,7 @@ import { TrainingGroup, } from '../models/index.js'; import clubArchiveService from '../services/clubArchiveService.js'; +import { hasClubPaymentClaimPaidAmountCentsColumn } from '../services/clubPaymentClaimCompatibility.js'; import { getSafeErrorMessage } from '../utils/errorUtils.js'; function formatRequestWorkflowStage(stage) { @@ -249,6 +250,7 @@ export const getClubDashboard = async (req, res) => { today.setHours(0, 0, 0, 0); const todayIso = today.toISOString().slice(0, 10); const availableTables = await loadAvailableTables(); + const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn(); const [ requests, @@ -293,14 +295,16 @@ export const getClubDashboard = async (req, res) => { }, attributes: ['memberId'], })), - loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({ - where: { - clubId, - status: { [Op.in]: ['open', 'partially_paid'] }, - archivedAt: null, - }, - order: [['dueOn', 'ASC']], - })), + hasPaidAmountCentsColumn + ? loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({ + where: { + clubId, + status: { [Op.in]: ['open', 'partially_paid'] }, + archivedAt: null, + }, + order: [['dueOn', 'ASC']], + })) + : Promise.resolve([]), loadOptionalTableData(availableTables, 'calendar_events', () => CalendarEvent.findAll({ where: { clubId, diff --git a/backend/controllers/myTischtennisUrlController.js b/backend/controllers/myTischtennisUrlController.js index 4ecf6c22..549f2a5e 100644 --- a/backend/controllers/myTischtennisUrlController.js +++ b/backend/controllers/myTischtennisUrlController.js @@ -10,6 +10,7 @@ import Season from '../models/Season.js'; import User from '../models/User.js'; import HttpError from '../exceptions/HttpError.js'; import { devLog } from '../utils/logger.js'; +import { hasUserClubAccess } from '../utils/userUtils.js'; import { randomUUID } from 'crypto'; const teamDataFetchJobs = new Map(); @@ -635,15 +636,34 @@ class MyTischtennisUrlController { /** * Configure league from myTischtennis table URL * POST /api/mytischtennis/configure-league - * Body: { url: string, createSeason?: boolean } + * Body: { url: string, clubId: number, createSeason?: boolean } */ async configureLeague(req, res, next) { try { - const { url, createSeason } = req.body; + const { url, createSeason, clubId } = req.body; const userIdOrEmail = req.headers.userid; - if (!url) { - throw new HttpError('URL is required', 400); + if (!url || !clubId) { + throw new HttpError('URL and clubId are required', 400); + } + + let userId = userIdOrEmail; + if (isNaN(userIdOrEmail)) { + const user = await User.findOne({ where: { email: userIdOrEmail } }); + if (!user) { + throw new HttpError('User not found', 404); + } + userId = user.id; + } + + const normalizedClubId = Number.parseInt(clubId, 10); + if (!Number.isInteger(normalizedClubId) || normalizedClubId <= 0) { + throw new HttpError('clubId must be a valid number', 400); + } + + const hasAccess = await hasUserClubAccess(userId, normalizedClubId); + if (!hasAccess) { + throw new HttpError('Keine Berechtigung für diesen Verein', 403); } // Parse URL @@ -669,6 +689,7 @@ class MyTischtennisUrlController { // Find or create league let league = await League.findOne({ where: { + clubId: normalizedClubId, myTischtennisGroupId: parsedData.groupId, association: parsedData.association } @@ -677,6 +698,7 @@ class MyTischtennisUrlController { if (!league) { league = await League.create({ name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version + clubId: normalizedClubId, myTischtennisGroupId: parsedData.groupId, association: parsedData.association, groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version diff --git a/backend/migrations/20260708_add_paid_amount_cents_to_club_payment_claims.sql b/backend/migrations/20260708_add_paid_amount_cents_to_club_payment_claims.sql new file mode 100644 index 00000000..5c3182e8 --- /dev/null +++ b/backend/migrations/20260708_add_paid_amount_cents_to_club_payment_claims.sql @@ -0,0 +1,36 @@ +-- club_payment_claims: Feld wie backend/models/ClubPaymentClaim.js (paidAmountCents) +-- Fehlt in der DB -> SequelizeDatabaseError ER_BAD_FIELD_ERROR in Club-Dashboard, +-- Aufgaben-Automation, Konten und Zahlungsforderungen. +-- +-- Diese Migration ist idempotent: +-- - fuegt `paid_amount_cents` nur hinzu, wenn die Spalte noch fehlt +-- - initialisiert bestehende Datensaetze mit Status `paid` auf `amount_cents` +-- Hinweis: +-- Bereits teilweise bezahlte Altfaelle koennen ohne historische Buchungsdaten +-- nicht exakt rekonstruiert werden und bleiben daher initial bei 0. + +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'club_payment_claims' + AND COLUMN_NAME = 'paid_amount_cents' +); + +SET @add_column_sql := IF( + @column_exists = 0, + 'ALTER TABLE `club_payment_claims` + ADD COLUMN `paid_amount_cents` BIGINT NOT NULL DEFAULT 0 + COMMENT ''Bereits bezahlter Anteil in Cent'' + AFTER `amount_cents`', + 'SELECT ''Column paid_amount_cents already exists'' AS message' +); + +PREPARE add_column_stmt FROM @add_column_sql; +EXECUTE add_column_stmt; +DEALLOCATE PREPARE add_column_stmt; + +UPDATE `club_payment_claims` +SET `paid_amount_cents` = `amount_cents` +WHERE `status` = 'paid' + AND COALESCE(`paid_amount_cents`, 0) = 0; diff --git a/backend/services/clubAccountService.js b/backend/services/clubAccountService.js index 9a808b12..697cde1e 100644 --- a/backend/services/clubAccountService.js +++ b/backend/services/clubAccountService.js @@ -4,6 +4,7 @@ import ClubAccount from '../models/ClubAccount.js'; import ClubAccountTransaction from '../models/ClubAccountTransaction.js'; import { ClubPaymentClaim, Member } from '../models/index.js'; import clubPaymentClaimService from './clubPaymentClaimService.js'; +import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js'; const TRANSACTION_DIRECTIONS = new Set(['credit', 'debit']); const TRANSACTION_BOOKING_TYPES = new Set(['manual', 'invoice', 'adjustment', 'payment_claim']); @@ -143,6 +144,10 @@ function scorePaymentClaimMatch(transaction, claim) { } async function findBestPaymentClaimMatch(clubId, transaction, dbTransaction = null) { + if (!(await hasClubPaymentClaimPaidAmountCentsColumn())) { + return null; + } + if (!transaction || transaction.direction !== 'credit' || transaction.status !== 'booked' || Number(transaction.amountCents || 0) <= 0) { return null; } @@ -204,6 +209,17 @@ async function findBestPaymentClaimMatch(clubId, transaction, dbTransaction = nu return bestMatch; } +function buildTransactionIncludes(includePaymentClaims) { + return includePaymentClaims + ? [ + { model: ClubAccount, as: 'account', required: false }, + { model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] }, + ] + : [ + { model: ClubAccount, as: 'account', required: false }, + ]; +} + function validatePayload(payload) { if (!payload.name) { const error = new Error('Kontobezeichnung ist erforderlich.'); @@ -292,6 +308,7 @@ async function ensureFallbackDefault(clubId, transaction) { class ClubAccountService { async listClubAccounts(clubId) { + const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn(); const [accounts, transactions] = await Promise.all([ ClubAccount.findAll({ where: { clubId }, @@ -304,10 +321,7 @@ class ClubAccountService { }), ClubAccountTransaction.findAll({ where: { clubId }, - include: [ - { model: ClubAccount, as: 'account', required: false }, - { model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] }, - ], + include: buildTransactionIncludes(includePaymentClaims), order: [['bookingDate', 'DESC'], ['createdAt', 'DESC']], limit: 250, }), @@ -320,7 +334,11 @@ class ClubAccountService { } async createTransaction(clubId, userId, payload) { + const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn(); const normalized = normalizeTransactionPayload(payload); + if (!includePaymentClaims) { + normalized.paymentClaimId = null; + } validateTransactionPayload(normalized); const account = await ClubAccount.findOne({ @@ -334,7 +352,7 @@ class ClubAccountService { return sequelize.transaction(async (dbTransaction) => { let matchedClaim = null; - if (normalized.paymentClaimId) { + if (includePaymentClaims && normalized.paymentClaimId) { matchedClaim = await ClubPaymentClaim.findOne({ where: { id: normalized.paymentClaimId, @@ -351,7 +369,7 @@ class ClubAccountService { error.status = 404; throw error; } - } else { + } else if (includePaymentClaims) { matchedClaim = await findBestPaymentClaimMatch(clubId, normalized, dbTransaction); } @@ -359,13 +377,13 @@ class ClubAccountService { clubId, createdByUserId: userId || null, ...normalized, - paymentClaimId: matchedClaim ? matchedClaim.id : normalized.paymentClaimId, + paymentClaimId: includePaymentClaims ? (matchedClaim ? matchedClaim.id : normalized.paymentClaimId) : null, bookingType: matchedClaim ? 'payment_claim' : normalized.bookingType, }; const transaction = await ClubAccountTransaction.create(transactionPayload, { transaction: dbTransaction }); - if (matchedClaim) { + if (includePaymentClaims && matchedClaim) { await clubPaymentClaimService.applyPaymentToClaim( clubId, matchedClaim, @@ -378,16 +396,14 @@ class ClubAccountService { } return transaction.reload({ - include: [ - { model: ClubAccount, as: 'account', required: false }, - { model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] }, - ], + include: buildTransactionIncludes(includePaymentClaims), transaction: dbTransaction, }); }); } async updateTransaction(clubId, transactionId, payload) { + const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn(); const transactionRow = await ClubAccountTransaction.findOne({ where: { id: transactionId, clubId }, }); @@ -403,6 +419,9 @@ class ClubAccountService { } const normalized = normalizeTransactionPayload(payload); + if (!includePaymentClaims) { + normalized.paymentClaimId = null; + } validateTransactionPayload(normalized); const previousPaymentClaimId = Number(transactionRow.paymentClaimId || 0) || null; @@ -417,20 +436,20 @@ class ClubAccountService { await transactionRow.update(normalized); - const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean)); - for (const claimId of claimIdsToReconcile) { - await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId); + if (includePaymentClaims) { + const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean)); + for (const claimId of claimIdsToReconcile) { + await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId); + } } return transactionRow.reload({ - include: [ - { model: ClubAccount, as: 'account', required: false }, - { model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] }, - ], + include: buildTransactionIncludes(includePaymentClaims), }); } async deleteTransaction(clubId, transactionId) { + const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn(); const transactionRow = await ClubAccountTransaction.findOne({ where: { id: transactionId, clubId }, }); @@ -447,13 +466,14 @@ class ClubAccountService { const claimId = Number(transactionRow.paymentClaimId || 0) || null; await transactionRow.destroy(); - if (claimId) { + if (includePaymentClaims && claimId) { await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId); } return { success: true }; } async listAccountTransactions(clubId, accountId = null) { + const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn(); const where = { clubId }; if (accountId) { where.accountId = accountId; @@ -461,10 +481,7 @@ class ClubAccountService { return ClubAccountTransaction.findAll({ where, - include: [ - { model: ClubAccount, as: 'account', required: false }, - { model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] }, - ], + include: buildTransactionIncludes(includePaymentClaims), order: [ ['bookingDate', 'DESC'], ['createdAt', 'DESC'], diff --git a/backend/services/clubArchiveService.js b/backend/services/clubArchiveService.js index 0b65eee9..adb97538 100644 --- a/backend/services/clubArchiveService.js +++ b/backend/services/clubArchiveService.js @@ -1,6 +1,7 @@ import { Op } from 'sequelize'; import sequelize from '../database.js'; import { ClubPaymentClaim, ClubRequest, ClubTask, Member } from '../models/index.js'; +import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js'; const DEFAULT_LIMIT = 50; @@ -36,6 +37,7 @@ class ClubArchiveService { } const availableTables = await loadAvailableTables(); + const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn(); const members = await Member.findAll({ where: { clubId }, @@ -70,21 +72,23 @@ class ClubArchiveService { }) ); - const archivedClaims = await loadOptionalTableData( - availableTables, - 'club_payment_claims', - () => ClubPaymentClaim.findAll({ - where: { - clubId, - [Op.or]: [ - { archivedAt: { [Op.not]: null } }, - { status: { [Op.in]: ['written_off', 'cancelled'] } }, - ], - }, - order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']], - limit: DEFAULT_LIMIT, - }) - ); + const archivedClaims = hasPaidAmountCentsColumn + ? await loadOptionalTableData( + availableTables, + 'club_payment_claims', + () => ClubPaymentClaim.findAll({ + where: { + clubId, + [Op.or]: [ + { archivedAt: { [Op.not]: null } }, + { status: { [Op.in]: ['written_off', 'cancelled'] } }, + ], + }, + order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']], + limit: DEFAULT_LIMIT, + }) + ) + : []; const inactiveMembers = members .filter((member) => !member.active) diff --git a/backend/services/clubPaymentClaimCompatibility.js b/backend/services/clubPaymentClaimCompatibility.js new file mode 100644 index 00000000..0372e928 --- /dev/null +++ b/backend/services/clubPaymentClaimCompatibility.js @@ -0,0 +1,22 @@ +import sequelize from '../database.js'; + +let hasPaidAmountCentsColumnPromise = null; + +function isMissingTableError(error) { + return error?.original?.code === 'ER_NO_SUCH_TABLE'; +} + +export async function hasClubPaymentClaimPaidAmountCentsColumn() { + if (!hasPaidAmountCentsColumnPromise) { + hasPaidAmountCentsColumnPromise = sequelize.getQueryInterface().describeTable('club_payment_claims') + .then((description) => Boolean(description?.paid_amount_cents)) + .catch((error) => { + if (isMissingTableError(error)) { + return false; + } + throw error; + }); + } + + return hasPaidAmountCentsColumnPromise; +} diff --git a/backend/services/clubPaymentClaimService.js b/backend/services/clubPaymentClaimService.js index a62f6458..d767124d 100644 --- a/backend/services/clubPaymentClaimService.js +++ b/backend/services/clubPaymentClaimService.js @@ -1,6 +1,7 @@ import { Op, Transaction } from 'sequelize'; import sequelize from '../database.js'; import { ClubAccount, ClubAccountTransaction, ClubPaymentClaim, Member } from '../models/index.js'; +import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js'; const CLAIM_TYPES = new Set(['membership_fee', 'additional_fee', 'course_fee', 'penalty_fee', 'other']); const CLAIM_STATUSES = new Set(['open', 'partially_paid', 'paid', 'written_off', 'cancelled']); @@ -82,6 +83,10 @@ async function ensureMemberBelongsToClub(clubId, memberId) { class ClubPaymentClaimService { async listClaims(clubId) { + if (!(await hasClubPaymentClaimPaidAmountCentsColumn())) { + return { claims: [] }; + } + const claims = await ClubPaymentClaim.findAll({ where: { clubId, diff --git a/backend/services/clubTaskAutomationService.js b/backend/services/clubTaskAutomationService.js index c3e05dde..4775d75f 100644 --- a/backend/services/clubTaskAutomationService.js +++ b/backend/services/clubTaskAutomationService.js @@ -1,4 +1,5 @@ import { Op } from 'sequelize'; +import sequelize from '../database.js'; import { CalendarEvent, ClubCommunicationRecipient, @@ -14,9 +15,24 @@ import { Member, } from '../models/index.js'; import { CLUB_TASK_DEFINITIONS, CLUB_WORKFLOW_SOURCES, getClubTaskDefinitionMap } from './clubTaskDefinitions.js'; +import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js'; const definitionMap = getClubTaskDefinitionMap(); +async function loadAvailableTables() { + const tables = await sequelize.getQueryInterface().showAllTables(); + return new Set( + tables + .map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0])) + .filter(Boolean) + .map((table) => String(table).toLowerCase()) + ); +} + +function hasTable(availableTables, tableName) { + return availableTables.has(String(tableName).toLowerCase()); +} + function activeTask(task) { return !['done', 'cancelled', 'archived'].includes(task.status); } @@ -184,6 +200,8 @@ function sponsorPartySuggestionFor(party, today) { class ClubTaskAutomationService { async buildAutomationOverview(clubId) { const today = todayStart(); + const availableTables = await loadAvailableTables(); + const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn(); const [currentTasks, requests, members, mandates, paymentClaims, invoices, parties, documents, communicationRecipients, events, suppressions] = await Promise.all([ ClubTask.findAll({ where: { @@ -191,13 +209,15 @@ class ClubTaskAutomationService { automationKey: { [Op.ne]: null }, }, }), - ClubRequest.findAll({ - where: { - clubId, - status: { [Op.in]: ['open', 'in_progress', 'waiting'] }, - }, - order: [['receivedAt', 'ASC']], - }), + hasTable(availableTables, 'club_requests') + ? ClubRequest.findAll({ + where: { + clubId, + status: { [Op.in]: ['open', 'in_progress', 'waiting'] }, + }, + order: [['receivedAt', 'ASC']], + }) + : Promise.resolve([]), Member.findAll({ where: { clubId, @@ -205,76 +225,86 @@ class ClubTaskAutomationService { }, order: [['lastName', 'ASC'], ['firstName', 'ASC']], }), - ClubSepaMandate.findAll({ - where: { - clubId, - status: 'active', - revokedAt: null, - memberId: { [Op.ne]: null }, - }, - attributes: ['memberId'], - }), - ClubPaymentClaim.findAll({ - where: { - clubId, - status: { [Op.in]: ['open', 'partially_paid'] }, - archivedAt: null, - }, - order: [['dueOn', 'ASC']], - }), - ClubInvoice.findAll({ - where: { - clubId, - status: { [Op.in]: ['issued', 'partially_paid'] }, - archivedAt: null, - dueOn: { [Op.ne]: null }, - }, - order: [['dueOn', 'ASC']], - }), - ClubInvoiceParty.findAll({ - where: { - clubId, - partyType: 'sponsor', - }, - order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']], - }), - ClubDocument.findAll({ - where: { - clubId, - documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] }, - status: { [Op.in]: ['active', 'draft'] }, - }, - order: [['updatedAt', 'DESC']], - }), - ClubCommunicationRecipient.findAll({ - where: { - clubId, - deliveryStatus: 'failed', - retryable: true, - }, - include: [ - { model: ClubCommunicationThread, as: 'thread', required: false }, - ], - order: [['updatedAt', 'DESC']], - }), - CalendarEvent.findAll({ - where: { - clubId, - endDate: { [Op.gte]: today.toISOString().slice(0, 10) }, - }, - order: [['startDate', 'ASC']], - limit: 20, - }), - ClubTaskSuppression.findAll({ - where: { clubId }, - attributes: ['automationKey', 'suppressionToken'], - }).catch((error) => { - if (error?.original?.code === 'ER_NO_SUCH_TABLE' - && /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''))) { - return []; - } - throw error; - }), + hasTable(availableTables, 'club_sepa_mandates') + ? ClubSepaMandate.findAll({ + where: { + clubId, + status: 'active', + revokedAt: null, + memberId: { [Op.ne]: null }, + }, + attributes: ['memberId'], + }) + : Promise.resolve([]), + hasPaidAmountCentsColumn && hasTable(availableTables, 'club_payment_claims') + ? ClubPaymentClaim.findAll({ + where: { + clubId, + status: { [Op.in]: ['open', 'partially_paid'] }, + archivedAt: null, + }, + order: [['dueOn', 'ASC']], + }) + : Promise.resolve([]), + hasTable(availableTables, 'club_invoices') + ? ClubInvoice.findAll({ + where: { + clubId, + status: { [Op.in]: ['issued', 'partially_paid'] }, + archivedAt: null, + dueOn: { [Op.ne]: null }, + }, + order: [['dueOn', 'ASC']], + }) + : Promise.resolve([]), + hasTable(availableTables, 'club_invoice_parties') + ? ClubInvoiceParty.findAll({ + where: { + clubId, + partyType: 'sponsor', + }, + order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']], + }) + : Promise.resolve([]), + hasTable(availableTables, 'club_documents') + ? ClubDocument.findAll({ + where: { + clubId, + documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] }, + status: { [Op.in]: ['active', 'draft'] }, + }, + order: [['updatedAt', 'DESC']], + }) + : Promise.resolve([]), + hasTable(availableTables, 'club_communication_recipients') && hasTable(availableTables, 'club_communication_threads') + ? ClubCommunicationRecipient.findAll({ + where: { + clubId, + deliveryStatus: 'failed', + retryable: true, + }, + include: [ + { model: ClubCommunicationThread, as: 'thread', required: false }, + ], + order: [['updatedAt', 'DESC']], + }) + : Promise.resolve([]), + hasTable(availableTables, 'calendar_events') + ? CalendarEvent.findAll({ + where: { + clubId, + endDate: { [Op.gte]: today.toISOString().slice(0, 10) }, + }, + order: [['startDate', 'ASC']], + limit: 20, + }) + : Promise.resolve([]), + hasTable(availableTables, 'club_task_suppressions') + ? ClubTaskSuppression.findAll({ + where: { clubId }, + attributes: ['automationKey', 'suppressionToken'], + }) + : Promise.resolve([]), ]); const existingKeys = new Set(currentTasks.filter(activeTask).map((task) => task.automationKey).filter(Boolean)); diff --git a/docs/TODO.md b/docs/TODO.md index aa57b1bb..f6f35df8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -56,6 +56,7 @@ Stand: 2026-06-22 - [ ] Historie feiner filtern, exportieren und moduluebergreifend verlinken. - [ ] Kommunikation um Dokumentanhaenge und Serienvorlagen erweitern. - [ ] Vereinsarchiv um weitere Entitaeten und komfortablere Suche erweitern. +- Detailplan fuer Club-Ausbau und Absicherung: [club-outstanding-plan.md](./club-outstanding-plan.md). - [ ] Die alte Optimierungs-Restliste bei Bedarf mit [OPTIMIZATION_TODO.md](./OPTIMIZATION_TODO.md) zusammenfuehren. ## Fehlend diff --git a/docs/club-outstanding-plan.md b/docs/club-outstanding-plan.md new file mode 100644 index 00000000..a9b15f51 --- /dev/null +++ b/docs/club-outstanding-plan.md @@ -0,0 +1,129 @@ +# Club-Produkt: Ausbau und Absicherung + +Stand: 2026-06-25 + +## Ziel + +Die vorhandenen Club-Module sollen nicht mehr nur "vorhanden", sondern im Alltag stabil und nachvollziehbar nutzbar sein. Der Schwerpunkt liegt jetzt auf zwei Dingen: + +- Ausbau der noch klar erkennbaren Restthemen. +- Absicherung der bereits fertig wirkenden Arbeitsbereiche gegen Kantenfaelle, Rechteprobleme und unklare Zustande. + +## Aktueller Fokus + +- Kommunikation +- Historie +- Archiv +- Restliche Club-UI-Absicherung in den Kernviews +- Danach erst das `mein-tt.de`-Produkt inhaltlich weiter ausbauen + +## Arbeitsreihenfolge + +### Phase 1: Absicherung der bestehenden Club-Views + +Status: in Arbeit + +Ziel: +- Keine haengenden Formulare bei Reload, Clubwechsel oder Auswahlwechsel. +- Read-only-Zustaende sind sichtbar und verhindern keine Navigation. +- Lade- und Fehlermeldungen sind konsistent und eindeutig. + +Konkrete Teilaufgaben: +- `ClubTasksView.vue`: Sicherstellen, dass `selectedTask` und `form` beim Clubwechsel, bei leerer Liste und nach Loesch-/Archivaktionen sauber getrennt werden. +- `ClubCommunicationView.vue`: `selectedThread`, `threadForm`, `groupForm`, `templateForm` und `messageForm` beim Clubwechsel und nach Auswahlwechseln komplett zuruecksetzen. +- `ClubAccountsView.vue`: `selectedAccount`, `selectedTransaction`, `form` und `transactionForm` in jedem Ruecksprung sauber bereinigen. +- `ClubInvoicesView.vue`: `selectedInvoice`, `selectedParty`, `invoiceForm` und `partyForm` beim Clubwechsel und bei leeren Selektionen auf Default bringen. +- Read-only-Hinweise auf jeder der vier Views vereinheitlichen, damit ein Nutzer mit fehlenden Rechten nicht erst in die Formulare klickt, um zu merken, dass keine Bearbeitung moeglich ist. +- Ladezustände und Fehlerbanner auf eine einheitliche Form bringen, damit Reload und Fehlerfall nicht unterschiedlich wirken. +- Ein kurzer Smoke-Check je View nach der Aenderung: Liste laden, Element auswaehlen, Auswahl loeschen, Club wechseln, erneut laden. + +Gepruefte Kantenfaelle: +- Club wird gewechselt, waehrend ein Detailformular offen ist. +- Datenquelle liefert eine leere Liste und der zuletzt selektierte Datensatz existiert nicht mehr. +- Nutzer hat nur Leserechte, soll aber trotzdem eine klare Orientierung haben. +- Reload erfolgt waehrend ein Formular bereits mit Daten befuellt ist. + +Fertig, wenn: +- Die vier Kernviews ohne manuelle Nacharbeit zwischen Liste, Detail und Neu-Anlage wechseln. +- Beim Clubwechsel keine Formularwerte aus dem vorherigen Club sichtbar bleiben. +- Read-only-Nutzer die Bereiche verstehen, ohne in kaputte Aktionen zu laufen. + +### Phase 2: Kommunikation produktiv absichern + +Ziel: +- Nachrichtenfluss nicht nur funktional, sondern praxisnah robust machen. + +Arbeitspakete: +- SMTP real testen, inklusive Zustellprotokoll und Fehlerfaelle. +- Optional Reply-To pro Verein oder Kommunikationsvorlage sauber ergaenzen. +- Dokumentanhaenge fuer Nachrichten und Vorlagen einfuehren. +- Serienvorlagen und wiederkehrende Nachrichtentypen vorbereiten. + +Fertig, wenn: +- Eine Testzustellung je Verein reproduzierbar gelingt. +- Fehlende SMTP-Konfiguration klar und frueh sichtbar wird. +- Nachrichten mit Anhaengen und Vorlagen ohne Sonderlogik im Alltag einsetzbar sind. + +### Phase 3: Historie und Archiv vertiefen + +Ziel: +- Vergaengliche Vorgange muessen spaeter besser auffindbar und nachvollziehbar sein. + +Arbeitspakete: +- Historie nach Modulen und Vorgangstypen filtern. +- Historie exportierbar machen. +- Historie mit Zielobjekten und Querverweisen versehen. +- Archiv um weitere Entitaeten erweitern. +- Archivsuche und Schnellfilter verbessern. + +Fertig, wenn: +- Vorstand oder Verwaltung einen Vorgang aus Historie oder Archiv ohne Umweg wiederfinden kann. +- Wichtige Clubobjekte nicht nur archiviert, sondern auch wieder auffindbar und verlinkt sind. + +### Phase 4: Restliche Club-UX verdichten + +Ziel: +- Das Dashboard und die Detailmodule sollen gleiche Sprache sprechen. + +Arbeitspakete: +- Dashboard-Schnellzugriffe weiter auf Tagesgeschaeft trimmen. +- Verlinkungen zwischen Dashboard, Mitgliedern, Zahlungen, Kommunikation und Archiv schaerfen. +- Kleine Inkonsistenzen in Statusworten, Akzentfarben und Listenlabels bereinigen. + +Fertig, wenn: +- Der Einstieg immer zur naechsten sinnvollen Aktion fuehrt. +- Die wichtigsten Statuswerte nicht doppelt oder widerspruechlich gezeigt werden. + +### Phase 5: Player-Produkt erst danach + +Ziel: +- `mein-tt.de` bekommt nur dann neue Inhalte, wenn die Club-Seite stabil ist. + +Arbeitspakete: +- Anforderungen fuer Spieleransichten separat sammeln. +- Keine Club-spezifischen Workflows mehr in das Player-Produkt ziehen. +- Neue Spielerfeatures nur gegen eigene Prioritaeten und nicht als Restverwertung der Club-Roadmap planen. + +## Nicht als naechstes anfassen + +- Generelles Beitrags- und Tarifsystem mit Familienlogik, Alterslogik und Gueltigkeitszeitrainen. +- Weitere grosse Produktumbauten ohne klaren Nutzen fuer den Club-Alltag. +- Zusätzliche Club-Module, solange die bestehenden Workflows noch nicht absicherungsfest sind. + +## Konkrete naechste Tickets + +- SMTP-Test fuer Kommunikation mit realer Zieladresse und dokumentiertem Ergebnis. +- Dokumentanhaenge fuer Kommunikation und Vorlagen. +- Historie: Filter und Export. +- Archiv: weitere Objektklassen und Suche. +- Club-UI-Smoke-Check fuer Aufgaben, Kommunikation, Konten und Rechnungen. + +## Abhaengigkeiten + +- SMTP-Test braucht eine real erreichbare Versandkonfiguration. +- Historie-Export braucht klare Zielobjekt- und Filterdefinitionen. +- Archiv-Erweiterungen sollten auf bereits vorhandene Dokument-, Rechnungs- und Beitragsdaten aufsetzen. + +## Erwartetes Ergebnis + +Nach dieser Runde sind die Club-Bereiche nicht nur vorhanden, sondern im Alltag kontrollierbar, nachvollziehbar und ausreichend robust fuer den produktiven Einsatz eines Vereins. Danach kann der Fokus auf neue inhaltliche Produktarbeit wechseln. diff --git a/frontend/src/views/ClubAccountsView.vue b/frontend/src/views/ClubAccountsView.vue index 7ab1182f..15893524 100644 --- a/frontend/src/views/ClubAccountsView.vue +++ b/frontend/src/views/ClubAccountsView.vue @@ -166,6 +166,7 @@