feat: Enhance club payment claims and task automation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s

- Added `paid_amount_cents` column to `club_payment_claims` for better tracking of payments.
- Implemented compatibility checks for the new column in `clubPaymentClaimService` and `clubTaskAutomationService`.
- Updated various views to handle read-only states when editing is not allowed.
- Refactored forms in `ClubAccountsView`, `ClubInvoicesView`, `ClubTasksView`, and `ClubCommunicationView` to use factory functions for cleaner code.
- Introduced a new migration script to add the `paid_amount_cents` column if it doesn't exist and initialize it for existing paid claims.
- Created a detailed plan for enhancing club features and ensuring stability in existing modules.
This commit is contained in:
Torsten Schulz (local)
2026-07-13 17:01:13 +02:00
parent 90e6c2f9f6
commit ac97332e6f
17 changed files with 628 additions and 307 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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;

View File

@@ -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'],

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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));

View File

@@ -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

View File

@@ -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.

View File

@@ -166,6 +166,7 @@
<div class="section-header">
<h3>{{ form.id ? 'Konto bearbeiten' : 'Neues Konto' }}</h3>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Konten können geprüft, aber nicht bearbeitet werden.</p>
<form class="account-form" @submit.prevent="submitAccount">
<label>
@@ -261,6 +262,7 @@
<div class="section-header">
<h3>{{ transactionForm.id ? 'Buchung bearbeiten' : 'Neue Buchung' }}</h3>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Kontobewegungen bleiben sichtbar, neue Buchungen sind gesperrt.</p>
<form class="account-form" @submit.prevent="submitTransaction">
<div class="account-form-grid">
@@ -453,6 +455,25 @@ function createEmptyTransactionForm(accountId = '') {
};
}
function createEmptyAccountForm() {
return {
id: null,
name: '',
accountHolder: '',
bankName: '',
iban: '',
bic: '',
accountType: 'bank',
usageType: 'general',
currencyCode: 'EUR',
allowSepaCollections: false,
allowOutgoingPayments: true,
isDefault: false,
status: 'active',
notes: '',
};
}
export default {
name: 'ClubAccountsView',
components: {
@@ -475,22 +496,7 @@ export default {
accountType: '',
search: '',
},
form: {
id: null,
name: '',
accountHolder: '',
bankName: '',
iban: '',
bic: '',
accountType: 'bank',
usageType: 'general',
currencyCode: 'EUR',
allowSepaCollections: false,
allowOutgoingPayments: true,
isDefault: false,
status: 'active',
notes: '',
},
form: createEmptyAccountForm(),
transactionForm: createEmptyTransactionForm(),
infoDialog: {
isOpen: false,
@@ -553,13 +559,7 @@ export default {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.accounts = [];
this.transactions = [];
this.selectedAccountId = null;
this.selectedTransactionId = null;
this.resetForm();
this.resetTransactionForm();
this.loadError = '';
this.clearAccountsState();
return;
}
await this.loadAccounts();
@@ -618,6 +618,16 @@ export default {
},
},
methods: {
clearAccountsState() {
this.accounts = [];
this.transactions = [];
this.paymentClaims = [];
this.selectedAccountId = null;
this.selectedTransactionId = null;
this.form = createEmptyAccountForm();
this.transactionForm = createEmptyTransactionForm();
this.loadError = '';
},
applyRouteQuery() {
const routeAccountId = this.$route?.query?.accountId;
const routeStatus = typeof this.$route?.query?.status === 'string' ? this.$route.query.status : '';
@@ -715,26 +725,12 @@ export default {
},
resetForm() {
this.selectedAccountId = null;
this.form = {
id: null,
name: '',
accountHolder: '',
bankName: '',
iban: '',
bic: '',
accountType: 'bank',
usageType: 'general',
currencyCode: 'EUR',
allowSepaCollections: false,
allowOutgoingPayments: true,
isDefault: false,
status: 'active',
notes: '',
};
this.form = createEmptyAccountForm();
this.resetTransactionForm('');
},
resetTransactionForm() {
resetTransactionForm(accountId = this.selectedAccountId || '') {
this.selectedTransactionId = null;
this.transactionForm = createEmptyTransactionForm(this.selectedAccountId || '');
this.transactionForm = createEmptyTransactionForm(accountId);
},
async loadPaymentClaims() {
if (!this.currentClub) {
@@ -760,13 +756,15 @@ export default {
await this.loadPaymentClaims();
this.applyRouteQuery();
if (this.selectedAccountId && !this.selectedAccount) {
this.selectedAccountId = null;
this.resetForm();
}
if (this.selectedTransactionId && !this.selectedTransaction) {
this.selectedTransactionId = null;
this.resetTransactionForm();
}
if (!this.selectedAccountId && this.accounts.length > 0) {
this.selectedAccountId = this.accounts[0].id;
} else if (this.accounts.length === 0) {
this.resetForm();
}
} catch (error) {
this.loadError = safeErrorMessage(error, 'Konten konnten nicht geladen werden.');
@@ -779,11 +777,15 @@ export default {
this.saving = true;
try {
const payload = { ...this.form };
let savedAccountId = this.form.id;
if (this.form.id) {
await apiClient.put(`/club-accounts/${this.currentClub}/${this.form.id}`, payload);
const response = await apiClient.put(`/club-accounts/${this.currentClub}/${this.form.id}`, payload);
savedAccountId = response.data?.account?.id || savedAccountId;
} else {
await apiClient.post(`/club-accounts/${this.currentClub}`, payload);
const response = await apiClient.post(`/club-accounts/${this.currentClub}`, payload);
savedAccountId = response.data?.account?.id || savedAccountId;
}
this.selectedAccountId = savedAccountId ? String(savedAccountId) : null;
await this.loadAccounts();
this.showInfo('Erfolg', 'Konto gespeichert.', '', 'success');
} catch (error) {
@@ -809,13 +811,16 @@ export default {
reference: this.transactionForm.reference,
notes: this.transactionForm.notes,
};
let savedTransactionId = this.transactionForm.id;
if (this.transactionForm.id) {
await apiClient.put(`/club-accounts/${this.currentClub}/transactions/${this.transactionForm.id}`, payload);
const response = await apiClient.put(`/club-accounts/${this.currentClub}/transactions/${this.transactionForm.id}`, payload);
savedTransactionId = response.data?.transaction?.id || savedTransactionId;
} else {
await apiClient.post(`/club-accounts/${this.currentClub}/transactions`, payload);
const response = await apiClient.post(`/club-accounts/${this.currentClub}/transactions`, payload);
savedTransactionId = response.data?.transaction?.id || savedTransactionId;
}
this.selectedTransactionId = savedTransactionId ? String(savedTransactionId) : null;
await this.loadAccounts();
this.resetTransactionForm();
} catch (error) {
this.showInfo('Fehler', safeErrorMessage(error, 'Kontobewegung konnte nicht gespeichert werden.'), '', 'error');
} finally {

View File

@@ -131,6 +131,7 @@
Neue Gruppe
</button>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Verteilergruppen bleiben sichtbar, Änderungen sind hier gesperrt.</p>
<div class="groups-chip-list">
<button
@@ -209,6 +210,7 @@
<div class="section-header">
<h3>{{ threadForm.id ? 'Vorgang bearbeiten' : 'Neuer Vorgang' }}</h3>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Vorgänge lassen sich prüfen, aber nicht ändern.</p>
<form class="thread-form" @submit.prevent="saveThread">
<div class="form-grid">
@@ -297,6 +299,7 @@
<div class="section-header">
<h3>Nachrichten</h3>
</div>
<p v-if="!canEdit && selectedThread" class="state-banner">Lesemodus aktiv. Verlauf und Versandstatus bleiben sichtbar, neue Einträge sind gesperrt.</p>
<p v-if="!selectedThread" class="state-banner">Wähle links einen Kommunikationsvorgang aus.</p>
<template v-else>
@@ -405,6 +408,7 @@
<div class="section-header">
<h3>Vorlagen & Antworten</h3>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Vorlagen können eingesehen, aber nicht bearbeitet werden.</p>
<div class="groups-chip-list">
<button
@@ -749,19 +753,7 @@ export default {
immediate: true,
async handler(clubId) {
if (!clubId) {
this.threads = [];
this.groups = [];
this.members = [];
this.templates = [];
this.selectedThreadId = null;
this.selectedGroupId = null;
this.selectedTemplateId = null;
this.threadForm = createEmptyThreadForm();
this.groupForm = createEmptyGroupForm();
this.templateForm = createEmptyTemplateForm();
this.messageForm = createEmptyMessageForm();
this.messageTemplateId = '';
this.loadError = '';
this.clearCommunicationState();
return;
}
await this.loadCommunication();
@@ -810,6 +802,21 @@ export default {
},
},
methods: {
clearCommunicationState() {
this.threads = [];
this.groups = [];
this.members = [];
this.templates = [];
this.selectedThreadId = null;
this.selectedGroupId = null;
this.selectedTemplateId = null;
this.threadForm = createEmptyThreadForm();
this.groupForm = createEmptyGroupForm();
this.templateForm = createEmptyTemplateForm();
this.messageForm = createEmptyMessageForm();
this.messageTemplateId = '';
this.loadError = '';
},
showInfo(title, message, details = '', type = 'info') {
this.infoDialog = buildInfoConfig({ title, message, details, type });
},
@@ -938,6 +945,14 @@ export default {
}
if (!this.selectedThreadId && this.threads.length > 0) {
this.selectedThreadId = this.threads[0].id;
} else if (this.threads.length === 0) {
this.resetThreadForm();
}
if (this.groups.length === 0) {
this.resetGroupForm();
}
if (this.templates.length === 0) {
this.resetTemplateForm();
}
} catch (error) {
this.loadError = safeErrorMessage(error, 'Kommunikation konnte nicht geladen werden.');
@@ -1056,11 +1071,15 @@ export default {
scheduledAt: this.threadForm.scheduledAt || null,
recipientFilters: this.threadForm.threadType === 'direct' ? {} : this.threadForm.recipientFilters,
};
let savedThreadId = this.threadForm.id;
if (this.threadForm.id) {
await apiClient.put(`/club-communication/${this.currentClub}/threads/${this.threadForm.id}`, payload);
const response = await apiClient.put(`/club-communication/${this.currentClub}/threads/${this.threadForm.id}`, payload);
savedThreadId = response.data?.thread?.id || savedThreadId;
} else {
await apiClient.post(`/club-communication/${this.currentClub}/threads`, payload);
const response = await apiClient.post(`/club-communication/${this.currentClub}/threads`, payload);
savedThreadId = response.data?.thread?.id || savedThreadId;
}
this.selectedThreadId = savedThreadId ? Number(savedThreadId) : null;
await this.loadCommunication();
this.showInfo('Erfolg', 'Kommunikationsvorgang gespeichert.', '', 'success');
} catch (error) {
@@ -1119,11 +1138,15 @@ export default {
...this.groupForm,
filterDefinition: this.groupForm.filterDefinition,
};
let savedGroupId = this.groupForm.id;
if (this.groupForm.id) {
await apiClient.put(`/club-communication/${this.currentClub}/groups/${this.groupForm.id}`, payload);
const response = await apiClient.put(`/club-communication/${this.currentClub}/groups/${this.groupForm.id}`, payload);
savedGroupId = response.data?.group?.id || savedGroupId;
} else {
await apiClient.post(`/club-communication/${this.currentClub}/groups`, payload);
const response = await apiClient.post(`/club-communication/${this.currentClub}/groups`, payload);
savedGroupId = response.data?.group?.id || savedGroupId;
}
this.selectedGroupId = savedGroupId ? Number(savedGroupId) : null;
await this.loadCommunication();
this.showInfo('Erfolg', 'Verteilergruppe gespeichert.', '', 'success');
} catch (error) {
@@ -1154,11 +1177,15 @@ export default {
if (!this.currentClub || !this.canEdit) return;
this.templateSaving = true;
try {
let savedTemplateId = this.templateForm.id;
if (this.templateForm.id) {
await apiClient.put(`/club-communication/${this.currentClub}/templates/${this.templateForm.id}`, this.templateForm);
const response = await apiClient.put(`/club-communication/${this.currentClub}/templates/${this.templateForm.id}`, this.templateForm);
savedTemplateId = response.data?.template?.id || savedTemplateId;
} else {
await apiClient.post(`/club-communication/${this.currentClub}/templates`, this.templateForm);
const response = await apiClient.post(`/club-communication/${this.currentClub}/templates`, this.templateForm);
savedTemplateId = response.data?.template?.id || savedTemplateId;
}
this.selectedTemplateId = savedTemplateId ? Number(savedTemplateId) : null;
await this.loadCommunication();
this.showInfo('Erfolg', 'Vorlage gespeichert.', '', 'success');
} catch (error) {

View File

@@ -76,6 +76,7 @@
<h3>Rechnungsparteien</h3>
<button type="button" class="btn-secondary" :disabled="!canEdit" @click="startCreateParty">Neue Partei</button>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Parteien können eingesehen, aber nicht bearbeitet werden.</p>
<div class="party-list">
<button
v-for="party in parties"
@@ -217,6 +218,7 @@
<div class="section-header">
<h3>{{ invoiceForm.id ? 'Rechnung bearbeiten' : 'Neue Rechnung' }}</h3>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Rechnungen lassen sich prüfen, aber nicht ändern.</p>
<form class="invoice-form" @submit.prevent="submitInvoice">
<div class="invoice-form-grid">
<label>
@@ -458,6 +460,45 @@ function buildInvoiceNumber(prefix, nextNumber, referenceDate = new Date()) {
return normalizedPrefix ? `${normalizedPrefix}-${year}-${paddedCounter}` : `${year}-${paddedCounter}`;
}
function createEmptyInvoiceForm() {
const issuedOn = new Date().toISOString().slice(0, 10);
return {
id: null,
invoiceDirection: 'outgoing',
invoiceType: 'sponsoring',
status: 'draft',
invoiceNumber: '',
externalReference: '',
partyId: '',
accountId: '',
issuedOn,
dueOn: addDaysIso(issuedOn, 14),
paidOn: '',
currencyCode: 'EUR',
description: '',
items: [createEmptyInvoiceItem()],
};
}
function createEmptyPartyForm() {
return {
id: null,
name: '',
partyType: 'customer',
status: 'active',
contractReference: '',
validFrom: '',
validTo: '',
contactName: '',
email: '',
phone: '',
street: '',
postalCode: '',
city: '',
notes: '',
};
}
export default {
name: 'ClubInvoicesView',
components: {
@@ -486,37 +527,8 @@ export default {
status: '',
search: '',
},
invoiceForm: {
id: null,
invoiceDirection: 'outgoing',
invoiceType: 'sponsoring',
status: 'draft',
externalReference: '',
partyId: '',
accountId: '',
issuedOn: new Date().toISOString().slice(0, 10),
dueOn: addDaysIso(new Date(), 14),
paidOn: '',
currencyCode: 'EUR',
description: '',
items: [createEmptyInvoiceItem()],
},
partyForm: {
id: null,
name: '',
partyType: 'customer',
status: 'active',
contractReference: '',
validFrom: '',
validTo: '',
contactName: '',
email: '',
phone: '',
street: '',
postalCode: '',
city: '',
notes: '',
},
invoiceForm: createEmptyInvoiceForm(),
partyForm: createEmptyPartyForm(),
infoDialog: {
isOpen: false,
title: '',
@@ -604,19 +616,7 @@ export default {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.invoices = [];
this.parties = [];
this.accounts = [];
this.invoiceSettings = {
outgoingInvoicePrefix: 'RE',
outgoingInvoiceNextNumber: 1,
incomingInvoicePrefix: 'EI',
incomingInvoiceNextNumber: 1,
};
this.selectedInvoiceId = null;
this.resetInvoiceForm();
this.resetPartyForm();
this.loadError = '';
this.clearInvoiceState();
return;
}
await this.loadInvoices();
@@ -681,6 +681,22 @@ export default {
},
},
methods: {
clearInvoiceState() {
this.invoices = [];
this.parties = [];
this.accounts = [];
this.invoiceSettings = {
outgoingInvoicePrefix: 'RE',
outgoingInvoiceNextNumber: 1,
incomingInvoicePrefix: 'EI',
incomingInvoiceNextNumber: 1,
};
this.selectedInvoiceId = null;
this.selectedPartyId = null;
this.invoiceForm = createEmptyInvoiceForm();
this.partyForm = createEmptyPartyForm();
this.loadError = '';
},
showInfo(title, message, details = '', type = 'info') {
this.infoDialog = buildInfoConfig({ title, message, details, type });
},
@@ -749,9 +765,23 @@ export default {
incomingInvoicePrefix: response.data?.settings?.incomingInvoicePrefix || 'EI',
incomingInvoiceNextNumber: Number(response.data?.settings?.incomingInvoiceNextNumber || 1) || 1,
};
if (this.selectedInvoiceId && !this.selectedInvoice) {
this.selectedInvoiceId = null;
this.resetInvoiceForm();
}
if (this.selectedPartyId && !this.selectedParty) {
this.selectedPartyId = null;
this.resetPartyForm();
}
if (!this.selectedInvoiceId && this.invoices.length > 0) {
this.selectedInvoiceId = this.invoices[0].id;
}
if (this.invoices.length === 0) {
this.resetInvoiceForm();
}
if (this.parties.length === 0) {
this.resetPartyForm();
}
this.applyWorkflowQueryPrefill();
} catch (error) {
this.loadError = safeErrorMessage(error, 'Rechnungen konnten nicht geladen werden.');
@@ -767,6 +797,7 @@ export default {
},
applyWorkflowQueryPrefill() {
if (!this.currentClub || !Array.isArray(this.parties)) return;
if (this.selectedInvoiceId || this.invoiceForm.id) return;
const partyId = String(this.$route?.query?.partyId || this.$route?.query?.selectedPartyId || '').trim();
if (!partyId) return;
const matchingParty = this.parties.find((party) => String(party.id) === partyId);
@@ -798,42 +829,11 @@ export default {
},
resetInvoiceForm() {
this.selectedInvoiceId = null;
const issuedOn = new Date().toISOString().slice(0, 10);
this.invoiceForm = {
id: null,
invoiceDirection: 'outgoing',
invoiceType: 'sponsoring',
status: 'draft',
invoiceNumber: '',
externalReference: '',
partyId: '',
accountId: '',
issuedOn,
dueOn: addDaysIso(issuedOn, 14),
paidOn: '',
currencyCode: 'EUR',
description: '',
items: [createEmptyInvoiceItem()],
};
this.invoiceForm = createEmptyInvoiceForm();
},
startCreateParty() {
this.selectedPartyId = null;
this.partyForm = {
id: null,
name: '',
partyType: 'customer',
status: 'active',
contractReference: '',
validFrom: '',
validTo: '',
contactName: '',
email: '',
phone: '',
street: '',
postalCode: '',
city: '',
notes: '',
};
this.partyForm = createEmptyPartyForm();
},
resetPartyForm() {
this.startCreateParty();
@@ -852,11 +852,15 @@ export default {
this.partySaving = true;
try {
const payload = { ...this.partyForm };
let savedPartyId = this.partyForm.id;
if (this.partyForm.id) {
await apiClient.put(`/club-invoices/${this.currentClub}/parties/${this.partyForm.id}`, payload);
const response = await apiClient.put(`/club-invoices/${this.currentClub}/parties/${this.partyForm.id}`, payload);
savedPartyId = response.data?.party?.id || savedPartyId;
} else {
await apiClient.post(`/club-invoices/${this.currentClub}/parties`, payload);
const response = await apiClient.post(`/club-invoices/${this.currentClub}/parties`, payload);
savedPartyId = response.data?.party?.id || savedPartyId;
}
this.selectedPartyId = savedPartyId ? String(savedPartyId) : null;
await this.loadInvoices();
this.showInfo('Erfolg', 'Rechnungspartei gespeichert.', '', 'success');
} catch (error) {
@@ -898,11 +902,15 @@ export default {
taxRate: item.taxRate,
})),
};
let savedInvoiceId = this.invoiceForm.id;
if (this.invoiceForm.id) {
await apiClient.put(`/club-invoices/${this.currentClub}/${this.invoiceForm.id}`, payload);
const response = await apiClient.put(`/club-invoices/${this.currentClub}/${this.invoiceForm.id}`, payload);
savedInvoiceId = response.data?.invoice?.id || savedInvoiceId;
} else {
await apiClient.post(`/club-invoices/${this.currentClub}`, payload);
const response = await apiClient.post(`/club-invoices/${this.currentClub}`, payload);
savedInvoiceId = response.data?.invoice?.id || savedInvoiceId;
}
this.selectedInvoiceId = savedInvoiceId ? String(savedInvoiceId) : null;
await this.loadInvoices();
this.showInfo('Erfolg', 'Rechnung gespeichert.', '', 'success');
} catch (error) {

View File

@@ -196,6 +196,7 @@
<div class="section-header">
<h3>{{ form.id ? 'Aufgabe bearbeiten' : 'Neue Aufgabe' }}</h3>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Aufgaben bleiben sichtbar, Änderungen am Formular sind gesperrt.</p>
<form class="task-form" @submit.prevent="submitTask">
<label>
<span>Titel</span>
@@ -420,6 +421,19 @@ function normalizeTask(payload = {}) {
};
}
function createEmptyTaskForm() {
return {
id: null,
title: '',
description: '',
status: 'open',
priority: 'normal',
dueAt: '',
remindAt: '',
assignedUserId: '',
};
}
export default {
name: 'ClubTasksView',
components: {
@@ -444,16 +458,7 @@ export default {
priority: '',
search: '',
},
form: {
id: null,
title: '',
description: '',
status: 'open',
priority: 'normal',
dueAt: '',
remindAt: '',
assignedUserId: '',
},
form: createEmptyTaskForm(),
infoDialog: {
isOpen: false,
title: '',
@@ -508,11 +513,7 @@ export default {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.tasks = [];
this.workflowSources = [];
this.selectedTaskId = null;
this.resetForm();
this.loadError = '';
this.clearTaskState();
return;
}
await this.loadTasks();
@@ -542,6 +543,16 @@ export default {
},
},
methods: {
clearTaskState() {
this.tasks = [];
this.taskDefinitions = [];
this.taskSuggestions = [];
this.workflowSources = [];
this.assignableUsers = [];
this.selectedTaskId = null;
this.form = createEmptyTaskForm();
this.loadError = '';
},
normalizeAssignableUser(user = {}) {
const email = typeof user.email === 'string' ? user.email.trim() : '';
return {
@@ -632,16 +643,7 @@ export default {
},
resetForm() {
this.selectedTaskId = null;
this.form = {
id: null,
title: '',
description: '',
status: 'open',
priority: 'normal',
dueAt: '',
remindAt: '',
assignedUserId: '',
};
this.form = createEmptyTaskForm();
},
async loadTasks() {
if (!this.currentClub) return;
@@ -669,6 +671,8 @@ export default {
}
if (!this.selectedTaskId && this.tasks.length > 0) {
this.selectedTaskId = this.tasks[0].id;
} else if (this.tasks.length === 0) {
this.resetForm();
}
} catch (error) {
this.loadError = safeErrorMessage(error, 'Aufgaben konnten nicht geladen werden.');
@@ -690,14 +694,17 @@ export default {
assignedUserId: this.form.assignedUserId || null,
};
try {
let savedTaskId = this.form.id;
if (this.form.id) {
await apiClient.put(`/club-tasks/${this.currentClub}/${this.form.id}`, payload);
const response = await apiClient.put(`/club-tasks/${this.currentClub}/${this.form.id}`, payload);
savedTaskId = response.data?.task?.id || savedTaskId;
} else {
await apiClient.post(`/club-tasks/${this.currentClub}`, payload);
const response = await apiClient.post(`/club-tasks/${this.currentClub}`, payload);
savedTaskId = response.data?.task?.id || savedTaskId;
}
this.selectedTaskId = savedTaskId ? String(savedTaskId) : null;
await this.loadTasks();
this.showInfo('Erfolg', 'Aufgabe gespeichert.', '', 'success');
this.resetForm();
} catch (error) {
this.showInfo('Fehler', safeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'), '', 'error');
} finally {
@@ -746,6 +753,9 @@ export default {
await apiClient.patch(`/club-tasks/${this.currentClub}/${task.id}/status`, {
status: 'archived',
});
if (this.selectedTask?.id === task.id) {
this.resetForm();
}
await this.loadTasks();
this.showInfo('Erfolg', 'Aufgabe wurde archiviert.', '', 'success');
} catch (error) {

View File

@@ -2165,7 +2165,7 @@ export default {
};
const configureLeagueFromUrl = async () => {
if (!parsedMyTischtennisData.value || parsedMyTischtennisData.value.urlType !== 'table') {
if (!parsedMyTischtennisData.value || parsedMyTischtennisData.value.urlType !== 'table' || !selectedClub.value) {
return;
}
@@ -2176,6 +2176,7 @@ export default {
try {
const response = await apiClient.post('/mytischtennis/configure-league', {
url: myTischtennisUrl.value.trim(),
clubId: selectedClub.value,
createSeason: true
});

View File

@@ -1,7 +1,7 @@
[versions]
# composeApp (Play Store / „Über die App“-Build)
appVersionCode = "28"
appVersionName = "1.7.8"
appVersionCode = "29"
appVersionName = "1.7.9"
agp = "9.2.1"
android-compileSdk = "35"
android-minSdk = "24"