Files
yourpart3/frontend/src/views/falukant/PoliticsView.vue
Torsten Schulz (local) 957780be1b feat(vocab): add dashboard learning summary and related endpoints
- Introduced `getDashboardLearningSummary` method in `VocabService` to provide a compact overview of enrolled courses and current lessons for users.
- Updated `vocabController` to include a new route for the dashboard widget, allowing users to access their learning summary.
- Enhanced `vocabRouter` to route requests for the new dashboard widget endpoint.
- Added localization support for the new dashboard features across multiple languages, improving user engagement and accessibility.
- Updated UI components to integrate the new dashboard widget, ensuring a seamless user experience.
2026-04-02 15:06:50 +02:00

500 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="politics-view">
<StatusBar />
<SimpleTabs v-model="activeTab" :tabs="tabs" @change="onTabChange" />
<!-- TabInhalt -->
<div class="tab-content">
<!-- Aktuelle Positionen -->
<div v-if="activeTab === 'current'" class="tab-pane">
<div v-if="loading.current" class="loading">{{ $t('loading') }}</div>
<div v-else-if="currentPositions.length" class="politics-card-list">
<article v-for="pos in currentPositions" :key="pos.id" class="politics-card" :class="{ 'own-position': isOwnPosition(pos) }">
<div class="politics-card__header">
<strong>{{ $t(`falukant.politics.offices.${pos.officeType.name}`) }}</strong>
<span>{{ pos.region.name }}</span>
</div>
<div class="politics-card__meta">
<span>
{{ $t('falukant.politics.current.holder') }}:
<template v-if="pos.character">
{{ pos.character.definedFirstName.name }} {{ pos.character.definedLastName.name }}
</template>
<template v-else></template>
</span>
<span>
{{ $t('falukant.politics.current.benefit') }}:
<template v-if="politicsBenefitItems(pos).length">
<template v-for="(b, i) in politicsBenefitItems(pos)" :key="i">
<span>{{ formatPoliticsBenefitItem(b) }}</span>
<span v-if="i < politicsBenefitItems(pos).length - 1">, </span>
</template>
</template>
<template v-else></template>
</span>
<span>
{{ $t('falukant.politics.current.termEnds') }}:
<template v-if="pos.termEnds">{{ formatDate(pos.termEnds) }}</template>
<template v-else></template>
</span>
</div>
</article>
</div>
<p v-else class="loading">{{ $t('falukant.politics.current.none') }}</p>
</div>
<!-- OPEN Tab: hier zeigen wir 'openPolitics' -->
<div v-else-if="activeTab === 'openPolitics'" class="tab-pane">
<p class="politics-age-requirement">{{ $t('falukant.politics.open.ageRequirement') }}</p>
<div v-if="loading.openPolitics" class="loading">{{ $t('loading') }}</div>
<div v-else-if="openPolitics.length" class="politics-card-list">
<article v-for="e in openPolitics" :key="e.id" class="politics-card">
<div class="politics-card__header">
<strong>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</strong>
<span>{{ e.region.name }}</span>
</div>
<div class="politics-card__meta">
<span>{{ $t('falukant.politics.open.date') }}: {{ formatDate(e.date) }}</span>
</div>
<label class="politics-card__checkbox" :title="e.canApplyByAge === false ? $t('falukant.politics.open.minAgeHint') : null">
<input
type="checkbox"
:id="`apply-${e.id}`"
v-model="selectedApplications"
:value="e.id"
:disabled="e.alreadyApplied || e.canApplyByAge === false"
/>
<span>Für diese Kandidatur vormerken</span>
</label>
</article>
</div>
<p v-else class="loading">{{ $t('falukant.politics.open.none') }}</p>
<div class="apply-button">
<button :disabled="!selectedApplications.length" @click="submitApplications">
{{ $t('falukant.politics.open.apply') }}
</button>
</div>
</div>
<!-- Wahlen -->
<div v-else-if="activeTab === 'elections'" class="tab-pane">
<div v-if="loading.elections" class="loading">{{ $t('loading') }}</div>
<div v-else-if="elections.length" class="politics-card-list">
<article v-for="e in elections" :key="e.id" class="politics-card politics-card--election">
<div class="politics-card__header">
<strong>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</strong>
<span>{{ e.region.name }}</span>
</div>
<div class="politics-card__meta">
<span>{{ $t('falukant.politics.elections.date') }}: {{ formatDate(e.date) }}</span>
<span>{{ $t('falukant.politics.elections.posts') }}: {{ e.postsToFill }}</span>
</div>
<div v-if="!e.voted" class="politics-card__vote">
<Multiselect v-model="selectedCandidates[e.id]" :options="e.candidates" multiple
:max="e.postsToFill" :close-on-select="false" :clear-on-select="false"
track-by="id" label="name" :custom-label="candidateLabel" placeholder="">
<template #option="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }} ({{ option.age }})
</template>
<template #selected="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }}
</template>
</Multiselect>
<button
:disabled="!selectedCandidates[e.id] || !selectedCandidates[e.id].length"
@click="submitVote(e.id)">
{{ $t('falukant.politics.elections.vote') }}
</button>
</div>
<ul v-else class="voted-list">
<li v-for="cid in e.votedFor" :key="cid">
<span v-if="findCandidateById(e, cid)">
{{ formatCandidateTitle(findCandidateById(e, cid)) }}
{{ findCandidateById(e, cid).name }}
</span>
</li>
<li v-if="!e.votedFor || !e.votedFor.length"></li>
</ul>
</article>
</div>
<p v-else class="loading">{{ $t('falukant.politics.elections.none') }}</p>
<div class="all-vote-button" v-if="hasAnyUnvoted">
<button :disabled="!hasAnySelection" @click="submitAllVotes">
{{ $t('falukant.politics.elections.voteAll') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import SimpleTabs from '@/components/SimpleTabs.vue';
import Multiselect from 'vue-multiselect';
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
const debugLog = () => {};
export default {
name: 'PoliticsView',
components: { StatusBar, SimpleTabs, Multiselect },
data() {
return {
activeTab: 'current',
tabs: [
{ value: 'current', label: 'falukant.politics.tabs.current' },
{ value: 'openPolitics', label: 'falukant.politics.tabs.upcoming' },
{ value: 'elections', label: 'falukant.politics.tabs.elections' }
],
currentPositions: [],
openPolitics: [],
elections: [],
selectedCandidates: {},
selectedApplications: [],
ownCharacterId: null,
loading: {
current: false,
openPolitics: false,
elections: false
}
};
},
computed: {
hasAnySelection() {
return Object.values(this.selectedCandidates)
.some(arr => Array.isArray(arr) && arr.length > 0);
},
hasAnyUnvoted() {
return this.elections.some(e => !e.voted);
}
},
mounted() {
this.loadOwnCharacterId();
this.loadCurrentPositions();
},
methods: {
politicsBenefitItems(pos) {
const raw = pos?.benefit;
if (!Array.isArray(raw) || !raw.length) {
return [];
}
if (raw.every((x) => typeof x === 'string') && raw.includes('*')) {
return [this.$t('falukant.politics.current.benefit_all')];
}
return raw;
},
formatPoliticsRegionLevel(key) {
const k = String(key || '');
const path = `falukant.politics.regionLevels.${k}`;
const t = this.$t(path);
return t !== path ? t : k;
},
formatPoliticsBenefitItem(b) {
if (b == null) {
return '';
}
if (typeof b === 'string') {
return b;
}
if (typeof b === 'object' && b.tr) {
if (b.tr === 'tax_exemption' && b.params?.all) {
return this.$t('falukant.politics.benefits.tax_exemption_all');
}
if (b.tr === 'tax_exemption' && Array.isArray(b.params?.regions)) {
const labels = b.params.regions.map((r) => this.formatPoliticsRegionLevel(r)).join(', ');
return this.$t('falukant.politics.benefits.tax_exemption', { regions: labels });
}
if (b.tr === 'daily_salary') {
return this.$t('falukant.politics.benefits.daily_salary', { amount: b.params?.amount ?? '—' });
}
if (b.tr === 'generic_benefit') {
return this.$t('falukant.politics.benefits.generic', { code: b.params?.code || '' });
}
}
return String(b);
},
onTabChange(tab) {
if (tab === 'current') {
this.loadCurrentPositions();
}
if (tab === 'openPolitics') {
this.loadOpenPolitics();
}
if (tab === 'elections') {
this.loadElections();
}
},
async loadCurrentPositions() {
this.loading.current = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/overview');
debugLog('[PoliticsView] loadCurrentPositions - API response:', data);
debugLog('[PoliticsView] loadCurrentPositions - ownCharacterId at load time:', this.ownCharacterId);
this.currentPositions = data;
debugLog('[PoliticsView] loadCurrentPositions - Loaded', data.length, 'positions');
} catch (err) {
console.error('[PoliticsView] Error loading current positions', err);
} finally {
this.loading.current = false;
}
},
async loadOpenPolitics() {
this.loading.openPolitics = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/open');
this.openPolitics = Array.isArray(data) ? data : [];
// Bereits beworbene Positionen vorselektieren, damit die Checkbox
// sichtbar markiert bleibt.
this.selectedApplications = this.openPolitics
.filter(e => e.alreadyApplied)
.map(e => e.id);
} catch (err) {
console.error('Error loading open politics', err);
} finally {
this.loading.openPolitics = false;
}
},
async loadElections() {
this.loading.elections = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/elections');
this.elections = data;
data.forEach(e => {
this.selectedCandidates[e.id] = [];
});
} catch (err) {
console.error('Error loading elections', err);
} finally {
this.loading.elections = false;
}
},
candidateLabel(option) {
const title = this.$t(`falukant.titles.${option.gender}.${option.title}`);
return `${title} ${option.name} (${option.age})`;
},
findCandidateById(election, candidateId) {
return election.candidates.find(c => c.id === candidateId) || {};
},
formatCandidateTitle(candidate) {
if (!candidate) return '';
return this.$t(`falukant.titles.${candidate.gender}.${candidate.title}`);
},
async submitVote(electionId) {
const singlePayload = [
{
electionId: electionId,
candidateIds: this.selectedCandidates[electionId].map(c => c.id)
}
];
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: singlePayload }
);
await this.loadElections();
showSuccess(this, 'Stimme erfolgreich abgegeben.');
} catch (err) {
console.error(`Error submitting vote for election ${electionId}`, err);
showApiError(this, err, 'Fehler beim Abgeben der Stimme');
}
},
async submitAllVotes() {
const payload = Object.entries(this.selectedCandidates)
.filter(([eid, arr]) => Array.isArray(arr) && arr.length > 0)
.map(([eid, arr]) => ({
electionId: parseInt(eid, 10),
candidateIds: arr.map(c => c.id)
}));
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: payload }
);
await this.loadElections();
showSuccess(this, 'Alle Stimmen erfolgreich abgegeben.');
} catch (err) {
console.error('Error submitting all votes', err);
showApiError(this, err, 'Fehler beim Abgeben der Stimmen');
}
},
formatDate(ts) {
return new Date(ts).toLocaleDateString(this.$i18n.locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
},
async loadOwnCharacterId() {
try {
const { data } = await apiClient.get('/api/falukant/info');
if (data.character && data.character.id) {
this.ownCharacterId = data.character.id;
}
} catch (err) {
console.error('[PoliticsView] Error loading own character ID', err);
}
},
isOwnPosition(pos) {
if (!this.ownCharacterId || !pos.character) {
return false;
}
return pos.character.id === this.ownCharacterId;
},
async submitApplications() {
try {
const response = await apiClient.post(
'/api/falukant/politics/open',
{ electionIds: this.selectedApplications }
);
// Speichere die IDs der erfolgreich beworbenen Positionen
const appliedIds = response.data?.applied || [];
// Lade die Daten neu
await this.loadOpenPolitics();
// Stelle sicher, dass alle bereits beworbenen Positionen (inkl. der gerade beworbenen) vorselektiert bleiben
this.selectedApplications = this.openPolitics
.filter(e => e.alreadyApplied || appliedIds.includes(e.id))
.map(e => e.id);
showSuccess(this, 'Kandidatur erfolgreich vorgemerkt.');
} catch (err) {
console.error('Error submitting applications', err);
if (err?.response?.data?.error === 'too_young') {
showApiError(this, err, this.$t('falukant.politics.too_young'));
return;
}
showApiError(this, err, this.$t('falukant.politics.applyError'));
}
}
}
};
</script>
<style scoped>
.politics-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.simple-tabs {
margin-bottom: 16px;
}
.tab-content {
display: flex;
flex-direction: column;
}
.tab-pane {
display: flex;
flex-direction: column;
}
.politics-age-requirement {
margin: 0 0 10px 0;
font-size: 0.95em;
color: var(--color-text-secondary);
}
.politics-card-list {
display: grid;
gap: 12px;
}
.politics-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.72);
}
.politics-card.own-position {
border-color: rgba(120, 195, 138, 0.45);
background: rgba(236, 248, 238, 0.92);
}
.politics-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.politics-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: var(--color-text-secondary);
}
.politics-card__checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
width: fit-content;
}
.politics-card__vote {
display: grid;
gap: 12px;
}
.loading {
text-align: center;
font-style: italic;
margin: 20px 0;
}
.voted-list {
list-style: none;
margin: 0;
padding: 0;
}
.all-vote-button {
padding: 10px 0;
text-align: right;
}
.all-vote-button button {
padding: 6px 12px;
cursor: pointer;
}
@media (max-width: 900px) {
.politics-card__header,
.politics-card__meta {
flex-direction: column;
align-items: flex-start;
}
}
</style>