feat: Enhance club payment claims and task automation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
backend/services/clubPaymentClaimCompatibility.js
Normal file
22
backend/services/clubPaymentClaimCompatibility.js
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
129
docs/club-outstanding-plan.md
Normal file
129
docs/club-outstanding-plan.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user