refactor(MembersView, TeamManagementView, ScheduleView, OPTIMIZATION_TODO): componentize views and update optimization tasks

- Completed the componentization of `MembersView`, `TeamManagementView`, and `ScheduleView` to improve maintainability and reduce file sizes.
- Extracted specific sections into dedicated components, enhancing the focus on core functionalities within each view.
- Updated the optimization documentation to reflect the completion of these tasks, ensuring clarity on the current state of the project.
This commit is contained in:
Torsten Schulz (local)
2026-03-17 16:46:33 +01:00
parent 414c5ccee5
commit b7b40f5a9b
7 changed files with 1153 additions and 496 deletions

View File

@@ -69,27 +69,36 @@ Diese Liste beschreibt die naechsten sinnvollen Optimierungsschritte nach dem zu
## Prioritaet C
- [ ] `MembersView.vue` weiter komponentisieren.
- [x] `MembersView.vue` weiter komponentisieren.
Grund: Die View ist mit ~134 KB weiterhin sehr gross, obwohl die UX bereits stark verbessert wurde.
Ziel:
- Tabellenbereich
- Preview-Bereich
- Bulk-/Exportbereich
jeweils sauber trennen
Erledigt am 2026-03-17:
- Kopf-, Filter-, Sortier- und Exportbereich in `components/members/MembersOverviewSection.vue` ausgelagert
- View fokussiert jetzt staerker auf Preview, Tabelle und Editorlogik
- [ ] `TeamManagementView.vue` weiter entdichten.
- [x] `TeamManagementView.vue` weiter entdichten.
Grund: Trotz erster Extraktion ist die View mit ~93 KB noch immer sehr umfangreich.
Ziel:
- Workspace-Sektionen weiter in eigene Komponenten ziehen
- Job-/Dokument-/MyTischtennis-Bloecke isolieren
Erledigt am 2026-03-17:
- Seitenkopf, Scheduler-Kompaktstatus und Teamliste in `components/team/TeamManagementOverview.vue` ausgelagert
- View konzentriert sich damit staerker auf den eigentlichen Team-Workspace
- [ ] `ScheduleView.vue` weiter bereinigen.
- [x] `ScheduleView.vue` weiter bereinigen.
Grund: Mit ~80 KB steckt dort weiterhin viel kombinierte UI- und Lade-/Filterlogik.
Ziel:
- Match-Tabelle
- Tabellenansicht
- Team-/Liga-Auswahl
separat machen
Erledigt am 2026-03-17:
- Kopf, Summary, Auswahlspalte und Workspace-Frame in `components/schedule/ScheduleLayoutShell.vue` ausgelagert
- PDF-Export auf den neuen Shell-Ref angepasst
## Prioritaet D

View File

