feat(App, ScheduleView): implement mobile club picker and location dialog

- Added a mobile club picker dialog to enhance club selection on mobile devices, allowing users to select or create a club easily.
- Introduced a location dialog in ScheduleView to display match location details, improving user experience when viewing schedules.
- Updated internationalization files to include new translation keys for mobile club picker hints and location information.
- Enhanced responsive design in various components to ensure a seamless experience across different viewport sizes.
This commit is contained in:
Torsten Schulz (local)
2026-03-20 10:02:38 +01:00
parent cbc5054f1f
commit cc6d1f6ebe
5 changed files with 465 additions and 94 deletions

View File

@@ -142,6 +142,37 @@
</router-view>
</main>
</div>
<BaseDialog
v-model="showMobileClubPicker"
:title="$t('club.select')"
:max-width="420"
:close-on-overlay="false"
:close-on-escape="false"
:show-close-button="false"
>
<div class="mobile-club-picker">
<p class="mobile-club-picker-text">{{ $t('club.mobileSelectHint') }}</p>
<div class="mobile-club-picker-list">
<button
v-for="club in clubs"
:key="club.id"
type="button"
class="mobile-club-picker-option"
@click="selectClubFromMobilePicker(club.id)"
>
{{ club.name }}
</button>
<button
type="button"
class="mobile-club-picker-option secondary-option"
@click="selectClubFromMobilePicker('new')"
>
{{ $t('club.new') }}
</button>
</div>
</div>
</BaseDialog>
<!-- Dialog Manager -->
<DialogManager />
@@ -184,12 +215,14 @@ import logoUrl from './assets/logo.png';
import DialogManager from './components/DialogManager.vue';
import InfoDialog from './components/InfoDialog.vue';
import ConfirmDialog from './components/ConfirmDialog.vue';
import BaseDialog from './components/BaseDialog.vue';
import { buildInfoConfig, buildConfirmConfig } from './utils/dialogUtils.js';
export default {
name: 'App',
components: {
DialogManager
,
BaseDialog,
InfoDialog,
ConfirmDialog},
data() {
@@ -214,10 +247,15 @@ export default {
sessionInterval: null,
logoUrl,
userDropdownOpen: false,
viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1280,
showMobileClubPicker: false,
};
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed', 'username', 'hasPermission', 'isClubOwner', 'userRole', 'language']),
isMobileViewport() {
return this.viewportWidth <= 768;
},
canManageApprovals() {
// Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden
if (!this.currentClub) return false;
@@ -246,6 +284,9 @@ export default {
if (newVal === 'new') {
this.$router.push('/createclub');
}
if (newVal) {
this.showMobileClubPicker = false;
}
// Removed automatic redirect to training-stats to allow manual navigation
},
isAuthenticated(newVal) {
@@ -260,10 +301,21 @@ export default {
clearInterval(this.sessionInterval);
this.sessionInterval = null;
}
this.showMobileClubPicker = false;
}
},
clubs: {
handler() {
this.updateMobileClubPickerState();
},
deep: true
}
},
methods: {
handleViewportResize() {
this.viewportWidth = window.innerWidth;
this.updateMobileClubPickerState();
},
toggleUserDropdown(event) {
event.stopPropagation();
this.userDropdownOpen = !this.userDropdownOpen;
@@ -316,6 +368,7 @@ export default {
async handleClubSelectionChange() {
if (!this.selectedClub) {
await this.setCurrentClub(null);
this.updateMobileClubPickerState();
return;
}
@@ -323,15 +376,53 @@ export default {
if (this.selectedClub === 'new') {
this.$router.push('/createclub');
}
this.updateMobileClubPickerState();
},
async selectClubFromMobilePicker(clubId) {
this.selectedClub = clubId;
await this.handleClubSelectionChange();
},
async initializeClubSelectionState() {
if (!this.isAuthenticated) {
this.showMobileClubPicker = false;
return;
}
if (this.currentClub) {
this.selectedClub = this.currentClub;
this.showMobileClubPicker = false;
return;
}
if (!this.isMobileViewport) {
this.showMobileClubPicker = false;
return;
}
if (this.clubs.length === 1) {
this.selectedClub = this.clubs[0].id;
await this.handleClubSelectionChange();
return;
}
this.showMobileClubPicker = this.clubs.length > 1;
},
updateMobileClubPickerState() {
if (!this.isAuthenticated || !this.isMobileViewport || this.currentClub || this.$route.path === '/createclub') {
this.showMobileClubPicker = false;
return;
}
this.showMobileClubPicker = this.clubs.length > 1;
},
async loadUserData() {
try {
const response = await apiClient.get('/clubs');
this.setClubs(response.data);
if (this.currentClub) {
this.selectedClub = this.currentClub;
}
await this.initializeClubSelectionState();
this.checkSession();
// Session-Check alle 30 Sekunden
this.sessionInterval = setInterval(this.checkSession, 30000);
@@ -373,9 +464,7 @@ export default {
try {
const response = await apiClient.get('/clubs');
this.setClubs(response.data);
if (this.currentClub) {
this.selectedClub = this.currentClub;
}
await this.initializeClubSelectionState();
this.checkSession();
// Session-Check alle 30 Sekunden
this.sessionInterval = setInterval(this.checkSession, 30000);
@@ -384,10 +473,12 @@ export default {
this.selectedClub = null;
}
}
window.addEventListener('resize', this.handleViewportResize);
},
beforeUnmount() {
clearInterval(this.sessionInterval);
document.removeEventListener('click', this.handleClickOutside);
window.removeEventListener('resize', this.handleViewportResize);
}
};
</script>
@@ -731,6 +822,34 @@ export default {
width: 100%;
}
.mobile-club-picker {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mobile-club-picker-text {
margin: 0;
color: var(--text-muted);
}
.mobile-club-picker-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mobile-club-picker-option {
width: 100%;
justify-content: center;
}
.mobile-club-picker-option.secondary-option {
background: white;
color: var(--primary-color);
border: 1.5px solid var(--primary-color);
}
.auth-links a {
text-align: center;
padding: 0.75rem;
@@ -800,6 +919,10 @@ export default {
}
@media (max-width: 768px) {
.auth-nav {
display: none;
}
.sidebar {
width: 220px;
}
@@ -938,6 +1061,10 @@ export default {
.sidebar:not(.sidebar-collapsed) ~ .main-content {
margin-left: 240px;
}
.auth-nav + .main-content {
margin-left: 0;
}
}
/* Button-Varianten */

View File

@@ -1,55 +1,51 @@
<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 class="schedule-static-chrome">
<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 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 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>
@@ -85,23 +81,25 @@
{{ 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 class="schedule-sidebar-scroll">
<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>
</div>
</aside>
<div class="flex-item">
<div class="schedule-workspace">
<div class="schedule-workspace-header">
<div>
<span class="workspace-eyebrow">{{ $t('schedule.activeSelection') }}</span>
@@ -144,10 +142,14 @@
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-content active">
<div v-show="activeTab === 'schedule'" class="tab-panel">
<slot name="schedule-panel" />
<div class="tab-panel-scroll">
<slot name="schedule-panel" />
</div>
</div>
<div v-show="activeTab === 'table'" class="tab-panel">
<slot name="table-panel" />
<div class="tab-panel-scroll">
<slot name="table-panel" />
</div>
</div>
</div>
<div v-else class="schedule-empty-state">
@@ -169,7 +171,6 @@ export default {
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 },
@@ -210,6 +211,16 @@ export default {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: calc(100dvh - 9rem);
height: calc(100dvh - 9rem);
overflow: hidden;
}
.schedule-static-chrome {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 0 0 auto;
}
.schedule-page-header,
@@ -264,18 +275,37 @@ export default {
.schedule-layout {
display: flex;
gap: 1rem;
align-items: flex-start;
align-items: stretch;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.schedule-sidebar {
width: 280px;
flex: 0 0 280px;
min-height: 0;
}
.schedule-sidebar-card {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.schedule-sidebar-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin-top: 0.75rem;
padding-right: 0.25rem;
}
.schedule-team-list {
list-style: none;
padding: 0;
margin: 0.75rem 0 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
@@ -305,7 +335,14 @@ export default {
border: 1px solid var(--border-color);
border-radius: 999px;
background: white;
color: var(--text-color, #333);
padding: 0.4rem 0.75rem;
box-shadow: none;
text-shadow: none;
}
.schedule-quick-link::before {
display: none;
}
.schedule-quick-link.active {
@@ -314,19 +351,63 @@ export default {
color: var(--primary-dark);
}
.flex-item {
.schedule-workspace {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-navigation {
flex: 0 0 auto;
}
.tab-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.tab-panel {
height: 100%;
min-height: 0;
}
.tab-panel-scroll {
height: 100%;
min-height: 0;
overflow-y: auto;
padding-right: 0.25rem;
}
@media (max-width: 960px) {
.schedule-layout {
flex-direction: column;
overflow: visible;
}
.schedule-sidebar {
width: 100%;
flex-basis: auto;
}
.schedule-shell {
min-height: auto;
height: auto;
overflow: visible;
}
.schedule-sidebar-card,
.schedule-workspace,
.tab-content,
.tab-panel,
.tab-panel-scroll,
.schedule-sidebar-scroll {
min-height: auto;
height: auto;
overflow: visible;
}
}
</style>

View File

@@ -106,7 +106,8 @@
"errorLoadingRequests": "Fehler beim Laden der offenen Anfragen",
"accessRequested": "Zugriff wurde angefragt.",
"accessRequestPending": "Der Zugriff auf diesen Verein ist beantragt. Bitte haben Sie etwas Geduld.",
"accessRequestFailed": "Zugriffsanfrage konnte nicht gestellt werden."
"accessRequestFailed": "Zugriffsanfrage konnte nicht gestellt werden.",
"mobileSelectHint": "Bitte wähle zuerst einen Verein aus, um die App auf dem Smartphone zu nutzen."
},
"auth": {
"login": "Einloggen",
@@ -1245,7 +1246,14 @@
"tableDataLoaded": "Tabellendaten erfolgreich von MyTischtennis geladen!",
"errorLoadingTable": "Fehler beim Laden der Tabellendaten von MyTischtennis",
"scheduleImportSuccess": "Spielplan erfolgreich importiert!",
"errorImportingCSV": "Fehler beim Importieren der CSV-Datei"
"errorImportingCSV": "Fehler beim Importieren der CSV-Datei",
"locationInfo": "Ort",
"showLocation": "Spiellokal anzeigen",
"locationDialogTitle": "Spiellokal",
"locationName": "Spiellokal",
"addressLabel": "Adresse",
"cityLabel": "PLZ / Ort",
"matchLabel": "Spiel"
},
"teamManagement": {
"title": "Team-Verwaltung",

View File

@@ -1,8 +1,8 @@
<template>
<div class="login-page">
<div>
<div class="login-card">
<h2>{{ $t('auth.login') }}</h2>
<form @submit.prevent="executeLogin">
<form class="login-form" @submit.prevent="executeLogin">
<input v-model="email" type="email" :placeholder="$t('auth.email')" required />
<input v-model="password" type="password" :placeholder="$t('auth.password')" required />
<button type="submit">{{ $t('auth.login') }}</button>
@@ -115,3 +115,62 @@ export default {
},
};
</script>
<style scoped>
.login-page {
min-height: calc(100dvh - 10rem);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-card {
width: min(100%, 420px);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(24, 70, 54, 0.08);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(24, 70, 54, 0.12);
padding: 1.5rem;
}
.login-card h2 {
margin-top: 0;
margin-bottom: 1rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login-form input,
.login-form button {
width: 100%;
}
.forgot-password-link,
.register-link {
margin-top: 0.9rem;
}
.register-link p {
margin-bottom: 0;
}
@media (max-width: 480px) {
.login-page {
min-height: calc(100dvh - 8rem);
padding: 0.5rem;
align-items: flex-start;
}
.login-card {
width: 100%;
margin-top: 0.5rem;
padding: 1rem;
border-radius: 14px;
}
}
</style>

View File

@@ -5,7 +5,6 @@
: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"
@@ -84,6 +83,7 @@
<table id="schedule-table">
<thead>
<tr>
<th>{{ $t('schedule.locationInfo') }}</th>
<th>{{ $t('schedule.date') }}</th>
<th>{{ $t('schedule.time') }}</th>
<th>{{ $t('schedule.homeTeam') }}</th>
@@ -99,11 +99,21 @@
</thead>
<tbody>
<tr v-for="match in matches" :key="match.id"
@mouseover="hoveredMatch = match"
@mouseleave="hoveredMatch = null"
@click="openPlayerSelectionDialog(match)"
:class="getRowClass(match.date)"
style="cursor: pointer;">
<td class="location-info-cell">
<button
v-if="match.location"
type="button"
class="location-info-button"
:title="$t('schedule.showLocation')"
@click.stop="openLocationDialog(match)"
>
📍
</button>
<span v-else class="no-data">-</span>
</td>
<td>{{ formatDate(match.date) }}</td>
<td>{{ match.time ? match.time.toString().slice(0, 5) + ' ' + $t('common.time') : 'N/A' }}</td>
<td :class="{ 'highlighted-club': isClubHighlighted(match.homeTeam?.name) }">
@@ -311,6 +321,40 @@
</div>
</div>
</BaseDialog>
<BaseDialog
v-model="locationDialog.isOpen"
:title="$t('schedule.locationDialogTitle')"
:max-width="520"
@close="closeLocationDialog"
>
<div v-if="locationDialog.match" class="schedule-location-dialog">
<div class="schedule-location-row">
<span class="schedule-location-label">{{ $t('schedule.locationName') }}</span>
<strong>{{ locationDialog.match.location?.name || 'N/A' }}</strong>
</div>
<div class="schedule-location-row">
<span class="schedule-location-label">{{ $t('schedule.addressLabel') }}</span>
<span>{{ locationDialog.match.location?.address || 'N/A' }}</span>
</div>
<div class="schedule-location-row">
<span class="schedule-location-label">{{ $t('schedule.cityLabel') }}</span>
<span>{{ [locationDialog.match.location?.zip, locationDialog.match.location?.city].filter(Boolean).join(' ') || 'N/A' }}</span>
</div>
<div class="schedule-location-row">
<span class="schedule-location-label">{{ $t('schedule.matchLabel') }}</span>
<span>{{ locationDialog.match.homeTeam?.name || 'N/A' }} - {{ locationDialog.match.guestTeam?.name || 'N/A' }}</span>
</div>
<div class="schedule-location-row">
<span class="schedule-location-label">{{ $t('schedule.date') }}</span>
<span>{{ formatDate(locationDialog.match.date) }}</span>
</div>
<div class="schedule-location-row">
<span class="schedule-location-label">{{ $t('schedule.time') }}</span>
<span>{{ locationDialog.match.time ? locationDialog.match.time.toString().slice(0, 5) + ' ' + $t('common.time') : 'N/A' }}</span>
</div>
</div>
</BaseDialog>
</template>
<script>
@@ -431,7 +475,6 @@ export default {
matches: [],
selectedLeague: '',
selectedTeam: null,
hoveredMatch: null,
selectedSeasonId: null,
currentSeason: null,
activeTab: 'schedule',
@@ -451,6 +494,10 @@ export default {
members: [],
loading: false
},
locationDialog: {
isOpen: false,
match: null
},
// Gallery Dialog
showGalleryDialog: false,
galleryLoading: false,
@@ -579,6 +626,17 @@ export default {
this.playerSelectionDialog.match = null;
this.playerSelectionDialog.members = [];
},
openLocationDialog(match) {
if (!match?.location) {
return;
}
this.locationDialog.isOpen = true;
this.locationDialog.match = match;
},
closeLocationDialog() {
this.locationDialog.isOpen = false;
this.locationDialog.match = null;
},
togglePlayerReady(member) {
member.isReady = !member.isReady;
@@ -1361,18 +1419,6 @@ td {
color: #856404;
}
.hover-info {
margin-top: 10px;
background-color: #eef;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
max-width: 300px;
position: fixed;
top: 16em;
left: 14em;
}
.modal {
display: flex;
justify-content: center;
@@ -2105,6 +2151,56 @@ li {
box-shadow: 0 0 0 3px rgba(47, 122, 95, 0.14);
}
.location-info-cell {
width: 56px;
text-align: center;
}
.location-info-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border-radius: 999px;
border: 1px solid var(--border-color, #dee2e6);
background: #fff;
color: var(--primary-strong, #1f5f49);
box-shadow: none;
text-shadow: none;
}
.location-info-button::before {
display: none;
}
.location-info-button:hover {
background: rgba(47, 122, 95, 0.08);
border-color: var(--primary-color, #2f7a5f);
transform: none;
box-shadow: none;
}
.schedule-location-dialog {
display: flex;
flex-direction: column;
gap: 12px;
}
.schedule-location-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.schedule-location-label {
font-size: 12px;
color: var(--text-muted, #6c757d);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tab-count {
display: inline-flex;
align-items: center;