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:
@@ -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
|
||||
|
||||
|
||||
355
frontend/src/components/members/MembersOverviewSection.vue
Normal file
355
frontend/src/components/members/MembersOverviewSection.vue
Normal 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>
|
||||
332
frontend/src/components/schedule/ScheduleLayoutShell.vue
Normal file
332
frontend/src/components/schedule/ScheduleLayoutShell.vue
Normal 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>
|
||||
304
frontend/src/components/team/TeamManagementOverview.vue
Normal file
304
frontend/src/components/team/TeamManagementOverview.vue
Normal 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>
|
||||
@@ -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']),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user