@@ -0,0 +1,355 @@
<template>
<div class="members-overview">
<div class="members-header">
<div>
<h2>{{ $t('members.title') }}</h2>
<p class="members-subtitle">{{ $t('members.subtitle') }}</p>
</div>
<div class="action-buttons">
<button @click="$emit('toggle-new-member')" class="btn-primary">
{{ memberFormIsOpen ? $t('members.closeEditor') : $t('members.newMember') }}
</button>
<button @click="$emit('create-phone-list')">{{ $t('members.generatePhoneList') }}</button>
<button @click="$emit('update-ratings')" class="btn-update-ratings" :disabled="isUpdatingRatings">
{{ isUpdatingRatings ? $t('members.updating') : $t('members.updateRatings') }}
</button>
<button @click="$emit('open-transfer-dialog')" class="btn-transfer">
{{ $t('members.transferMembers') }}
</button>
</div>
</div>
<div class="members-stats-grid">
<div class="members-stat-card">
<span class="members-stat-label">{{ $t('members.activeMembers') }}</span>
<strong class="members-stat-value">{{ activeMembersCount }}</strong>
</div>
<div class="members-stat-card">
<span class="members-stat-label">{{ $t('members.testMembers') }}</span>
<strong class="members-stat-value">{{ testMembersCount }}</strong>
</div>
<div class="members-stat-card">
<span class="members-stat-label">{{ $t('members.inactiveMembers') }}</span>
<strong class="members-stat-value">{{ inactiveMembersCount }}</strong>
</div>
<div class="members-stat-card members-stat-card-accent">
<span class="members-stat-label">{{ $t('members.visibleMembers') }}</span>
<strong class="members-stat-value">{{ visibleMembersCount }}</strong>
</div>
</div>
<div class="filters-section">
<div class="members-filter-topline">
<div class="member-search-group">
<label for="member-search-input">{{ $t('members.search') }}</label>
<input
id="member-search-input"
:value="searchQuery"
type="search"
class="member-search-input"
:placeholder="$t('members.searchPlaceholder')"
@input="$emit('update:search-query', $event.target.value.trim())"
>
</div>
<div class="members-scope-buttons" role="tablist" :aria-label="$t('members.memberScope')">
<button
v-for="scope in memberScopeOptions"
:key="scope.value"
type="button"
class="scope-chip"
:class="{ 'scope-chip-active': selectedMemberScope === scope.value }"
@click="$emit('update:selected-member-scope', scope.value)"
>
{{ scope.label }} <span class="scope-chip-count">{{ scope.count }}</span>
</button>
</div>
</div>
<div class="filter-controls">
<div class="checkbox-group">
<label class="checkbox-item">
<input
type="checkbox"
:checked="showInactiveMembers"
@change="$emit('update:show-inactive-members', $event.target.checked)"
>
<span>{{ $t('members.showInactiveMembers') }}</span>
</label>
</div>
<div class="filter-group">
<label>{{ $t('members.ageGroup') }}:</label>
<select :value="selectedAgeGroup" class="filter-select" @change="$emit('update:selected-age-group', $event.target.value)">
<option value="">{{ $t('common.all') }}</option>
<option value="adult">{{ $t('members.adults') }}</option>
<option value="J19">{{ $t('members.j19') }}</option>
<option value="J17">{{ $t('members.j17') }}</option>
<option value="J15">{{ $t('members.j15') }}</option>
<option value="J13">{{ $t('members.j13') }}</option>
<option value="J11">{{ $t('members.j11') }}</option>
</select>
</div>
<div class="filter-group">
<label>{{ $t('members.gender') }}:</label>
<select :value="selectedGender" class="filter-select" @change="$emit('update:selected-gender', $event.target.value)">
<option value="">{{ $t('common.all') }}</option>
<option value="female">{{ $t('members.genderFemale') }}</option>
<option value="male">{{ $t('members.genderMale') }}</option>
<option value="diverse">{{ $t('members.genderDiverse') }}</option>
<option value="unknown">{{ $t('members.genderUnknown') }}</option>
</select>
</div>
<button @click="$emit('clear-filters')" class="btn-clear-filters">{{ $t('members.clearFilters') }}</button>
</div>
</div>
<div class="members-results-bar">
<div class="members-results-copy">
<strong>{{ visibleMembersCount }}</strong> {{ $t('members.resultsVisible') }}
</div>
<div class="members-results-actions">
<label class="members-sort-group">
<span>{{ $t('members.sortBy') }}</span>
<select :value="selectedSort" class="filter-select" @change="$emit('update:selected-sort', $event.target.value)">
<option value="lastName">{{ $t('members.sortLastName') }}</option>
<option value="firstName">{{ $t('members.sortFirstName') }}</option>
<option value="birthday">{{ $t('members.sortBirthday') }}</option>
<option value="age">{{ $t('members.sortAge') }}</option>
<option value="lastTraining">{{ $t('members.sortLastTraining') }}</option>
<option value="openTasks">{{ $t('members.sortOpenTasks') }}</option>
</select>
</label>
<button type="button" class="member-icon-button" :title="$t('members.toggleSortDirection')" @click="$emit('toggle-sort-direction')">
{{ sortDirection === 'asc' ? '' : '' }}
</button>
<button type="button" @click="$emit('create-phone-list-for-filtered')" :disabled="visibleMembersCount === 0">
{{ $t('members.phoneListForSelection') }}
</button>
<button type="button" @click="$emit('mark-filtered-forms-handed-over')" :disabled="filteredMembersWithoutFormCount === 0">
{{ $t('members.markFormsForSelection') }}
</button>
<button type="button" @click="$emit('mark-filtered-as-regular')" :disabled="filteredTestMembersCount === 0">
{{ $t('members.markRegularForSelection') }}
</button>
<div class="members-results-hint">{{ $t('members.editHint') }}</div>
</div>
</div>
<div class="members-export-preview">
<div class="members-export-card">
<span class="members-export-label">{{ $t('members.exportPreview') }}</span>
<strong class="members-export-value">{{ visibleMembersCount }}</strong>
<span class="members-export-copy">{{ $t('members.exportMembersSelected') }}</span>
<div class="members-export-actions">
<button type="button" @click="$emit('export-filtered-members-csv')" :disabled="visibleMembersCount === 0">
{{ $t('members.exportCsv') }}
</button>
</div>
</div>
<div class="members-export-card">
<span class="members-export-label">{{ $t('members.exportPhones') }}</span>
<strong class="members-export-value">{{ filteredMembersWithPhonesCount }}</strong>
<span class="members-export-copy">{{ $t('members.exportReachableByPhone') }}</span>
<div class="members-export-actions">
<button type="button" @click="$emit('copy-filtered-phones')" :disabled="filteredMembersWithPhonesCount === 0">
{{ $t('members.copyPhones') }}
</button>
</div>
</div>
<div class="members-export-card">
<span class="members-export-label">{{ $t('members.exportEmails') }}</span>
<strong class="members-export-value">{{ filteredMembersWithEmailsCount }}</strong>
<span class="members-export-copy">{{ $t('members.exportReachableByEmail') }}</span>
<div class="members-export-actions">
<button type="button" @click="$emit('copy-filtered-emails')" :disabled="filteredMembersWithEmailsCount === 0">
{{ $t('members.copyEmails') }}
</button>
<button type="button" @click="$emit('compose-email-to-filtered')" :disabled="filteredMembersWithEmailsCount === 0">
{{ $t('members.composeEmail') }}
</button>
</div>
</div>
<div class="members-export-card members-export-card-wide">
<span class="members-export-label">{{ $t('members.exportPreviewNames') }}</span>
<span class="members-export-copy">
{{ exportPreviewNames.length ? exportPreviewNames.join(', ') : $t('members.exportPreviewEmpty') }}
</span>
<div class="members-export-actions">
<button type="button" @click="$emit('copy-filtered-contact-summary')" :disabled="visibleMembersCount === 0">
{{ $t('members.copyContactSummary') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'MembersOverviewSection',
props: {
memberFormIsOpen: { type: Boolean, required: true },
isUpdatingRatings: { type: Boolean, required: true },
activeMembersCount: { type: Number, required: true },
testMembersCount: { type: Number, required: true },
inactiveMembersCount: { type: Number, required: true },
visibleMembersCount: { type: Number, required: true },
searchQuery: { type: String, required: true },
memberScopeOptions: { type: Array, required: true },
selectedMemberScope: { type: String, required: true },
showInactiveMembers: { type: Boolean, required: true },
selectedAgeGroup: { type: String, required: true },
selectedGender: { type: String, required: true },
selectedSort: { type: String, required: true },
sortDirection: { type: String, required: true },
filteredMembersWithoutFormCount: { type: Number, required: true },
filteredTestMembersCount: { type: Number, required: true },
filteredMembersWithPhonesCount: { type: Number, required: true },
filteredMembersWithEmailsCount: { type: Number, required: true },
exportPreviewNames: { type: Array, required: true }
},
emits: [
'toggle-new-member',
'create-phone-list',
'update-ratings',
'open-transfer-dialog',
'update:search-query',
'update:selected-member-scope',
'update:show-inactive-members',
'update:selected-age-group',
'update:selected-gender',
'clear-filters',
'update:selected-sort',
'toggle-sort-direction',
'create-phone-list-for-filtered',
'mark-filtered-forms-handed-over',
'mark-filtered-as-regular',
'export-filtered-members-csv',
'copy-filtered-phones',
'copy-filtered-emails',
'compose-email-to-filtered',
'copy-filtered-contact-summary'
]
};
</script>
<style scoped>
.members-overview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.members-header,
.members-filter-topline,
.members-results-bar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.members-subtitle {
margin: 0.2rem 0 0;
color: var(--text-muted);
}
.action-buttons,
.filter-controls,
.members-results-actions,
.members-export-actions,
.members-scope-buttons {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: center;
}
.members-stats-grid,
.members-export-preview {
display: grid;
gap: 0.85rem;
}
.members-stats-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.members-export-preview {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.members-stat-card,
.members-export-card,
.filters-section,
.members-results-bar {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--background-light);
padding: 0.85rem;
}
.members-stat-card-accent {
background: linear-gradient(135deg, var(--primary-soft), var(--background-light));
}
.members-stat-label,
.members-export-label,
.members-results-hint {
font-size: 0.8rem;
color: var(--text-muted);
}
.members-stat-value,
.members-export-value {
display: block;
margin-top: 0.2rem;
font-size: 1.3rem;
}
.member-search-group,
.members-sort-group,
.filter-group,
.checkbox-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.member-search-group {
flex: 1 1 18rem;
}
.member-search-input,
.filter-select {
min-width: 11rem;
}
.scope-chip {
border: 1px solid var(--border-color);
border-radius: 999px;
padding: 0.4rem 0.7rem;
background: white;
color: var(--text-color);
}
.scope-chip-active {
background: var(--primary-light);
color: var(--primary-dark);
border-color: var(--primary-color);
}
.scope-chip-count {
opacity: 0.75;
}
.members-export-card-wide {
grid-column: 1 / -1;
}
@media (max-width: 900px) {
.members-results-actions {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,332 @@
<template>
<div class="schedule-shell">
<div class="schedule-page-header">
<div class="schedule-page-title">
<h2>{{ $t('schedule.title') }}</h2>
<p>{{ $t('schedule.subtitle') }}</p>
</div>
<div class="schedule-page-actions">
<SeasonSelector
:model-value="selectedSeasonId"
:show-current-season="true"
@update:model-value="$emit('update:selected-season-id', $event)"
@season-change="$emit('season-change', $event)"
/>
<button @click="$emit('open-import-modal')">{{ $t('schedule.importSchedule') }}</button>
<button
v-if="showGalleryButton"
@click="$emit('open-gallery-dialog')"
class="btn-secondary"
:disabled="galleryLoading"
>
{{ galleryLoading ? $t('schedule.galleryLoading') : $t('schedule.gallery') }}
</button>
</div>
</div>
<div v-if="hoveredMatch && hoveredMatch.location" class="hover-info">
<p><strong>{{ hoveredMatch.location.name || 'N/A' }}</strong></p>
<p>{{ hoveredMatch.location.address || 'N/A' }}</p>
<p>{{ hoveredMatch.location.zip || '' }} {{ hoveredMatch.location.city || 'N/A' }}</p>
</div>
<div class="schedule-summary-bar">
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.teams') }}</span>
<strong>{{ filteredScheduleTeamsCount }}/{{ teamsCount }}</strong>
</div>
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.matches') }}</span>
<strong>{{ matchesCount }}</strong>
</div>
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.completedShort') }}</span>
<strong>{{ completedMatchesCount }}</strong>
</div>
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.pendingShort') }}</span>
<strong>{{ pendingMatchesCount }}</strong>
</div>
<div class="schedule-summary-item" v-if="nextScheduledMatchLabel">
<span class="schedule-summary-label">{{ $t('schedule.nextMatch') }}</span>
<strong>{{ nextScheduledMatchLabel }}</strong>
</div>
</div>
<div class="output schedule-layout">
<aside class="schedule-sidebar">
<div class="schedule-sidebar-card">
<div class="schedule-sidebar-header">
<h3>{{ $t('schedule.selection') }}</h3>
<span class="schedule-selection-count">{{ filteredScheduleTeamsCount }}/{{ teamsCount }}</span>
</div>
<input
:value="teamSearchQuery"
type="search"
class="schedule-team-search"
:placeholder="$t('schedule.searchTeams')"
@input="$emit('update:team-search-query', $event.target.value.trim())"
/>
<div class="schedule-quick-links">
<button
type="button"
class="schedule-quick-link"
:class="{ active: selectedLeague === overallScheduleLabel }"
@click="$emit('load-all-matches')"
>
{{ overallScheduleLabel }}
</button>
<button
type="button"
class="schedule-quick-link"
:class="{ active: selectedLeague === adultScheduleLabel }"
@click="$emit('load-adult-matches')"
>
{{ adultScheduleLabel }}
</button>
</div>
<ul class="schedule-team-list">
<li
v-for="team in filteredScheduleTeams"
:key="team.id"
@click="$emit('load-team', team)"
:class="{ active: selectedTeam && selectedTeam.id === team.id }"
>
<span class="schedule-team-name">{{ team.name }}</span>
<span class="team-league" v-if="team.league && team.league.name">{{ team.league.name }}</span>
</li>
<li v-if="teamsCount === 0" class="no-leagues">{{ $t('schedule.noTeamsFound') }}</li>
<li v-else-if="filteredScheduleTeamsCount === 0" class="no-leagues">{{ $t('schedule.noMatchingTeams') }}</li>
</ul>
</div>
</aside>
<div class="flex-item">
<div class="schedule-workspace-header">
<div>
<span class="workspace-eyebrow">{{ $t('schedule.activeSelection') }}</span>
<h3 v-if="selectedLeague">{{ selectedLeague }}</h3>
<h3 v-else>{{ $t('schedule.noSelectionTitle') }}</h3>
<p v-if="selectedLeague">{{ workspaceDescription }}</p>
<p v-else>{{ $t('schedule.noSelectionMessage') }}</p>
</div>
<div v-if="selectedLeague" class="schedule-workspace-actions">
<button
v-if="activeTab === 'schedule' && selectedTeam"
type="button"
class="btn-secondary"
@click="$emit('fetch-team-data-manually')"
:disabled="fetchingTeamData"
>
{{ fetchingTeamData ? $t('schedule.fetchingTeamData') : $t('schedule.fetchTeamData') }}
</button>
<button v-if="activeTab === 'schedule'" @click="$emit('generate-pdf')">{{ $t('schedule.downloadPDF') }}</button>
<button
v-if="activeTab === 'table' && selectedTeam"
type="button"
class="btn-secondary"
@click="$emit('fetch-table')"
:disabled="fetchingTable"
>
{{ fetchingTable ? $t('schedule.tableLoading') : $t('schedule.refreshTable') }}
</button>
</div>
</div>
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-navigation">
<button :class="['tab-button', { active: activeTab === 'schedule' }]" @click="$emit('update:active-tab', 'schedule')">
📅 {{ $t('schedule.scheduleTab') }} <span class="tab-count">{{ matchesCount }}</span>
</button>
<button :class="['tab-button', { active: activeTab === 'table' }]" @click="$emit('update:active-tab', 'table')">
📊 {{ $t('schedule.tableTab') }} <span class="tab-count">{{ tableCount }}</span>
</button>
</div>
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-content">
<div v-show="activeTab === 'schedule'" class="tab-panel">
<slot name="schedule-panel" />
</div>
<div v-show="activeTab === 'table'" class="tab-panel">
<slot name="table-panel" />
</div>
</div>
<div v-else class="schedule-empty-state">
<h3>{{ $t('schedule.noSelectionTitle') }}</h3>
<p>{{ $t('schedule.noSelectionMessage') }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
import SeasonSelector from '../SeasonSelector.vue';
export default {
name: 'ScheduleLayoutShell',
components: { SeasonSelector },
props: {
selectedSeasonId: { type: [Number, String, null], default: null },
showGalleryButton: { type: Boolean, required: true },
galleryLoading: { type: Boolean, required: true },
hoveredMatch: { type: Object, default: null },
filteredScheduleTeamsCount: { type: Number, required: true },
teamsCount: { type: Number, required: true },
matchesCount: { type: Number, required: true },
completedMatchesCount: { type: Number, required: true },
pendingMatchesCount: { type: Number, required: true },
nextScheduledMatchLabel: { type: String, default: '' },
teamSearchQuery: { type: String, required: true },
overallScheduleLabel: { type: String, required: true },
adultScheduleLabel: { type: String, required: true },
selectedLeague: { type: String, default: '' },
filteredScheduleTeams: { type: Array, required: true },
selectedTeam: { type: Object, default: null },
workspaceDescription: { type: String, default: '' },
activeTab: { type: String, required: true },
tableCount: { type: Number, required: true },
fetchingTeamData: { type: Boolean, required: true },
fetchingTable: { type: Boolean, required: true }
},
emits: [
'update:selected-season-id',
'season-change',
'open-import-modal',
'open-gallery-dialog',
'update:team-search-query',
'load-all-matches',
'load-adult-matches',
'load-team',
'fetch-team-data-manually',
'generate-pdf',
'fetch-table',
'update:active-tab'
]
};
</script>
<style scoped>
.schedule-shell {
display: flex;
flex-direction: column;
gap: 1rem;
}
.schedule-page-header,
.schedule-summary-bar,
.schedule-sidebar-card,
.schedule-workspace-header {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--background-light);
padding: 0.9rem;
}
.schedule-page-header,
.schedule-page-actions,
.schedule-summary-bar,
.schedule-sidebar-header,
.schedule-workspace-header,
.schedule-quick-links {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.schedule-page-header,
.schedule-workspace-header,
.schedule-sidebar-header {
justify-content: space-between;
align-items: flex-start;
}
.schedule-page-title h2,
.schedule-workspace-header h3 {
margin: 0;
}
.schedule-page-title p,
.schedule-workspace-header p {
margin: 0.25rem 0 0;
color: var(--text-muted);
}
.schedule-summary-item {
min-width: 120px;
}
.schedule-summary-label,
.workspace-eyebrow {
font-size: 0.8rem;
color: var(--text-muted);
}
.schedule-layout {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.schedule-sidebar {
width: 280px;
flex: 0 0 280px;
}
.schedule-team-list {
list-style: none;
padding: 0;
margin: 0.75rem 0 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.schedule-team-list li {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.55rem 0.7rem;
border-radius: 0.7rem;
border: 1px solid transparent;
background: white;
cursor: pointer;
}
.schedule-team-list li.active {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.schedule-team-search {
width: 100%;
}
.schedule-quick-link {
border: 1px solid var(--border-color);
border-radius: 999px;
background: white;
padding: 0.4rem 0.75rem;
}
.schedule-quick-link.active {
background: var(--primary-light);
border-color: var(--primary-color);
color: var(--primary-dark);
}
.flex-item {
flex: 1;
min-width: 0;
}
@media (max-width: 960px) {
.schedule-layout {
flex-direction: column;
}
.schedule-sidebar {
width: 100%;
flex-basis: auto;
}
}
</style>

View File

@@ -0,0 +1,304 @@
<template>
<div class="team-management-overview">
<div class="team-management-hero">
<div class="team-management-hero-header">
<div class="team-management-hero-copy">
<h2>{{ $t('teamManagement.title') }}</h2>
<p>{{ $t('teamManagement.subtitle') }}</p>
</div>
<div class="team-management-hero-actions">
<div class="team-management-season-selector">
<span class="team-management-hero-label">{{ $t('teamManagement.season') }}</span>
<SeasonSelector
:model-value="selectedSeasonId"
:show-current-season="true"
@update:model-value="$emit('update:selected-season-id', $event)"
@season-change="$emit('season-change', $event)"
/>
</div>
</div>
</div>
<div v-if="schedulerJobs.rating_updates || schedulerJobs.match_results" class="scheduler-jobs-summary">
<div class="scheduler-jobs-summary-copy">
<strong>{{ $t('teamManagement.automaticJobs') }}</strong>
<span v-if="schedulerJobs.rating_updates?.lastRun || schedulerJobs.match_results?.lastRun" class="scheduler-jobs-summary-text">
{{ $t('teamManagement.lastRun') }}:
{{ formatJobDate(schedulerJobs.match_results?.lastRun || schedulerJobs.rating_updates?.lastRun) }}
</span>
</div>
<button type="button" class="scheduler-jobs-toggle" @click="$emit('toggle-job-details')">
<span class="scheduler-jobs-toggle-icon">{{ showGlobalJobDetails ? '▾' : '▸' }}</span>
<span class="scheduler-jobs-toggle-text">{{ showGlobalJobDetails ? $t('common.close') : $t('common.details') }}</span>
</button>
</div>
<div class="team-management-summary">
<div class="team-summary-card">
<span class="team-summary-label">{{ $t('teamManagement.teams') }}</span>
<strong class="team-summary-value">{{ teamsCount }}</strong>
</div>
<div class="team-summary-card">
<span class="team-summary-label">{{ $t('teamManagement.fullyConfigured') }}</span>
<strong class="team-summary-value">{{ fullyConfiguredTeamsCount }}</strong>
</div>
<div class="team-summary-card">
<span class="team-summary-label">{{ $t('teamManagement.partiallyConfigured') }}</span>
<strong class="team-summary-value">{{ partiallyConfiguredTeamsCount }}</strong>
</div>
<div class="team-summary-card">
<span class="team-summary-label">{{ $t('teamManagement.noLeague') }}</span>
<strong class="team-summary-value">{{ teamsWithoutLeagueCount }}</strong>
</div>
</div>
</div>
<div v-if="showGlobalJobDetails && (schedulerJobs.rating_updates || schedulerJobs.match_results)" class="scheduler-jobs-info">
<div class="job-info" v-if="schedulerJobs.rating_updates?.lastRun">
<span class="job-label">🔄 {{ $t('teamManagement.ratingUpdates') }}:</span>
<span class="job-details">
{{ $t('teamManagement.lastRun') }}: {{ formatJobDate(schedulerJobs.rating_updates.lastRun) }}
<span v-if="schedulerJobs.rating_updates.updatedCount !== null" class="job-count">
({{ schedulerJobs.rating_updates.updatedCount }} {{ $t('teamManagement.updated') }})
</span>
<span v-if="!schedulerJobs.rating_updates.success" class="job-error"> {{ $t('teamManagement.error') }}</span>
</span>
</div>
<div class="job-info" v-if="schedulerJobs.match_results?.lastRun">
<div class="job-header">
<span class="job-label">📊 {{ $t('teamManagement.matchResults') }}:</span>
<span class="job-details">
{{ $t('teamManagement.lastRun') }}: {{ formatJobDate(schedulerJobs.match_results.lastRun) }}
<span v-if="schedulerJobs.match_results.fetchedCount !== null" class="job-count">
({{ schedulerJobs.match_results.fetchedCount }} {{ $t('teamManagement.fetched') }})
</span>
<span v-if="!schedulerJobs.match_results.success" class="job-error"> {{ $t('teamManagement.error') }}</span>
</span>
</div>
<div v-if="filteredTeamDetails.length > 0" class="team-details">
<div v-for="team in filteredTeamDetails" :key="team.clubTeamId" class="team-detail-item">
<span class="team-name">{{ team.teamName }}</span>
<span v-if="team.success" class="team-status success"></span>
<span v-else class="team-status error"></span>
</div>
</div>
</div>
</div>
<div class="teams-list">
<div class="teams-list-header">
<h3>{{ $t('teamManagement.teams') }} ({{ filteredTeams.length }}) - {{ $t('teamManagement.season') }} {{ currentSeasonLabel || $t('teamManagement.seasonUnknown') }}</h3>
<div class="teams-list-tools">
<input
:value="teamSearchQuery"
type="search"
class="team-search-input"
:placeholder="$t('teamManagement.searchTeams')"
@input="$emit('update:team-search-query', $event.target.value)"
>
<div class="team-filter-chips">
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'all' }" @click="$emit('update:team-filter', 'all')">{{ $t('teamManagement.filterAll') }}</button>
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'configured' }" @click="$emit('update:team-filter', 'configured')">{{ $t('teamManagement.filterConfigured') }}</button>
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'attention' }" @click="$emit('update:team-filter', 'attention')">{{ $t('teamManagement.filterNeedsAttention') }}</button>
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'noLeague' }" @click="$emit('update:team-filter', 'noLeague')">{{ $t('teamManagement.filterNoLeague') }}</button>
</div>
<button type="button" class="btn-primary team-create-button" @click="$emit('create-new-team')">
{{ $t('teamManagement.createNewTeam') }}
</button>
</div>
</div>
<div v-if="teamsCount === 0" class="no-teams">
<p>{{ $t('teamManagement.noTeamsYet') }}</p>
</div>
<div v-else-if="filteredTeams.length === 0" class="no-teams">
<p>{{ $t('teamManagement.noMatchingTeams') }}</p>
</div>
<div v-else class="teams-grid">
<TeamListCard
v-for="team in filteredTeams"
:key="team.id"
:team="team"
:active="activeTeamId === team.id"
:status="getMyTischtennisStatus(team)"
:league-name="team.league ? team.league.name : $t('teamManagement.noLeague')"
:season-label="team.season?.season || $t('teamManagement.unknown')"
:created-text="formatDate(team.createdAt)"
:last-updated-text="getTeamJobInfo(team) && getTeamJobInfo(team).lastRun ? formatJobDate(getTeamJobInfo(team).lastRun) : $t('teamManagement.never')"
:has-code-list="getTeamDocuments(team.id, 'code_list').length > 0"
:has-pin-list="getTeamDocuments(team.id, 'pin_list').length > 0"
@edit="$emit('edit-team', team)"
@delete="$emit('delete-team', team)"
@show-code-list="$emit('show-pdf-dialog', team.id, 'code_list')"
@show-pin-list="$emit('show-pdf-dialog', team.id, 'pin_list')"
/>
</div>
</div>
</div>
</template>
<script>
import SeasonSelector from '../SeasonSelector.vue';
import TeamListCard from './TeamListCard.vue';
export default {
name: 'TeamManagementOverview',
components: {
SeasonSelector,
TeamListCard
},
props: {
selectedSeasonId: { type: [Number, String, null], default: null },
schedulerJobs: { type: Object, required: true },
showGlobalJobDetails: { type: Boolean, required: true },
teamsCount: { type: Number, required: true },
fullyConfiguredTeamsCount: { type: Number, required: true },
partiallyConfiguredTeamsCount: { type: Number, required: true },
teamsWithoutLeagueCount: { type: Number, required: true },
filteredTeamDetails: { type: Array, required: true },
formatJobDate: { type: Function, required: true },
filteredTeams: { type: Array, required: true },
currentSeasonLabel: { type: String, default: '' },
teamSearchQuery: { type: String, required: true },
teamFilter: { type: String, required: true },
activeTeamId: { type: [Number, null], default: null },
getMyTischtennisStatus: { type: Function, required: true },
formatDate: { type: Function, required: true },
getTeamJobInfo: { type: Function, required: true },
getTeamDocuments: { type: Function, required: true }
},
emits: [
'update:selected-season-id',
'season-change',
'toggle-job-details',
'update:team-search-query',
'update:team-filter',
'create-new-team',
'edit-team',
'delete-team',
'show-pdf-dialog'
]
};
</script>
<style scoped>
.team-management-overview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.team-management-hero,
.scheduler-jobs-info,
.teams-list {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--background-light);
padding: 0.9rem;
}
.team-management-hero-header,
.scheduler-jobs-summary,
.teams-list-header,
.teams-list-tools,
.team-management-summary {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.team-management-hero-header,
.scheduler-jobs-summary,
.teams-list-header {
justify-content: space-between;
align-items: flex-start;
}
.team-management-hero-copy h2,
.teams-list-header h3 {
margin: 0;
}
.team-management-hero-copy p {
margin: 0.25rem 0 0;
color: var(--text-muted);
}
.team-summary-card {
min-width: 150px;
flex: 1 1 150px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: white;
padding: 0.75rem;
}
.team-summary-label,
.scheduler-jobs-summary-text {
color: var(--text-muted);
font-size: 0.85rem;
}
.team-summary-value {
display: block;
margin-top: 0.2rem;
}
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.85rem;
}
.team-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.team-filter-chip {
border: 1px solid var(--border-color);
background: white;
border-radius: 999px;
padding: 0.4rem 0.75rem;
}
.team-filter-chip.active {
background: var(--primary-light);
border-color: var(--primary-color);
color: var(--primary-dark);
}
.team-search-input {
min-width: 14rem;
}
.job-info {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.team-details {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.team-detail-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: white;
border: 1px solid var(--border-color);
}
@media (max-width: 900px) {
.teams-list-tools {
width: 100%;
}
}
</style>

View File

@@ -1,180 +1,46 @@
<template>
<div class="members-page">
<div class="members-header">
<div>
<h2>{{ $t('members.title') }}</h2>
<p class="members-subtitle">{{ $t('members.subtitle') }}</p>
</div>
<div class="action-buttons">
<button @click="toggleNewMember" class="btn-primary">
{{ memberFormIsOpen ? $t('members.closeEditor') : $t('members.newMember') }}
</button>
<button @click="createPhoneList">{{ $t('members.generatePhoneList') }}</button>
<button @click="updateRatingsFromMyTischtennis" class="btn-update-ratings" :disabled="isUpdatingRatings">
{{ isUpdatingRatings ? $t('members.updating') : $t('members.updateRatings') }}
</button>
<button @click="openTransferDialog" class="btn-transfer">
{{ $t('members.transferMembers') }}
</button>
</div>
</div>
<div class="members-stats-grid">
<div class="members-stat-card">
<span class="members-stat-label">{{ $t('members.activeMembers') }}</span>
<strong class="members-stat-value">{{ activeMembersCount }}</strong>
</div>
<div class="members-stat-card">
<span class="members-stat-label">{{ $t('members.testMembers') }}</span>
<strong class="members-stat-value">{{ testMembersCount }}</strong>
</div>
<div class="members-stat-card">
<span class="members-stat-label">{{ $t('members.inactiveMembers') }}</span>
<strong class="members-stat-value">{{ inactiveMembersCount }}</strong>
</div>
<div class="members-stat-card members-stat-card-accent">
<span class="members-stat-label">{{ $t('members.visibleMembers') }}</span>
<strong class="members-stat-value">{{ filteredMembers.length }}</strong>
</div>
</div>
<div class="filters-section">
<div class="members-filter-topline">
<div class="member-search-group">
<label for="member-search-input">{{ $t('members.search') }}</label>
<input
id="member-search-input"
v-model.trim="searchQuery"
type="search"
class="member-search-input"
:placeholder="$t('members.searchPlaceholder')"
>
</div>
<div class="members-scope-buttons" role="tablist" :aria-label="$t('members.memberScope')">
<button
v-for="scope in memberScopeOptions"
:key="scope.value"
type="button"
class="scope-chip"
:class="{ 'scope-chip-active': selectedMemberScope === scope.value }"
@click="selectedMemberScope = scope.value"
>
{{ scope.label }} <span class="scope-chip-count">{{ scope.count }}</span>
</button>
</div>
</div>
<div class="filter-controls">
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" v-model="showInactiveMembers">
<span>{{ $t('members.showInactiveMembers') }}</span>
</label>
</div>
<div class="filter-group">
<label>{{ $t('members.ageGroup') }}:</label>
<select v-model="selectedAgeGroup" class="filter-select">
<option value="">{{ $t('common.all') }}</option>
<option value="adult">{{ $t('members.adults') }}</option>
<option value="J19">{{ $t('members.j19') }}</option>
<option value="J17">{{ $t('members.j17') }}</option>
<option value="J15">{{ $t('members.j15') }}</option>
<option value="J13">{{ $t('members.j13') }}</option>
<option value="J11">{{ $t('members.j11') }}</option>
</select>
</div>
<div class="filter-group">
<label>{{ $t('members.gender') }}:</label>
<select v-model="selectedGender" class="filter-select">
<option value="">{{ $t('common.all') }}</option>
<option value="female">{{ $t('members.genderFemale') }}</option>
<option value="male">{{ $t('members.genderMale') }}</option>
<option value="diverse">{{ $t('members.genderDiverse') }}</option>
<option value="unknown">{{ $t('members.genderUnknown') }}</option>
</select>
</div>
<button @click="clearFilters" class="btn-clear-filters">{{ $t('members.clearFilters') }}</button>
</div>
</div>
<div class="members-results-bar">
<div class="members-results-copy">
<strong>{{ filteredMembers.length }}</strong> {{ $t('members.resultsVisible') }}
</div>
<div class="members-results-actions">
<label class="members-sort-group">
<span>{{ $t('members.sortBy') }}</span>
<select v-model="selectedSort" class="filter-select">
<option value="lastName">{{ $t('members.sortLastName') }}</option>
<option value="firstName">{{ $t('members.sortFirstName') }}</option>
<option value="birthday">{{ $t('members.sortBirthday') }}</option>
<option value="age">{{ $t('members.sortAge') }}</option>
<option value="lastTraining">{{ $t('members.sortLastTraining') }}</option>
<option value="openTasks">{{ $t('members.sortOpenTasks') }}</option>
</select>
</label>
<button type="button" class="member-icon-button" :title="$t('members.toggleSortDirection')" @click="toggleSortDirection">
{{ sortDirection === 'asc' ? '' : '' }}
</button>
<button type="button" @click="createPhoneListForFiltered" :disabled="filteredMembers.length === 0">
{{ $t('members.phoneListForSelection') }}
</button>
<button type="button" @click="markFilteredFormsHandedOver" :disabled="filteredMembersWithoutForm.length === 0">
{{ $t('members.markFormsForSelection') }}
</button>
<button type="button" @click="markFilteredAsRegular" :disabled="filteredTestMembers.length === 0">
{{ $t('members.markRegularForSelection') }}
</button>
<div class="members-results-hint">{{ $t('members.editHint') }}</div>
</div>
</div>
<div class="members-export-preview">
<div class="members-export-card">
<span class="members-export-label">{{ $t('members.exportPreview') }}</span>
<strong class="members-export-value">{{ filteredMembers.length }}</strong>
<span class="members-export-copy">{{ $t('members.exportMembersSelected') }}</span>
<div class="members-export-actions">
<button type="button" @click="exportFilteredMembersCsv" :disabled="filteredMembers.length === 0">
{{ $t('members.exportCsv') }}
</button>
</div>
</div>
<div class="members-export-card">
<span class="members-export-label">{{ $t('members.exportPhones') }}</span>
<strong class="members-export-value">{{ filteredMembersWithPhonesCount }}</strong>
<span class="members-export-copy">{{ $t('members.exportReachableByPhone') }}</span>
<div class="members-export-actions">
<button type="button" @click="copyFilteredPhones" :disabled="filteredMembersWithPhonesCount === 0">
{{ $t('members.copyPhones') }}
</button>
</div>
</div>
<div class="members-export-card">
<span class="members-export-label">{{ $t('members.exportEmails') }}</span>
<strong class="members-export-value">{{ filteredMembersWithEmailsCount }}</strong>
<span class="members-export-copy">{{ $t('members.exportReachableByEmail') }}</span>
<div class="members-export-actions">
<button type="button" @click="copyFilteredEmails" :disabled="filteredMembersWithEmailsCount === 0">
{{ $t('members.copyEmails') }}
</button>
<button type="button" @click="composeEmailToFiltered" :disabled="filteredMembersWithEmailsCount === 0">
{{ $t('members.composeEmail') }}
</button>
</div>
</div>
<div class="members-export-card members-export-card-wide">
<span class="members-export-label">{{ $t('members.exportPreviewNames') }}</span>
<span class="members-export-copy">
{{ exportPreviewNames.length ? exportPreviewNames.join(', ') : $t('members.exportPreviewEmpty') }}
</span>
<div class="members-export-actions">
<button type="button" @click="copyFilteredContactSummary" :disabled="filteredMembers.length === 0">
{{ $t('members.copyContactSummary') }}
</button>
</div>
</div>
</div>
<MembersOverviewSection
:member-form-is-open="memberFormIsOpen"
:is-updating-ratings="isUpdatingRatings"
:active-members-count="activeMembersCount"
:test-members-count="testMembersCount"
:inactive-members-count="inactiveMembersCount"
:visible-members-count="filteredMembers.length"
:search-query="searchQuery"
:member-scope-options="memberScopeOptions"
:selected-member-scope="selectedMemberScope"
:show-inactive-members="showInactiveMembers"
:selected-age-group="selectedAgeGroup"
:selected-gender="selectedGender"
:selected-sort="selectedSort"
:sort-direction="sortDirection"
:filtered-members-without-form-count="filteredMembersWithoutForm.length"
:filtered-test-members-count="filteredTestMembers.length"
:filtered-members-with-phones-count="filteredMembersWithPhonesCount"
:filtered-members-with-emails-count="filteredMembersWithEmailsCount"
:export-preview-names="exportPreviewNames"
@toggle-new-member="toggleNewMember"
@create-phone-list="createPhoneList"
@update-ratings="updateRatingsFromMyTischtennis"
@open-transfer-dialog="openTransferDialog"
@update:search-query="searchQuery = $event"
@update:selected-member-scope="selectedMemberScope = $event"
@update:show-inactive-members="showInactiveMembers = $event"
@update:selected-age-group="selectedAgeGroup = $event"
@update:selected-gender="selectedGender = $event"
@clear-filters="clearFilters"
@update:selected-sort="selectedSort = $event"
@toggle-sort-direction="toggleSortDirection"
@create-phone-list-for-filtered="createPhoneListForFiltered"
@mark-filtered-forms-handed-over="markFilteredFormsHandedOver"
@mark-filtered-as-regular="markFilteredAsRegular"
@export-filtered-members-csv="exportFilteredMembersCsv"
@copy-filtered-phones="copyFilteredPhones"
@copy-filtered-emails="copyFilteredEmails"
@compose-email-to-filtered="composeEmailToFiltered"
@copy-filtered-contact-summary="copyFilteredContactSummary"
/>
<div v-if="isLoadingMembers" class="members-state-banner members-state-banner-info">
{{ $t('members.loadingMembers') }}
@@ -617,6 +483,7 @@ import BaseDialog from '../components/BaseDialog.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue';
import MemberTransferDialog from '../components/MemberTransferDialog.vue';
import MembersOverviewSection from '../components/members/MembersOverviewSection.vue';
import { debounce } from '../utils/debounce.js';
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js';
export default {
@@ -628,7 +495,8 @@ export default {
BaseDialog,
MemberNotesDialog,
MemberActivitiesDialog,
MemberTransferDialog
MemberTransferDialog,
MembersOverviewSection
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'token']),

View File

@@ -1,144 +1,42 @@
<template>
<div class="schedule-view">
<div class="schedule-page-header">
<div class="schedule-page-title">
<h2>{{ $t('schedule.title') }}</h2>
<p>{{ $t('schedule.subtitle') }}</p>
</div>
<div class="schedule-page-actions">
<SeasonSelector v-model="selectedSeasonId" @season-change="onSeasonChange" :show-current-season="true" />
<button @click="openImportModal">{{ $t('schedule.importSchedule') }}</button>
<button
v-if="playerSelectionDialog.match"
@click="openGalleryDialog"
class="btn-secondary"
:disabled="galleryLoading"
>
{{ galleryLoading ? $t('schedule.galleryLoading') : $t('schedule.gallery') }}
</button>
</div>
</div>
<div v-if="hoveredMatch && hoveredMatch.location" class="hover-info">
<p><strong>{{ hoveredMatch.location.name || 'N/A' }}</strong></p>
<p>{{ hoveredMatch.location.address || 'N/A' }}</p>
<p>{{ hoveredMatch.location.zip || '' }} {{ hoveredMatch.location.city || 'N/A' }}</p>
</div>
<div class="schedule-summary-bar">
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.teams') }}</span>
<strong>{{ filteredScheduleTeams.length }}/{{ teams.length }}</strong>
</div>
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.matches') }}</span>
<strong>{{ matches.length }}</strong>
</div>
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.completedShort') }}</span>
<strong>{{ completedMatchesCount }}</strong>
</div>
<div class="schedule-summary-item">
<span class="schedule-summary-label">{{ $t('schedule.pendingShort') }}</span>
<strong>{{ pendingMatchesCount }}</strong>
</div>
<div class="schedule-summary-item" v-if="nextScheduledMatchLabel">
<span class="schedule-summary-label">{{ $t('schedule.nextMatch') }}</span>
<strong>{{ nextScheduledMatchLabel }}</strong>
</div>
</div>
<div class="output schedule-layout">
<aside class="schedule-sidebar">
<div class="schedule-sidebar-card">
<div class="schedule-sidebar-header">
<h3>{{ $t('schedule.selection') }}</h3>
<span class="schedule-selection-count">{{ filteredScheduleTeams.length }}/{{ teams.length }}</span>
</div>
<input
v-model.trim="teamSearchQuery"
type="search"
class="schedule-team-search"
:placeholder="$t('schedule.searchTeams')"
/>
<div class="schedule-quick-links">
<button
type="button"
class="schedule-quick-link"
:class="{ active: selectedLeague === $t('schedule.overallSchedule') }"
@click="loadAllMatches"
>
{{ $t('schedule.overallSchedule') }}
</button>
<button
type="button"
class="schedule-quick-link"
:class="{ active: selectedLeague === $t('schedule.adultSchedule') }"
@click="loadAdultMatches"
>
{{ $t('schedule.adultSchedule') }}
</button>
</div>
<ul class="schedule-team-list">
<li v-for="team in filteredScheduleTeams" :key="team.id" @click="loadMatchesForTeam(team)"
:class="{ active: selectedTeam && selectedTeam.id === team.id }">
<span class="schedule-team-name">{{ team.name }}</span>
<span class="team-league" v-if="team.league && team.league.name">{{ team.league.name }}</span>
</li>
<li v-if="teams.length === 0" class="no-leagues">{{ $t('schedule.noTeamsFound') }}</li>
<li v-else-if="filteredScheduleTeams.length === 0" class="no-leagues">{{ $t('schedule.noMatchingTeams') }}</li>
</ul>
</div>
</aside>
<div class="flex-item" ref="scheduleContainer">
<div class="schedule-workspace-header">
<div>
<span class="workspace-eyebrow">{{ $t('schedule.activeSelection') }}</span>
<h3 v-if="selectedLeague">{{ selectedLeague }}</h3>
<h3 v-else>{{ $t('schedule.noSelectionTitle') }}</h3>
<p v-if="selectedLeague">{{ workspaceDescription }}</p>
<p v-else>{{ $t('schedule.noSelectionMessage') }}</p>
</div>
<div v-if="selectedLeague" class="schedule-workspace-actions">
<button
v-if="activeTab === 'schedule' && selectedTeam"
type="button"
class="btn-secondary"
@click="fetchTeamDataManually"
:disabled="fetchingTeamData"
>
{{ fetchingTeamData ? $t('schedule.fetchingTeamData') : $t('schedule.fetchTeamData') }}
</button>
<button v-if="activeTab === 'schedule'" @click="generatePDF">{{ $t('schedule.downloadPDF') }}</button>
<button
v-if="activeTab === 'table' && selectedTeam"
type="button"
class="btn-secondary"
@click="fetchTableFromMyTischtennis"
:disabled="fetchingTable"
>
{{ fetchingTable ? $t('schedule.tableLoading') : $t('schedule.refreshTable') }}
</button>
</div>
</div>
<!-- Tab Navigation - nur anzeigen wenn Liga ausgewählt -->
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-navigation">
<button
:class="['tab-button', { active: activeTab === 'schedule' }]"
@click="activeTab = 'schedule'"
>
📅 {{ $t('schedule.scheduleTab') }} <span class="tab-count">{{ matches.length }}</span>
</button>
<button
:class="['tab-button', { active: activeTab === 'table' }]"
@click="activeTab = 'table'"
>
📊 {{ $t('schedule.tableTab') }} <span class="tab-count">{{ leagueTable.length }}</span>
</button>
</div>
<!-- Tab Content - nur anzeigen wenn Liga ausgewählt -->
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-content">
<!-- Spielplan Tab -->
<div v-show="activeTab === 'schedule'" class="tab-panel">
<ScheduleLayoutShell
ref="scheduleShell"
:selected-season-id="selectedSeasonId"
:show-gallery-button="Boolean(playerSelectionDialog.match)"
:gallery-loading="galleryLoading"
:hovered-match="hoveredMatch"
:filtered-schedule-teams-count="filteredScheduleTeams.length"
:teams-count="teams.length"
:matches-count="matches.length"
:completed-matches-count="completedMatchesCount"
:pending-matches-count="pendingMatchesCount"
:next-scheduled-match-label="nextScheduledMatchLabel"
:team-search-query="teamSearchQuery"
:overall-schedule-label="$t('schedule.overallSchedule')"
:adult-schedule-label="$t('schedule.adultSchedule')"
:selected-league="selectedLeague"
:filtered-schedule-teams="filteredScheduleTeams"
:selected-team="selectedTeam"
:workspace-description="workspaceDescription"
:active-tab="activeTab"
:table-count="leagueTable.length"
:fetching-team-data="fetchingTeamData"
:fetching-table="fetchingTable"
@update:selected-season-id="selectedSeasonId = $event"
@season-change="onSeasonChange"
@open-import-modal="openImportModal"
@open-gallery-dialog="openGalleryDialog"
@update:team-search-query="teamSearchQuery = $event"
@load-all-matches="loadAllMatches"
@load-adult-matches="loadAdultMatches"
@load-team="loadMatchesForTeam"
@fetch-team-data-manually="fetchTeamDataManually"
@generate-pdf="generatePDF"
@fetch-table="fetchTableFromMyTischtennis"
@update:active-tab="activeTab = $event"
>
<template #schedule-panel>
<div v-if="selectedTeam" class="league-match-scope-card">
<div class="league-match-scope-header">
<strong>{{ $t('schedule.matchOverviewTitle') }}</strong>
@@ -254,51 +152,42 @@
<div v-else>
<p>{{ $t('schedule.noGames') }}</p>
</div>
</template>
<template #table-panel>
<div class="table-section">
<div class="table-header">
<h3>{{ $t('schedule.leagueTable') }}</h3>
</div>
<!-- Tabelle Tab -->
<div v-show="activeTab === 'table'" class="tab-panel">
<div class="table-section">
<div class="table-header">
<h3>{{ $t('schedule.leagueTable') }}</h3>
</div>
<div v-if="leagueTable.length > 0">
<table id="league-table">
<thead>
<tr>
<th>{{ $t('schedule.position') }}</th>
<th>{{ $t('schedule.team') }}</th>
<th>{{ $t('schedule.matches') }}</th>
<th>{{ $t('schedule.sets') }}</th>
<th>{{ $t('schedule.points') }}</th>
<th>{{ $t('schedule.balls') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in leagueTable" :key="team.teamId"
:class="{ 'our-team': isOurTeam(team.teamName) }">
<td>{{ index + 1 }}</td>
<td>{{ team.teamName }}</td>
<td>{{ team.matchPoints }}</td>
<td>{{ team.setsWon }}:{{ team.setsLost }}</td>
<td>{{ team.tablePoints }}</td>
<td>{{ team.pointRatio }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else>
<p>{{ $t('schedule.noTableData') }}</p>
</div>
</div>
<div v-if="leagueTable.length > 0">
<table id="league-table">
<thead>
<tr>
<th>{{ $t('schedule.position') }}</th>
<th>{{ $t('schedule.team') }}</th>
<th>{{ $t('schedule.matches') }}</th>
<th>{{ $t('schedule.sets') }}</th>
<th>{{ $t('schedule.points') }}</th>
<th>{{ $t('schedule.balls') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in leagueTable" :key="team.teamId" :class="{ 'our-team': isOurTeam(team.teamName) }">
<td>{{ index + 1 }}</td>
<td>{{ team.teamName }}</td>
<td>{{ team.matchPoints }}</td>
<td>{{ team.setsWon }}:{{ team.setsLost }}</td>
<td>{{ team.tablePoints }}</td>
<td>{{ team.pointRatio }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else>
<p>{{ $t('schedule.noTableData') }}</p>
</div>
</div>
<div v-else class="schedule-empty-state">
<h3>{{ $t('schedule.noSelectionTitle') }}</h3>
<p>{{ $t('schedule.noSelectionMessage') }}</p>
</div>
</div>
</div>
</template>
</ScheduleLayoutShell>
<!-- Import Modal -->
<CsvImportDialog v-model="showImportModal" @import="handleCsvImport" @close="closeImportModal" />
@@ -436,6 +325,7 @@ import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import CsvImportDialog from '../components/CsvImportDialog.vue';
import ScheduleLayoutShell from '../components/schedule/ScheduleLayoutShell.vue';
import {
connectSocket,
disconnectSocket,
@@ -452,7 +342,8 @@ export default {
InfoDialog,
ConfirmDialog,
BaseDialog,
CsvImportDialog
CsvImportDialog,
ScheduleLayoutShell
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']),
@@ -1119,7 +1010,7 @@ export default {
return club ? club.name : '';
},
async generatePDF() {
const element = this.$refs.scheduleContainer;
const element = this.$refs.scheduleShell?.$el || this.$refs.scheduleContainer;
const highlightName = this.getCurrentClubName();
if (element) {
const pdfGen = new PDFGenerator(20, 10, this.$t);

View File

@@ -1,138 +1,34 @@
<template>
<div class="team-management-view">
<div class="team-management-hero">
<div class="team-management-hero-header">
<div class="team-management-hero-copy">
<h2>{{ t('teamManagement.title') }}</h2>
<p>{{ t('teamManagement.subtitle') }}</p>
</div>
<div class="team-management-hero-actions">
<div class="team-management-season-selector">
<span class="team-management-hero-label">{{ t('teamManagement.season') }}</span>
<SeasonSelector
v-model="selectedSeasonId"
@season-change="onSeasonChange"
:show-current-season="true"
/>
</div>
</div>
</div>
<div v-if="schedulerJobs.rating_updates || schedulerJobs.match_results" class="scheduler-jobs-summary">
<div class="scheduler-jobs-summary-copy">
<strong>{{ t('teamManagement.automaticJobs') }}</strong>
<span v-if="schedulerJobs.rating_updates?.lastRun || schedulerJobs.match_results?.lastRun" class="scheduler-jobs-summary-text">
{{ t('teamManagement.lastRun') }}:
{{ formatJobDate(schedulerJobs.match_results?.lastRun || schedulerJobs.rating_updates?.lastRun) }}
</span>
</div>
<button type="button" class="scheduler-jobs-toggle" @click="showGlobalJobDetails = !showGlobalJobDetails">
<span class="scheduler-jobs-toggle-icon">{{ showGlobalJobDetails ? '▾' : '▸' }}</span>
<span class="scheduler-jobs-toggle-text">{{ showGlobalJobDetails ? t('common.close') : t('common.details') }}</span>
</button>
</div>
<div class="team-management-summary">
<div class="team-summary-card">
<span class="team-summary-label">{{ t('teamManagement.teams') }}</span>
<strong class="team-summary-value">{{ teams.length }}</strong>
</div>
<div class="team-summary-card">
<span class="team-summary-label">{{ t('teamManagement.fullyConfigured') }}</span>
<strong class="team-summary-value">{{ fullyConfiguredTeamsCount }}</strong>
</div>
<div class="team-summary-card">
<span class="team-summary-label">{{ t('teamManagement.partiallyConfigured') }}</span>
<strong class="team-summary-value">{{ partiallyConfiguredTeamsCount }}</strong>
</div>
<div class="team-summary-card">
<span class="team-summary-label">{{ t('teamManagement.noLeague') }}</span>
<strong class="team-summary-value">{{ teamsWithoutLeagueCount }}</strong>
</div>
</div>
</div>
<div v-if="showGlobalJobDetails && (schedulerJobs.rating_updates || schedulerJobs.match_results)" class="scheduler-jobs-info">
<div class="job-info" v-if="schedulerJobs.rating_updates?.lastRun">
<span class="job-label">🔄 {{ t('teamManagement.ratingUpdates') }}:</span>
<span class="job-details">
{{ t('teamManagement.lastRun') }}: {{ formatJobDate(schedulerJobs.rating_updates.lastRun) }}
<span v-if="schedulerJobs.rating_updates.updatedCount !== null" class="job-count">
({{ schedulerJobs.rating_updates.updatedCount }} {{ t('teamManagement.updated') }})
</span>
<span v-if="!schedulerJobs.rating_updates.success" class="job-error"> {{ t('teamManagement.error') }}</span>
</span>
</div>
<div class="job-info" v-if="schedulerJobs.match_results?.lastRun">
<div class="job-header">
<span class="job-label">📊 {{ t('teamManagement.matchResults') }}:</span>
<span class="job-details">
{{ t('teamManagement.lastRun') }}: {{ formatJobDate(schedulerJobs.match_results.lastRun) }}
<span v-if="schedulerJobs.match_results.fetchedCount !== null" class="job-count">
({{ schedulerJobs.match_results.fetchedCount }} {{ t('teamManagement.fetched') }})
</span>
<span v-if="!schedulerJobs.match_results.success" class="job-error"> {{ t('teamManagement.error') }}</span>
</span>
</div>
<div v-if="schedulerJobs.match_results.teamDetails && schedulerJobs.match_results.teamDetails.length > 0" class="team-details">
<div v-for="team in getFilteredTeamDetails(schedulerJobs.match_results.teamDetails)" :key="team.clubTeamId" class="team-detail-item">
<span class="team-name">{{ team.teamName }}</span>
<span v-if="team.success" class="team-status success"></span>
<span v-else class="team-status error"></span>
</div>
</div>
</div>
</div>
<div class="teams-list">
<div class="teams-list-header">
<h3>{{ t('teamManagement.teams') }} ({{ filteredTeams.length }}) - {{ t('teamManagement.season') }} {{ currentSeason?.season || t('teamManagement.seasonUnknown') }}</h3>
<div class="teams-list-tools">
<input
v-model="teamSearchQuery"
type="search"
class="team-search-input"
:placeholder="t('teamManagement.searchTeams')"
>
<div class="team-filter-chips">
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'all' }" @click="teamFilter = 'all'">{{ t('teamManagement.filterAll') }}</button>
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'configured' }" @click="teamFilter = 'configured'">{{ t('teamManagement.filterConfigured') }}</button>
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'attention' }" @click="teamFilter = 'attention'">{{ t('teamManagement.filterNeedsAttention') }}</button>
<button type="button" class="team-filter-chip" :class="{ active: teamFilter === 'noLeague' }" @click="teamFilter = 'noLeague'">{{ t('teamManagement.filterNoLeague') }}</button>
</div>
<button type="button" class="btn-primary team-create-button" @click="resetToNewTeam(); teamFormIsOpen = true;">
{{ t('teamManagement.createNewTeam') }}
</button>
</div>
</div>
<div v-if="teams.length === 0" class="no-teams">
<p>{{ t('teamManagement.noTeamsYet') }}</p>
</div>
<div v-else-if="filteredTeams.length === 0" class="no-teams">
<p>{{ t('teamManagement.noMatchingTeams') }}</p>
</div>
<div v-else class="teams-grid">
<TeamListCard
v-for="team in filteredTeams"
:key="team.id"
:team="team"
:active="teamToEdit && teamToEdit.id === team.id"
:status="getMyTischtennisStatus(team)"
:league-name="team.league ? team.league.name : t('teamManagement.noLeague')"
:season-label="team.season?.season || t('teamManagement.unknown')"
:created-text="formatDate(team.createdAt)"
:last-updated-text="getTeamJobInfo(team) && getTeamJobInfo(team).lastRun ? formatJobDate(getTeamJobInfo(team).lastRun) : t('teamManagement.never')"
:has-code-list="getTeamDocuments(team.id, 'code_list').length > 0"
:has-pin-list="getTeamDocuments(team.id, 'pin_list').length > 0"
@edit="editTeam(team)"
@delete="deleteTeam(team)"
@show-code-list="showPDFDialog(team.id, 'code_list')"
@show-pin-list="showPDFDialog(team.id, 'pin_list')"
/>
</div>
</div>
<TeamManagementOverview
:selected-season-id="selectedSeasonId"
:scheduler-jobs="schedulerJobs"
:show-global-job-details="showGlobalJobDetails"
:teams-count="teams.length"
:fully-configured-teams-count="fullyConfiguredTeamsCount"
:partially-configured-teams-count="partiallyConfiguredTeamsCount"
:teams-without-league-count="teamsWithoutLeagueCount"
:filtered-team-details="getFilteredTeamDetails(schedulerJobs.match_results?.teamDetails || [])"
:format-job-date="formatJobDate"
:filtered-teams="filteredTeams"
:current-season-label="currentSeason?.season || ''"
:team-search-query="teamSearchQuery"
:team-filter="teamFilter"
:active-team-id="teamToEdit ? teamToEdit.id : null"
:get-my-tischtennis-status="getMyTischtennisStatus"
:format-date="formatDate"
:get-team-job-info="getTeamJobInfo"
:get-team-documents="getTeamDocuments"
@update:selected-season-id="selectedSeasonId = $event"
@season-change="onSeasonChange"
@toggle-job-details="showGlobalJobDetails = !showGlobalJobDetails"
@update:team-search-query="teamSearchQuery = $event"
@update:team-filter="teamFilter = $event"
@create-new-team="resetToNewTeam(); teamFormIsOpen = true;"
@edit-team="editTeam"
@delete-team="deleteTeam"
@show-pdf-dialog="showPDFDialog"
/>
<div class="newteam">
<div class="toggle-new-team">
@@ -492,6 +388,7 @@ import i18n from '../i18n';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import TeamListCard from '../components/team/TeamListCard.vue';
import TeamManagementOverview from '../components/team/TeamManagementOverview.vue';
import { buildInfoConfig, buildConfirmConfig } from '../utils/dialogUtils.js';
export default {
@@ -500,7 +397,8 @@ export default {
SeasonSelector,
InfoDialog,
ConfirmDialog,
TeamListCard
TeamListCard,
TeamManagementOverview
},
setup() {
const store = useStore();