+
-
- Beispiel: Plätze 1 & 2 -> Endrunde.
+
+ {{ $t('tournaments.noPoolRulesYetFinal') }}
-
-
+
@@ -232,6 +574,18 @@ export default {
type: Boolean,
required: true
},
+ groups: {
+ type: Array,
+ default: () => []
+ },
+ groupMatches: {
+ type: Array,
+ default: () => []
+ },
+ plannedGroupCount: {
+ type: [Number, null],
+ default: null
+ },
isMiniChampionship: {
type: Boolean,
default: false
@@ -290,17 +644,387 @@ export default {
stage2GroupCount: 2,
pools12: [],
poolsFinal: [],
+ stage2StageId: null,
+ finalStageId: null,
finalStageType: 'knockout',
finalStageThirdPlace: false,
finalStageGroupCount: 1,
error: null,
success: null,
},
+ highlightedRuleElement: null,
+ highlightedRuleTimer: null,
};
},
computed: {
finalPools() {
return this.stageConfig.poolsFinal;
+ },
+ canAdvanceToIntermediate() {
+ return this.stageConfig.useIntermediateStage && this.stageConfig.pools12.length > 0;
+ },
+ canAdvanceToFinalFromPreliminary() {
+ return !this.stageConfig.useIntermediateStage && this.finalPools.length > 0;
+ },
+ canAdvanceToFinalFromIntermediate() {
+ return this.stageConfig.useIntermediateStage && this.finalPools.length > 0;
+ },
+ hasAnyAdvanceRule() {
+ return this.stageConfig.pools12.length > 0 || this.finalPools.length > 0;
+ },
+ stageValidationIssues() {
+ const issues = [];
+ const stage2Label = this.$t('tournaments.intermediateRound');
+ const finalLabel = this.$t('tournaments.finalRound');
+ const pushIssues = (items) => {
+ items.forEach((item, index) => {
+ issues.push({
+ ...item,
+ key: item.key || `${item.transition}-${index}-${item.code}`
+ });
+ });
+ };
+
+ if (
+ this.stageConfig.useIntermediateStage &&
+ this.stageConfig.stage2Type === 'groups' &&
+ (!Number.isFinite(Number(this.stageConfig.stage2GroupCount)) || Number(this.stageConfig.stage2GroupCount) < 1)
+ ) {
+ issues.push({
+ key: 'stage2-group-count',
+ level: 'error',
+ title: stage2Label,
+ message: this.$t('tournaments.stageValidationGroupCountRequired'),
+ fields: ['stage2-group-count']
+ });
+ }
+
+ if (
+ this.stageConfig.finalStageType === 'groups' &&
+ (!Number.isFinite(Number(this.stageConfig.finalStageGroupCount)) || Number(this.stageConfig.finalStageGroupCount) < 1)
+ ) {
+ issues.push({
+ key: 'final-group-count',
+ level: 'error',
+ title: finalLabel,
+ message: this.$t('tournaments.stageValidationGroupCountRequired'),
+ fields: ['final-group-count']
+ });
+ }
+
+ if (this.stageConfig.useIntermediateStage) {
+ pushIssues(this.validatePoolRules(this.stageConfig.pools12, {
+ stageLabel: stage2Label,
+ transition: 'stage2',
+ forceKnockout: false,
+ allowGroupsTarget: true,
+ expectedTargetType: this.stageConfig.stage2Type,
+ expectedGroupCount: this.stageConfig.stage2Type === 'groups'
+ ? Number(this.stageConfig.stage2GroupCount || 0)
+ : null
+ }));
+ }
+
+ pushIssues(this.validatePoolRules(this.finalPools, {
+ stageLabel: finalLabel,
+ transition: 'final',
+ forceKnockout: this.stageConfig.finalStageType === 'knockout',
+ allowGroupsTarget: this.stageConfig.finalStageType === 'groups',
+ expectedTargetType: this.stageConfig.finalStageType,
+ expectedGroupCount: this.stageConfig.finalStageType === 'groups'
+ ? Number(this.stageConfig.finalStageGroupCount || 0)
+ : null
+ }));
+
+ return issues;
+ },
+ stageFlowHealth() {
+ const errors = this.stageValidationIssues.filter(issue => issue.level === 'error');
+ const warnings = this.stageValidationIssues.filter(issue => issue.level === 'warning');
+ const hasIntermediateRules = !this.stageConfig.useIntermediateStage || this.stageConfig.pools12.length > 0;
+ const hasFinalRules = this.finalPools.length > 0;
+ const hasRulesForAllTransitions = hasIntermediateRules && hasFinalRules;
+
+ if (errors.length > 0) {
+ return {
+ level: 'error',
+ label: this.$t('tournaments.stageFlowHealthBlocked'),
+ message: this.$t('tournaments.stageFlowHealthBlockedDescription')
+ };
+ }
+
+ if (!hasRulesForAllTransitions || warnings.length > 0) {
+ return {
+ level: 'warning',
+ label: this.$t('tournaments.stageFlowHealthIncomplete'),
+ message: !hasRulesForAllTransitions
+ ? this.$t('tournaments.stageFlowHealthMissingRules')
+ : this.$t('tournaments.stageFlowHealthReviewDescription')
+ };
+ }
+
+ return {
+ level: 'ok',
+ label: this.$t('tournaments.stageFlowHealthOk'),
+ message: this.$t('tournaments.stageFlowHealthOkDescription')
+ };
+ },
+ stageFlowBreakdown() {
+ const items = [];
+ const buildItem = ({ key, label, transition, enabled, missingRuleMessage }) => {
+ if (!enabled) return null;
+ const transitionIssues = this.stageValidationIssues.filter(issue => issue.transition === transition);
+ const errorCount = transitionIssues.filter(issue => issue.level === 'error').length;
+ const warningCount = transitionIssues.filter(issue => issue.level === 'warning').length;
+ const firstIssue = transitionIssues[0] || null;
+ const hasRules = transition === 'stage2' ? this.stageConfig.pools12.length > 0 : this.finalPools.length > 0;
+ const readiness = this.getTransitionReadiness(transition);
+ const focusField = firstIssue?.fields?.[0]
+ || (transition === 'stage2' ? 'stage2:0:places' : 'final:0:places');
+
+ if (!hasRules) {
+ return {
+ key,
+ label,
+ level: 'warning',
+ message: missingRuleMessage,
+ focusField,
+ action: 'add-rule',
+ actionLabel: this.$t('tournaments.stageFlowBreakdownActionAddRule')
+ };
+ }
+ if (!readiness.ready) {
+ return {
+ key,
+ label,
+ level: 'warning',
+ message: readiness.message,
+ focusField,
+ action: 'review',
+ actionLabel: this.$t('tournaments.stageFlowBreakdownActionReview')
+ };
+ }
+ if (errorCount > 0) {
+ return {
+ key,
+ label,
+ level: 'error',
+ message: this.$t('tournaments.stageFlowBreakdownErrors', { count: errorCount }),
+ focusField,
+ action: 'review',
+ actionLabel: this.$t('tournaments.stageFlowBreakdownActionReview')
+ };
+ }
+ if (warningCount > 0) {
+ return {
+ key,
+ label,
+ level: 'warning',
+ message: this.$t('tournaments.stageFlowBreakdownWarnings', { count: warningCount }),
+ focusField,
+ action: 'review',
+ actionLabel: this.$t('tournaments.stageFlowBreakdownActionReview')
+ };
+ }
+ return {
+ key,
+ label,
+ level: 'ok',
+ message: this.$t('tournaments.stageFlowBreakdownOk'),
+ focusField,
+ action: 'open',
+ actionLabel: this.$t('tournaments.stageFlowBreakdownActionOpen')
+ };
+ };
+
+ if (this.stageConfig.useIntermediateStage) {
+ items.push(buildItem({
+ key: 'stage2',
+ label: this.$t('tournaments.promotionRule12'),
+ transition: 'stage2',
+ enabled: true,
+ missingRuleMessage: this.$t('tournaments.stageFlowBreakdownMissingRule')
+ }));
+ items.push(buildItem({
+ key: 'final',
+ label: this.$t('tournaments.promotionRuleFinal', { from: 2, to: 3 }),
+ transition: 'final',
+ enabled: true,
+ missingRuleMessage: this.$t('tournaments.stageFlowBreakdownMissingRule')
+ }));
+ } else {
+ items.push(buildItem({
+ key: 'final',
+ label: this.$t('tournaments.promotionRuleFinal', { from: 1, to: 3 }),
+ transition: 'final',
+ enabled: true,
+ missingRuleMessage: this.$t('tournaments.stageFlowBreakdownMissingRule')
+ }));
+ }
+
+ return items.filter(Boolean);
+ },
+ autoFixableStageIssues() {
+ const fixes = [];
+ const collectRuleFixes = (transition, rules) => {
+ rules.forEach((rule, index) => {
+ const placesSuggestion = this.getRulePlacesSuggestion(transition, index);
+ if (placesSuggestion) {
+ fixes.push({ type: 'places', transition, index });
+ }
+ ['targetType', 'targetGroupCount'].forEach(field => {
+ if (this.hasRuleQuickFix(transition, index, field)) {
+ fixes.push({ type: field, transition, index });
+ }
+ });
+ });
+ };
+ collectRuleFixes('stage2', this.stageConfig.pools12);
+ collectRuleFixes('final', this.finalPools);
+ return fixes;
+ },
+ stageFlowRecommendation() {
+ const missingRuleItem = this.stageFlowBreakdown.find(item => item.action === 'add-rule');
+ const firstError = this.stageValidationIssues.find(issue => issue.level === 'error');
+ const firstWarning = this.stageValidationIssues.find(issue => issue.level === 'warning');
+
+ if (this.autoFixableStageIssues.length > 0) {
+ return {
+ type: 'apply-fixes',
+ label: this.$t('tournaments.stageValidationApplyAllFixes', { count: this.autoFixableStageIssues.length }),
+ message: this.$t('tournaments.stageFlowRecommendationFixes')
+ };
+ }
+
+ if (missingRuleItem) {
+ return {
+ type: 'add-rule',
+ key: missingRuleItem.key,
+ label: this.$t('tournaments.stageFlowBreakdownActionAddRule'),
+ message: this.$t('tournaments.stageFlowRecommendationMissingRule', { label: missingRuleItem.label })
+ };
+ }
+
+ if (firstError) {
+ return {
+ type: 'review',
+ field: firstError.fields?.[0] || null,
+ label: this.$t('tournaments.stageFlowBreakdownActionReview'),
+ message: this.$t('tournaments.stageFlowRecommendationError')
+ };
+ }
+
+ if (firstWarning) {
+ return {
+ type: 'review',
+ field: firstWarning.fields?.[0] || null,
+ label: this.$t('tournaments.stageFlowBreakdownActionReview'),
+ message: this.$t('tournaments.stageFlowRecommendationWarnings')
+ };
+ }
+
+ return {
+ type: 'save',
+ label: this.$t('tournaments.saveRounds'),
+ message: this.$t('tournaments.stageFlowRecommendationSave')
+ };
+ },
+ stageFlowPreviewItems() {
+ const buildPreview = (transition, label, rules, targetType, targetGroupCount) => {
+ if (!Array.isArray(rules) || rules.length === 0) {
+ return {
+ key: transition,
+ label,
+ message: this.$t('tournaments.stageFlowPreviewMissing')
+ };
+ }
+ const fragments = rules.map(rule => {
+ const places = this.normalizeRulePlaces(rule?.fromPlacesText).unique.sort((a, b) => a - b).join(',');
+ const qualifiedCount = this.getRuleQualifiedCount(transition, rule);
+ const targetLabel = targetType === 'knockout'
+ ? this.$t('tournaments.knockoutLabel')
+ : this.$t('tournaments.targetGroupsLabel', { count: targetGroupCount || rule?.targetGroupCount || 1 });
+ const base = this.$t('tournaments.stageFlowPreviewRule', {
+ places: places || '–',
+ target: targetLabel
+ });
+ if (qualifiedCount == null) {
+ return base;
+ }
+ return this.$t('tournaments.stageFlowPreviewRuleWithCount', {
+ base,
+ count: qualifiedCount
+ });
+ });
+ return {
+ key: transition,
+ label,
+ message: fragments.join(' · ')
+ };
+ };
+
+ const items = [];
+ if (this.stageConfig.useIntermediateStage) {
+ items.push(buildPreview(
+ 'stage2',
+ this.$t('tournaments.promotionRule12'),
+ this.stageConfig.pools12,
+ this.stageConfig.stage2Type,
+ this.stageConfig.stage2GroupCount
+ ));
+ items.push(buildPreview(
+ 'final',
+ this.$t('tournaments.promotionRuleFinal', { from: 2, to: 3 }),
+ this.finalPools,
+ this.stageConfig.finalStageType,
+ this.stageConfig.finalStageGroupCount
+ ));
+ } else {
+ items.push(buildPreview(
+ 'final',
+ this.$t('tournaments.promotionRuleFinal', { from: 1, to: 3 }),
+ this.finalPools,
+ this.stageConfig.finalStageType,
+ this.stageConfig.finalStageGroupCount
+ ));
+ }
+ return items;
+ },
+ hasBlockingStageValidationIssues() {
+ return this.stageValidationIssues.some(issue => issue.level === 'error');
+ },
+ stageValidationFieldMap() {
+ const map = {};
+ this.stageValidationIssues.forEach(issue => {
+ if (!Array.isArray(issue.fields)) return;
+ issue.fields.forEach(field => {
+ map[field] ||= [];
+ map[field].push(issue);
+ });
+ });
+ return map;
+ },
+ stageFlowSummary() {
+ if (this.stageConfig.useIntermediateStage) {
+ return this.$t('tournaments.stageFlowWithIntermediate', {
+ stage2: this.stageTypeLabel(this.stageConfig.stage2Type),
+ final: this.stageTypeLabel(this.stageConfig.finalStageType)
+ });
+ }
+ return this.$t('tournaments.stageFlowDirectFinal', {
+ final: this.stageTypeLabel(this.stageConfig.finalStageType)
+ });
+ },
+ currentPreliminaryGroupCount() {
+ const currentGroups = (this.groups || []).filter(group => group && (group.stageId == null || group.stageId === 1));
+ if (currentGroups.length > 0) {
+ return currentGroups.length;
+ }
+ const fallback = Number(this.plannedGroupCount);
+ return Number.isInteger(fallback) && fallback > 0 ? fallback : null;
+ },
+ effectiveGroups() {
+ return Array.isArray(this.groups) ? this.groups : [];
}
},
watch: {
@@ -336,7 +1060,543 @@ export default {
]
,
methods: {
+ stageTypeLabel(type) {
+ return type === 'knockout' ? this.$t('tournaments.knockoutLabel') : this.$t('tournaments.groupsLabel');
+ },
+ poolRulePreview(rule, stage) {
+ const places = String(rule?.fromPlacesText || '')
+ .split(',')
+ .map(value => value.trim())
+ .filter(Boolean)
+ .join(', ');
+ const targetType = stage === 'final' && this.stageConfig.finalStageType === 'knockout'
+ ? 'knockout'
+ : (rule?.targetType || 'groups');
+ const targetLabel = targetType === 'knockout'
+ ? this.$t('tournaments.poolRuleTargetKnockout')
+ : this.$t('tournaments.poolRuleTargetGroupsDetailed', {
+ count: rule?.targetGroupCount || (stage === 'final' ? this.stageConfig.finalStageGroupCount || 1 : this.stageConfig.stage2GroupCount || 2)
+ });
+ return this.$t('tournaments.poolRulePreview', {
+ places: places || '–',
+ target: targetLabel
+ });
+ },
+ applyPresetRule(rule, value) {
+ if (!rule) return;
+ rule.fromPlacesText = value;
+ },
+ normalizeRulePlaces(text) {
+ const rawValues = String(text || '')
+ .split(',')
+ .map(value => value.trim())
+ .filter(Boolean);
+ const parsed = rawValues.map(value => Number(value));
+ return {
+ rawValues,
+ parsed,
+ valid: parsed.every(value => Number.isInteger(value) && value > 0),
+ unique: [...new Set(parsed.filter(value => Number.isInteger(value) && value > 0))]
+ };
+ },
+ isPowerOfTwo(value) {
+ const n = Number(value);
+ return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0;
+ },
+ getSourceGroupCount(transition) {
+ if (transition === 'stage2') {
+ return this.currentPreliminaryGroupCount;
+ }
+ if (transition === 'final' && this.stageConfig.useIntermediateStage && this.stageConfig.stage2Type === 'groups') {
+ const count = Number(this.stageConfig.stage2GroupCount);
+ return Number.isInteger(count) && count > 0 ? count : null;
+ }
+ if (transition === 'final' && !this.stageConfig.useIntermediateStage) {
+ return this.currentPreliminaryGroupCount;
+ }
+ return null;
+ },
+ getSourceGroupsForTransition(transition) {
+ if (transition === 'stage2') {
+ return this.effectiveGroups.filter(group => group && (group.stageId == null || group.stageId === undefined));
+ }
+ if (transition === 'final' && this.stageConfig.useIntermediateStage && this.stageConfig.stage2Type === 'groups') {
+ return this.effectiveGroups.filter(group => group && Number(group.stageId) === Number(this.stageConfig.stage2StageId));
+ }
+ if (transition === 'final' && !this.stageConfig.useIntermediateStage) {
+ return this.effectiveGroups.filter(group => group && (group.stageId == null || group.stageId === undefined));
+ }
+ return [];
+ },
+ getSourceGroupMatchesForTransition(transition) {
+ const sourceGroupIds = new Set(this.getSourceGroupsForTransition(transition).map(group => Number(group.groupId ?? group.id)));
+ return (Array.isArray(this.groupMatches) ? this.groupMatches : []).filter(match => sourceGroupIds.has(Number(match.groupId)));
+ },
+ isGroupReadyForAdvancement(group, transition = 'stage2') {
+ const participants = Array.isArray(group?.participants) ? group.participants : [];
+ if (participants.length < 2) {
+ return true;
+ }
+ const groupId = Number(group?.groupId ?? group?.id);
+ const matches = this.getSourceGroupMatchesForTransition(transition)
+ .filter(match => Number(match.groupId) === groupId);
+ const positions = participants
+ .map(participant => Number(participant.position || 0))
+ .filter(position => Number.isInteger(position) && position > 0);
+ return matches.length > 0
+ && matches.every(match => match.isFinished)
+ && positions.length === participants.length
+ && new Set(positions).size === positions.length;
+ },
+ getTransitionReadiness(transition) {
+ const sourceGroups = this.getSourceGroupsForTransition(transition);
+ if (sourceGroups.length === 0) {
+ return {
+ ready: false,
+ message: this.$t('tournaments.stageFlowReadinessNoGroups')
+ };
+ }
+ const incompleteGroups = sourceGroups.filter(group => !this.isGroupReadyForAdvancement(group, transition));
+ if (incompleteGroups.length > 0) {
+ return {
+ ready: false,
+ message: this.$t('tournaments.stageFlowReadinessIncompleteGroups', { count: incompleteGroups.length })
+ };
+ }
+ return {
+ ready: true,
+ message: this.$t('tournaments.stageFlowReadinessReady')
+ };
+ },
+ getRuleQualifiedCount(transition, rule) {
+ const places = this.normalizeRulePlaces(rule?.fromPlacesText).unique;
+ const sourceGroups = this.getSourceGroupsForTransition(transition);
+ const allReady = sourceGroups.length > 0 && sourceGroups.every(group => this.isGroupReadyForAdvancement(group, transition));
+ if (allReady && places.length > 0) {
+ return sourceGroups.reduce((count, group) => {
+ const participants = Array.isArray(group?.participants) ? group.participants : [];
+ return count + participants.filter(participant => places.includes(Number(participant.position || 0))).length;
+ }, 0);
+ }
+ const sourceGroupCount = this.getSourceGroupCount(transition);
+ if (!Number.isInteger(sourceGroupCount) || sourceGroupCount < 1 || places.length < 1) {
+ return null;
+ }
+ return sourceGroupCount * places.length;
+ },
+ getRuleQualifiedPreview(transition, rule) {
+ const places = this.normalizeRulePlaces(rule?.fromPlacesText).unique;
+ if (places.length === 0) return [];
+ const sourceGroups = this.getSourceGroupsForTransition(transition);
+ const allReady = sourceGroups.length > 0 && sourceGroups.every(group => this.isGroupReadyForAdvancement(group, transition));
+ if (!allReady) return [];
+
+ return sourceGroups.flatMap((group, groupIndex) => {
+ const participants = Array.isArray(group?.participants) ? group.participants : [];
+ return participants
+ .filter(participant => places.includes(Number(participant.position || 0)))
+ .map(participant => ({
+ key: `${group.groupId ?? group.id}-${participant.id}-${participant.position}`,
+ label: this.$t('tournaments.stageFlowQualifiedPreviewEntry', {
+ group: group.groupNumber || groupIndex + 1,
+ position: participant.position,
+ name: participant.name
+ })
+ }));
+ });
+ },
+ validatePoolRules(rules, options = {}) {
+ const stageLabel = options.stageLabel || this.$t('tournaments.finalRound');
+ const transition = options.transition || 'stage';
+ const forceKnockout = options.forceKnockout === true;
+ const allowGroupsTarget = options.allowGroupsTarget !== false;
+ const expectedTargetType = options.expectedTargetType || null;
+ const expectedGroupCount = Number(options.expectedGroupCount || 0);
+ const sourceGroupCount = this.getSourceGroupCount(transition);
+ const issues = [];
+ const placeMap = new Map();
+
+ if (!Array.isArray(rules) || rules.length === 0) {
+ issues.push({
+ code: 'no-rules',
+ transition,
+ level: 'warning',
+ title: stageLabel,
+ message: this.$t('tournaments.stageValidationNoRules', { stage: stageLabel }),
+ fields: []
+ });
+ return issues;
+ }
+
+ rules.forEach((rule, index) => {
+ const ruleLabel = this.$t('tournaments.poolRuleLabel', { number: index + 1 });
+ const normalized = this.normalizeRulePlaces(rule?.fromPlacesText);
+ const targetType = forceKnockout ? 'knockout' : (rule?.targetType || 'groups');
+
+ if (normalized.rawValues.length === 0) {
+ issues.push({
+ code: 'missing-places',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationRulePlacesRequired'),
+ fields: [`${transition}:${index}:places`]
+ });
+ return;
+ }
+
+ if (!normalized.valid) {
+ issues.push({
+ code: 'invalid-places',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationRulePlacesInvalid'),
+ fields: [`${transition}:${index}:places`]
+ });
+ return;
+ }
+
+ if (normalized.unique.length !== normalized.parsed.length) {
+ issues.push({
+ code: 'duplicate-places',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationRulePlacesDuplicate'),
+ fields: [`${transition}:${index}:places`]
+ });
+ }
+
+ if (
+ normalized.unique.length > 1 &&
+ normalized.unique.some((place, placeIndex) => placeIndex > 0 && place !== normalized.unique[placeIndex - 1] + 1)
+ ) {
+ issues.push({
+ code: 'non-contiguous-places',
+ transition,
+ level: 'warning',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationRulePlacesGap'),
+ fields: [`${transition}:${index}:places`]
+ });
+ }
+
+ normalized.unique.forEach(place => {
+ const existingRule = placeMap.get(place);
+ if (existingRule != null) {
+ issues.push({
+ code: `overlap-${place}`,
+ transition,
+ level: 'error',
+ title: stageLabel,
+ message: this.$t('tournaments.stageValidationRulesOverlap', {
+ place,
+ first: existingRule,
+ second: index + 1
+ }),
+ fields: [`${transition}:${existingRule - 1}:places`, `${transition}:${index}:places`]
+ });
+ } else {
+ placeMap.set(place, index + 1);
+ }
+ });
+
+ if (!forceKnockout && targetType === 'groups') {
+ const groupCount = Number(rule?.targetGroupCount);
+ if (!Number.isInteger(groupCount) || groupCount < 1) {
+ issues.push({
+ code: 'target-groups-required',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationTargetGroupsRequired'),
+ fields: [`${transition}:${index}:targetGroupCount`]
+ });
+ } else if (expectedTargetType === 'groups' && Number.isInteger(expectedGroupCount) && expectedGroupCount > 0 && groupCount !== expectedGroupCount) {
+ issues.push({
+ code: 'target-groups-mismatch',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationTargetGroupCountMismatch', {
+ expected: expectedGroupCount
+ }),
+ fields: [`${transition}:${index}:targetGroupCount`]
+ });
+ }
+ }
+
+ if (!allowGroupsTarget && targetType === 'groups') {
+ issues.push({
+ code: 'target-mismatch',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationKnockoutTargetOnly'),
+ fields: [`${transition}:${index}:targetType`]
+ });
+ } else if (expectedTargetType && targetType !== expectedTargetType) {
+ issues.push({
+ code: 'target-type-mismatch',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationTargetTypeMismatch', {
+ target: expectedTargetType === 'knockout'
+ ? this.$t('tournaments.knockoutLabel')
+ : this.$t('tournaments.groupsLabel')
+ }),
+ fields: [`${transition}:${index}:targetType`]
+ });
+ }
+
+ const qualifiedCount = this.getRuleQualifiedCount(transition, rule);
+ if (qualifiedCount != null && targetType === 'knockout') {
+ if (qualifiedCount < 2) {
+ issues.push({
+ code: 'knockout-too-small',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationKnockoutNeedsTwoPlayers'),
+ fields: [`${transition}:${index}:places`, `${transition}:${index}:targetType`]
+ });
+ } else if (!this.isPowerOfTwo(qualifiedCount)) {
+ issues.push({
+ code: 'knockout-byes-likely',
+ transition,
+ level: 'warning',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationKnockoutByesLikely', { count: qualifiedCount }),
+ fields: [`${transition}:${index}:places`]
+ });
+ }
+ }
+
+ if (qualifiedCount != null && targetType === 'groups' && Number.isInteger(expectedGroupCount) && expectedGroupCount > 0) {
+ if (qualifiedCount < expectedGroupCount) {
+ issues.push({
+ code: 'groups-too-few-qualifiers',
+ transition,
+ level: 'error',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationNotEnoughQualifiersForGroups', { count: qualifiedCount, groups: expectedGroupCount }),
+ fields: [`${transition}:${index}:places`, `${transition}:${index}:targetGroupCount`]
+ });
+ } else if (qualifiedCount < expectedGroupCount * 2) {
+ issues.push({
+ code: 'groups-thin',
+ transition,
+ level: 'warning',
+ title: `${stageLabel}: ${ruleLabel}`,
+ message: this.$t('tournaments.stageValidationThinGroupsLikely', { count: qualifiedCount, groups: expectedGroupCount }),
+ fields: [`${transition}:${index}:places`, `${transition}:${index}:targetGroupCount`]
+ });
+ }
+ }
+ });
+
+ return issues;
+ },
+ hasStageFieldError(fieldKey) {
+ return (this.stageValidationFieldMap[fieldKey] || []).some(issue => issue.level === 'error');
+ },
+ hasRuleFieldError(transition, index, field) {
+ return (this.stageValidationFieldMap[`${transition}:${index}:${field}`] || []).some(issue => issue.level === 'error');
+ },
+ getStageFieldHint(fieldKey) {
+ return (this.stageValidationFieldMap[fieldKey] || [])[0]?.message || '';
+ },
+ getRuleFieldHint(transition, index, field) {
+ return (this.stageValidationFieldMap[`${transition}:${index}:${field}`] || [])[0]?.message || '';
+ },
+ getNormalizedPlacesSuggestion(text) {
+ const normalized = this.normalizeRulePlaces(text);
+ if (normalized.unique.length === 0) return '';
+ const suggestion = normalized.unique.sort((a, b) => a - b).join(',');
+ const raw = String(text || '').replace(/\s+/g, '');
+ if (!suggestion || suggestion === raw) return '';
+ return suggestion;
+ },
+ getRulePlacesSuggestion(transition, index) {
+ if (!this.hasRuleFieldError(transition, index, 'places')) return '';
+ const rules = transition === 'final' ? this.finalPools : this.stageConfig.pools12;
+ const rule = rules[index];
+ return this.getNormalizedPlacesSuggestion(rule?.fromPlacesText);
+ },
+ getExpectedTargetType(transition) {
+ return transition === 'final'
+ ? this.stageConfig.finalStageType
+ : this.stageConfig.stage2Type;
+ },
+ getExpectedTargetGroupCount(transition) {
+ return transition === 'final'
+ ? Number(this.stageConfig.finalStageGroupCount || 0)
+ : Number(this.stageConfig.stage2GroupCount || 0);
+ },
+ getRuleFieldIssues(transition, index, field) {
+ return this.stageValidationFieldMap[`${transition}:${index}:${field}`] || [];
+ },
+ getRuleIssues(transition, index) {
+ return this.stageValidationIssues.filter(issue =>
+ Array.isArray(issue.fields) &&
+ issue.fields.some(field => field.startsWith(`${transition}:${index}:`))
+ );
+ },
+ getRuleStatus(transition, index) {
+ const issues = this.getRuleIssues(transition, index);
+ if (issues.some(issue => issue.level === 'error')) {
+ return { level: 'error', issue: issues.find(issue => issue.level === 'error') };
+ }
+ if (issues.some(issue => issue.level === 'warning')) {
+ return { level: 'warning', issue: issues.find(issue => issue.level === 'warning') };
+ }
+ return { level: 'ok', issue: null };
+ },
+ getRuleStatusLabel(transition, index) {
+ const status = this.getRuleStatus(transition, index);
+ if (status.level === 'error') return this.$t('tournaments.ruleStatusBlocked');
+ if (status.level === 'warning') return this.$t('tournaments.ruleStatusReview');
+ return this.$t('tournaments.ruleStatusOk');
+ },
+ getRuleStatusMessage(transition, index) {
+ const status = this.getRuleStatus(transition, index);
+ if (status.issue?.message) return status.issue.message;
+ return this.$t('tournaments.ruleStatusOkDescription');
+ },
+ hasRuleQuickFix(transition, index, field) {
+ return this.getRuleQuickFixLabel(transition, index, field) !== '';
+ },
+ getRuleQuickFixLabel(transition, index, field) {
+ const issues = this.getRuleFieldIssues(transition, index, field);
+ if (field === 'targetType' && issues.some(issue => issue.code === 'target-type-mismatch' || issue.code === 'target-mismatch')) {
+ return this.$t('tournaments.stageValidationApplyTargetTypeFix', {
+ target: this.getExpectedTargetType(transition) === 'knockout'
+ ? this.$t('tournaments.knockoutLabel')
+ : this.$t('tournaments.groupsLabel')
+ });
+ }
+ if (field === 'targetGroupCount' && issues.some(issue => issue.code === 'target-groups-required' || issue.code === 'target-groups-mismatch')) {
+ return this.$t('tournaments.stageValidationApplyTargetGroupFix', {
+ count: this.getExpectedTargetGroupCount(transition)
+ });
+ }
+ return '';
+ },
+ applyRulePlacesSuggestion(transition, index) {
+ const rules = transition === 'final' ? this.finalPools : this.stageConfig.pools12;
+ const suggestion = this.getRulePlacesSuggestion(transition, index);
+ if (!suggestion || !rules[index]) return;
+ rules[index].fromPlacesText = suggestion;
+ },
+ applyRuleQuickFix(transition, index, field) {
+ const rules = transition === 'final' ? this.finalPools : this.stageConfig.pools12;
+ const rule = rules[index];
+ if (!rule) return;
+ if (field === 'targetType') {
+ rule.targetType = this.getExpectedTargetType(transition);
+ if (rule.targetType === 'groups' && (!Number.isInteger(Number(rule.targetGroupCount)) || Number(rule.targetGroupCount) < 1)) {
+ rule.targetGroupCount = Math.max(1, this.getExpectedTargetGroupCount(transition) || 1);
+ }
+ return;
+ }
+ if (field === 'targetGroupCount') {
+ rule.targetGroupCount = Math.max(1, this.getExpectedTargetGroupCount(transition) || 1);
+ }
+ },
+ focusStageField(fieldKey) {
+ if (!fieldKey) return;
+ this.$nextTick(() => {
+ const element = this.$el?.querySelector?.(`[data-field-key="${fieldKey}"]`);
+ if (!element) return;
+ const ruleKey = fieldKey.includes(':')
+ ? fieldKey.split(':').slice(0, 2).join(':')
+ : null;
+ this.highlightRule(ruleKey);
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ if (typeof element.focus === 'function') {
+ element.focus();
+ }
+ });
+ },
+ handleStageFlowBreakdownClick(item) {
+ if (!item) return;
+ if (item.action === 'add-rule') {
+ this.addPoolRule(item.key === 'stage2' ? '12' : 'final');
+ this.$nextTick(() => {
+ const rules = item.key === 'stage2' ? this.stageConfig.pools12 : this.finalPools;
+ const index = Math.max(0, rules.length - 1);
+ this.focusStageField(`${item.key}:${index}:places`);
+ });
+ return;
+ }
+ this.focusStageField(item.focusField);
+ },
+ applyAllStageQuickFixes() {
+ this.autoFixableStageIssues.forEach(fix => {
+ if (fix.type === 'places') {
+ this.applyRulePlacesSuggestion(fix.transition, fix.index);
+ return;
+ }
+ this.applyRuleQuickFix(fix.transition, fix.index, fix.type);
+ });
+ const firstFix = this.autoFixableStageIssues[0];
+ if (firstFix) {
+ const field = firstFix.type === 'places' ? 'places' : firstFix.type;
+ this.focusStageField(`${firstFix.transition}:${firstFix.index}:${field}`);
+ }
+ },
+ runStageFlowRecommendation() {
+ const recommendation = this.stageFlowRecommendation;
+ if (!recommendation) return;
+ if (recommendation.type === 'apply-fixes') {
+ this.applyAllStageQuickFixes();
+ return;
+ }
+ if (recommendation.type === 'add-rule') {
+ this.handleStageFlowBreakdownClick({
+ key: recommendation.key,
+ action: 'add-rule'
+ });
+ return;
+ }
+ if (recommendation.type === 'review') {
+ this.focusStageField(recommendation.field);
+ return;
+ }
+ if (recommendation.type === 'save') {
+ this.onSaveClick();
+ }
+ },
+ highlightRule(ruleKey) {
+ if (!ruleKey) return;
+ if (this.highlightedRuleTimer) {
+ clearTimeout(this.highlightedRuleTimer);
+ this.highlightedRuleTimer = null;
+ }
+ if (this.highlightedRuleElement) {
+ this.highlightedRuleElement.classList.remove('pool-rule--highlighted');
+ }
+ const ruleElement = this.$el?.querySelector?.(`[data-rule-key="${ruleKey}"]`);
+ if (!ruleElement) return;
+ ruleElement.classList.add('pool-rule--highlighted');
+ this.highlightedRuleElement = ruleElement;
+ this.highlightedRuleTimer = setTimeout(() => {
+ if (this.highlightedRuleElement) {
+ this.highlightedRuleElement.classList.remove('pool-rule--highlighted');
+ this.highlightedRuleElement = null;
+ }
+ this.highlightedRuleTimer = null;
+ }, 2200);
+ },
onSaveClick() {
+ if (this.hasBlockingStageValidationIssues) {
+ const firstIssue = this.stageValidationIssues.find(issue => issue.level === 'error');
+ this.stageConfig.error = firstIssue?.message || this.$t('tournaments.stageValidationSaveBlocked');
+ this.stageConfig.success = null;
+ return;
+ }
this.saveStageConfig();
},
@@ -347,7 +1607,7 @@ export default {
this.stageConfig.success = null;
if (!this.clubId || !this.tournamentId) {
- this.stageConfig.error = 'Kann nicht speichern: clubId oder tournamentId fehlt.';
+ this.stageConfig.error = this.$t('tournaments.stageConfigMissingIds');
return;
}
@@ -360,10 +1620,10 @@ export default {
tournamentId: Number(this.tournamentId),
}
});
- if (getRes.status >= 400) throw new Error(getRes.data?.error || 'Fehler beim Laden');
+ if (getRes.status >= 400) throw new Error(getRes.data?.error || this.$t('tournaments.stageConfigLoadError'));
if (!Array.isArray(getRes.data?.stages) || !Array.isArray(getRes.data?.advancements)) {
- throw new Error('Fehlerhafte Antwort vom Server (stages/advancements fehlen).');
+ throw new Error(this.$t('tournaments.stageConfigBadServerResponse'));
}
let stages = Array.isArray(getRes.data?.stages) ? getRes.data.stages : [];
@@ -448,7 +1708,7 @@ export default {
if (putRes.status >= 400) throw new Error(putRes.data?.error || 'Fehler beim Speichern');
await this.loadStageConfig();
- this.stageConfig.success = 'Gespeichert.';
+ this.stageConfig.success = this.$t('common.saved');
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
@@ -466,7 +1726,7 @@ export default {
tournamentId: Number(this.tournamentId),
}
});
- if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Laden der Zwischenrunden');
+ if (res.status >= 400) throw new Error(res.data?.error || this.$t('tournaments.stageConfigLoadError'));
const stages = Array.isArray(res.data?.stages) ? res.data.stages : [];
const advancements = Array.isArray(res.data?.advancements) ? res.data.advancements : [];
@@ -476,16 +1736,21 @@ export default {
this.stageConfig.useIntermediateStage = !!stage2;
if (stage2) {
+ this.stageConfig.stage2StageId = Number(stage2.id);
this.stageConfig.stage2Type = stage2.type || 'groups';
this.stageConfig.stage2GroupCount = stage2.numberOfGroups || 2;
+ } else {
+ this.stageConfig.stage2StageId = null;
}
const stage3 = stages.find(s => Number(s.index) === 3);
if (stage3) {
+ this.stageConfig.finalStageId = Number(stage3.id);
this.stageConfig.finalStageType = stage3.type || 'knockout';
this.stageConfig.finalStageGroupCount = stage3.numberOfGroups || 1;
} else {
// Fallback, wenn bisher nur 1->2 existierte
+ this.stageConfig.finalStageId = null;
this.stageConfig.finalStageType = 'knockout';
this.stageConfig.finalStageGroupCount = 1;
}
@@ -634,7 +1899,7 @@ export default {
const hasPools = Array.isArray(adv?.config?.pools) && adv.config.pools.length > 0;
if (!hasPools) {
const label = `${adv.fromStageIndex}→${adv.toStageIndex}`;
- throw new Error(`Bitte mindestens eine Pool-Regel für ${label} anlegen (z.B. Plätze 1,2).`);
+ throw new Error(this.$t('tournaments.atLeastOnePoolRule', { label }));
}
}
@@ -644,10 +1909,10 @@ export default {
stages,
advancements,
});
- if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Speichern');
+ if (res.status >= 400) throw new Error(res.data?.error || this.$t('messages.saveFailed'));
await this.loadStageConfig();
- this.stageConfig.success = 'Gespeichert.';
+ this.stageConfig.success = this.$t('common.saved');
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
@@ -685,13 +1950,719 @@ export default {
}
const res = await apiClient.post('/tournament/stages/advance', payload);
- if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Erstellen der Runde');
- this.stageConfig.success = `Runde ${toStageIndex} wurde erstellt.`;
+ if (res.status >= 400) throw new Error(res.data?.error || this.$t('tournaments.errorCreatingGroups'));
+ this.stageConfig.success = this.$t('tournaments.stageParticipantsTransferred', {
+ round: this.$t(toStageIndex === 2 ? 'tournaments.intermediateRound' : 'tournaments.finalRound')
+ });
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
}
+ },
+ beforeUnmount() {
+ if (this.highlightedRuleTimer) {
+ clearTimeout(this.highlightedRuleTimer);
+ }
}
};
+
diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue
index 83062d4d..a3e0e06a 100644
--- a/frontend/src/components/tournament/TournamentGroupsTab.vue
+++ b/frontend/src/components/tournament/TournamentGroupsTab.vue
@@ -8,96 +8,110 @@
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
-
- {{ $t('tournaments.advancersPerGroup') }}:
-
-
-
- {{ $t('tournaments.maxGroupSize') }}:
-
-
-
-
-
{{ $t('tournaments.groupsPerClass') }}
-
{{ $t('tournaments.groupsPerClassHint') }}
-
-
-
- {{ $t('tournaments.numberOfGroups') }}:
-
-
-
-
-
-
-
-
-
-
{{ $t('tournaments.mergeClasses') || 'Klassen zusammenlegen (gemeinsame Gruppenphase)' }}
-
-
- {{ $t('tournaments.sourceClass') || 'Quelle' }}:
-
-
-
- {{ $t('tournaments.targetClass') || 'Ziel' }}:
-
-
-
-
- {{ $t('tournaments.strategy') || 'Strategie' }}:
-
-
-
-
- {{ mergeOutOfCompetitionLabel }}
-
-
-
-
-
+
+
+
+
-
-
+
-
+
@@ -298,7 +312,7 @@ export default {
},
filterMatchesByClass(matches) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
- if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
+ if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') {
return matches;
}
// Wenn "Ohne Klasse" ausgewählt ist
@@ -320,7 +334,7 @@ export default {
},
shouldShowClass(classId) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
- if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
+ if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') {
return true;
}
// Wenn "Ohne Klasse" ausgewählt ist
@@ -501,29 +515,122 @@ export default {
+@media (max-width: 900px) {
+ .groups-card-header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
+
diff --git a/frontend/src/components/tournament/TournamentParticipantsTab.vue b/frontend/src/components/tournament/TournamentParticipantsTab.vue
index eddbe135..536bdbdc 100644
--- a/frontend/src/components/tournament/TournamentParticipantsTab.vue
+++ b/frontend/src/components/tournament/TournamentParticipantsTab.vue
@@ -9,11 +9,106 @@
/>
{{ $t('tournaments.participants') }}
+
+ {{ allParticipantsCount }} {{ $t('tournaments.participants') }}
+
+ {{ unassignedParticipantsCount }} {{ $t('tournaments.withoutClass') }}
+
+
+ {{ participantsWithConflictsCount }} {{ $t('tournaments.participantConflicts') }}
+
+
+ {{ unpairedDoublesCount }} {{ $t('tournaments.unpairedDoubles') }}
+
+
+ {{ externalParticipantsCount }} {{ $t('tournaments.external') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('tournaments.withoutClass') }}:
+ {{ $t('tournaments.participantsNeedClassAssignment', { count: unassignedParticipantsCount }) }}
+
+ {{ $t('tournaments.autoAssignableParticipantsHint', { count: autoAssignableParticipantsCount }) }}
+
+
+
+
+
+
+ {{ $t('tournaments.assignmentReviewTitle', { count: ambiguousAssignableParticipants.length }) }}
+
+
+
+
{{ participantDisplayName(entry.participant) }}
+
+
+
+
+
+
+
-
-
{{ $t('tournaments.addClubMember') }}
+
+ {{ $t('tournaments.addClubMember') }}
-
-
-
{{ $t('tournaments.addExternalParticipant') }}
+
+
+ {{ $t('tournaments.addExternalParticipant') }}
-
+