Files
yourpart3/frontend/src/views/falukant/ChurchView.vue

635 lines
20 KiB
Vue
Executable File

<template>
<div class="church-view">
<StatusBar />
<div class="church-content">
<h2>{{ $t('falukant.church.title') }}</h2>
<div class="church-summary">
<div class="church-summary-card">
<div class="church-summary-label">{{ $t('falukant.church.summary.highestCurrentOffice') }}</div>
<div class="church-summary-value">{{ highestCurrentOfficeLabel }}</div>
</div>
<div class="church-summary-card">
<div class="church-summary-label">{{ $t('falukant.church.summary.availableApplications') }}</div>
<div class="church-summary-value">{{ availablePositions.length }}</div>
</div>
<div class="church-summary-card">
<div class="church-summary-label">{{ $t('falukant.church.summary.supervisedApplications') }}</div>
<div class="church-summary-value">{{ supervisedApplications.length }}</div>
</div>
</div>
<div class="church-guidance">
<p>{{ $t('falukant.church.summary.guidance') }}</p>
</div>
<SimpleTabs v-model="activeTab" :tabs="tabs" />
<div class="tab-content">
<!-- Taufe -->
<div v-if="activeTab === 'baptism'">
<h3>{{ $t('falukant.church.baptism.title') }}</h3>
<table>
<thead>
<tr>
<th>{{ $t('falukant.church.baptism.table.gender') }}</th>
<th>{{ $t('falukant.church.baptism.table.name') }}</th>
<th>{{ $t('falukant.church.baptism.table.age') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="person in baptismList" :key="person.id">
<td>{{ $t(`falukant.church.baptism.gender.${person.gender}`) }}</td>
<td>
<input type="text" v-model="person.proposedFirstName" />
<button @click="newName(person)">
{{ $t('falukant.church.baptism.table.newName') }}
</button>
</td>
<td>{{ person.age }}</td>
<td>
<button @click="baptise(person)">
{{ $t('falukant.church.baptism.table.baptise') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Aktuelle kirchliche Positionen -->
<div v-else-if="activeTab === 'current'" class="tab-pane">
<div v-if="loading.current" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="church-table">
<thead>
<tr>
<th>{{ $t('falukant.church.current.office') }}</th>
<th>{{ $t('falukant.church.current.region') }}</th>
<th>{{ $t('falukant.church.current.holder') }}</th>
<th>{{ $t('falukant.church.current.supervisor') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="pos in currentPositions" :key="pos.id" :class="{ 'own-position': isOwnPosition(pos) }">
<td>{{ $t(`falukant.church.offices.${pos.officeType.name}`) }}</td>
<td>{{ pos.region.name }}</td>
<td>
<span v-if="pos.character">
{{ $t(`falukant.titles.${pos.character.gender}.${pos.character.title || 'noncivil'}`) }}
{{ pos.character.name }}
</span>
<span v-else></span>
</td>
<td>
<span v-if="pos.supervisor">
{{ pos.supervisor.name }}
</span>
<span v-else></span>
</td>
</tr>
<tr v-if="!currentPositions.length">
<td colspan="4">{{ $t('falukant.church.current.none') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Verfügbare Positionen -->
<div v-else-if="activeTab === 'available'" class="tab-pane">
<div v-if="loading.available" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="church-table">
<thead>
<tr>
<th>{{ $t('falukant.church.available.office') }}</th>
<th>{{ $t('falukant.church.available.region') }}</th>
<th>{{ $t('falukant.church.available.supervisor') }}</th>
<th>{{ $t('falukant.church.available.decision') }}</th>
<th>{{ $t('falukant.church.available.seats') }}</th>
<th>{{ $t('falukant.church.available.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="pos in availablePositions" :key="pos.id">
<td>{{ $t(`falukant.church.offices.${pos.officeType.name}`) }}</td>
<td>{{ pos.region?.name || '—' }}</td>
<td>
<span v-if="pos.supervisor">
{{ pos.supervisor.name }}
</span>
<span v-else></span>
</td>
<td>{{ decisionModeLabel(pos.decisionMode) }}</td>
<td>{{ pos.availableSeats }}</td>
<td>
<button @click="applyForPosition(pos)" :disabled="pos.availableSeats === 0">
{{ $t('falukant.church.available.apply') }}
</button>
</td>
</tr>
<tr v-if="!availablePositions.length">
<td colspan="6">{{ $t('falukant.church.available.none') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Bewerbungen (als Vorgesetzter) -->
<div v-else-if="activeTab === 'applications'" class="tab-pane">
<div v-if="loading.applications" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="church-table">
<thead>
<tr>
<th>{{ $t('falukant.church.applications.office') }}</th>
<th>{{ $t('falukant.church.applications.region') }}</th>
<th>{{ $t('falukant.church.applications.applicant') }}</th>
<th>{{ $t('falukant.church.applications.date') }}</th>
<th>{{ $t('falukant.church.applications.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="app in supervisedApplications" :key="app.id">
<td>{{ $t(`falukant.church.offices.${app.officeType.name}`) }}</td>
<td>{{ app.region.name }}</td>
<td>
{{ $t(`falukant.titles.${app.applicant.gender}.${app.applicant.title || 'noncivil'}`) }}
{{ app.applicant.name }} ({{ app.applicant.age }})
</td>
<td>{{ formatDate(app.createdAt) }}</td>
<td>
<button @click="decideOnApplication(app.id, 'approve')" class="approve-button">
{{ $t('falukant.church.applications.approve') }}
</button>
<button @click="decideOnApplication(app.id, 'reject')" class="reject-button">
{{ $t('falukant.church.applications.reject') }}
</button>
</td>
</tr>
<tr v-if="!supervisedApplications.length">
<td colspan="5">{{ $t('falukant.church.applications.none') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue'
import MessageDialog from '@/dialogues/standard/MessageDialog.vue'
import ErrorDialog from '@/dialogues/standard/ErrorDialog.vue'
import apiClient from '@/utils/axios.js'
import SimpleTabs from '@/components/SimpleTabs.vue'
import { mapState } from 'vuex'
export default {
name: 'ChurchView',
components: {
StatusBar,
MessageDialog,
ErrorDialog,
SimpleTabs,
},
data() {
return {
activeTab: 'baptism',
tabs: [
{ value: 'baptism', label: 'falukant.church.baptism.title' },
{ value: 'current', label: 'falukant.church.tabs.current' },
{ value: 'available', label: 'falukant.church.tabs.available' },
{ value: 'applications', label: 'falukant.church.tabs.applications' },
],
baptismList: [],
currentPositions: [],
availablePositions: [],
supervisedApplications: [],
ownCharacterId: null,
loading: {
current: false,
available: false,
applications: false
}
}
},
computed: {
...mapState(['socket', 'daemonSocket', 'user']),
highestCurrentOffice() {
if (!this.currentPositions.length) {
return null;
}
return [...this.currentPositions]
.filter(pos => this.isOwnPosition(pos))
.sort((a, b) => this.officeRank(b.officeType?.name) - this.officeRank(a.officeType?.name))[0] || null;
},
highestCurrentOfficeLabel() {
if (!this.highestCurrentOffice?.officeType?.name) {
return this.$t('falukant.church.summary.none');
}
return this.$t(`falukant.church.offices.${this.highestCurrentOffice.officeType.name}`);
}
},
async mounted() {
await this.loadNotBaptisedChildren();
await this.loadOwnCharacterId();
await Promise.all([
this.loadCurrentPositions(),
this.loadAvailablePositions(),
this.loadSupervisedApplications()
]);
this.setupSocketEvents();
this.setupDaemonListeners();
},
beforeUnmount() {
this.teardownSocketEvents();
this.teardownDaemonListeners();
},
watch: {
activeTab(newTab) {
if (newTab === 'current') {
this.loadCurrentPositions();
} else if (newTab === 'available') {
this.loadAvailablePositions();
} else if (newTab === 'applications') {
this.loadSupervisedApplications();
}
}
},
methods: {
matchesCurrentUser(eventData) {
if (eventData?.user_id == null) {
return true;
}
const currentIds = [this.user?.id, this.user?.hashedId]
.filter(Boolean)
.map((value) => String(value));
return currentIds.includes(String(eventData.user_id));
},
setupSocketEvents() {
this.teardownSocketEvents();
if (!this.socket) {
setTimeout(() => this.setupSocketEvents(), 1000);
return;
}
this._statusSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data });
this._churchSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateChurch', ...data });
this.socket.on('falukantUpdateStatus', this._statusSocketHandler);
this.socket.on('falukantUpdateChurch', this._churchSocketHandler);
},
teardownSocketEvents() {
if (!this.socket) return;
if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler);
if (this._churchSocketHandler) this.socket.off('falukantUpdateChurch', this._churchSocketHandler);
},
setupDaemonListeners() {
this.teardownDaemonListeners();
if (!this.daemonSocket) return;
this._daemonChurchHandler = (event) => {
if (event.data === 'ping') return;
try {
const message = JSON.parse(event.data);
if (['falukantUpdateStatus', 'falukantUpdateChurch'].includes(message.event)) {
this.handleEvent(message);
}
} catch (_) {}
};
this.daemonSocket.addEventListener('message', this._daemonChurchHandler);
},
teardownDaemonListeners() {
if (this.daemonSocket && this._daemonChurchHandler) {
this.daemonSocket.removeEventListener('message', this._daemonChurchHandler);
this._daemonChurchHandler = null;
}
},
queueChurchRefresh({ current = true, available = true, applications = true } = {}) {
if (this._pendingChurchRefresh) {
clearTimeout(this._pendingChurchRefresh);
}
this._pendingChurchRefresh = setTimeout(async () => {
this._pendingChurchRefresh = null;
const tasks = [];
if (current) tasks.push(this.loadCurrentPositions());
if (available) tasks.push(this.loadAvailablePositions());
if (applications) tasks.push(this.loadSupervisedApplications());
await Promise.all(tasks);
}, 120);
},
handleEvent(eventData) {
if (!this.matchesCurrentUser(eventData)) {
return;
}
switch (eventData.event) {
case 'falukantUpdateStatus':
this.queueChurchRefresh();
break;
case 'falukantUpdateChurch':
switch (eventData.reason) {
case 'applications':
this.queueChurchRefresh({ current: false, available: false, applications: true });
break;
case 'npc_decision':
this.queueChurchRefresh({ current: true, available: true, applications: true });
break;
case 'appointment':
case 'vacancy_fill':
case 'promotion':
default:
this.queueChurchRefresh({ current: true, available: true, applications: false });
break;
}
break;
}
},
async loadNotBaptisedChildren() {
try {
const { data } = await apiClient.get('/api/falukant/family/notbaptised')
this.baptismList = data
} catch (err) {
console.error(err)
}
},
async newName(person) {
try {
const { data } = await apiClient.get(
`/api/falukant/name/randomfirstname/${person.gender}`
)
person.proposedFirstName = data.name ?? data
} catch (err) {
console.error(err)
}
},
async baptise(person) {
try {
await apiClient.post('/api/falukant/church/baptise', {
characterId: person.id,
firstName: person.proposedFirstName
})
this.loadNotBaptisedChildren();
this.$root.$refs.messageDialog.open('tr:falukant.church.baptism.success')
} catch (err) {
console.error(err)
this.$root.$refs.errorDialog.open('tr:falukant.church.baptism.error')
}
},
async loadCurrentPositions() {
this.loading.current = true;
try {
const { data } = await apiClient.get('/api/falukant/church/overview');
this.currentPositions = data;
} catch (err) {
console.error('Error loading current positions', err);
} finally {
this.loading.current = false;
}
},
async loadAvailablePositions() {
this.loading.available = true;
try {
const { data } = await apiClient.get('/api/falukant/church/positions/available');
this.availablePositions = data;
} catch (err) {
console.error('Error loading available positions', err);
} finally {
this.loading.available = false;
}
},
async loadSupervisedApplications() {
this.loading.applications = true;
try {
const { data } = await apiClient.get('/api/falukant/church/applications/supervised');
this.supervisedApplications = data;
} catch (err) {
console.error('Error loading supervised applications', err);
} finally {
this.loading.applications = false;
}
},
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('Error loading own character ID', err);
}
},
isOwnPosition(pos) {
if (!this.ownCharacterId || !pos.character) {
return false;
}
return pos.character.id === this.ownCharacterId;
},
officeRank(name) {
const ranks = {
'lay-preacher': 0,
'village-priest': 1,
'parish-priest': 2,
dean: 3,
archdeacon: 4,
bishop: 5,
archbishop: 6,
cardinal: 7,
pope: 8
};
return ranks[name] ?? -1;
},
decisionModeLabel(mode) {
const key = `falukant.church.available.decisionType.${mode || 'interim'}`;
return this.$te(key) ? this.$t(key) : mode || '—';
},
async applyForPosition(position) {
try {
const regionId = position.regionId || position.region?.id;
if (!regionId) {
throw new Error('Region not found');
}
await apiClient.post('/api/falukant/church/positions/apply', {
officeTypeId: position.id,
regionId: regionId
});
this.$root.$refs.messageDialog?.open('tr:falukant.church.available.applySuccess');
await this.loadAvailablePositions();
} catch (err) {
console.error('Error applying for position', err);
const errorMsg = err.response?.data?.message || 'falukant.church.available.applyError';
this.$root.$refs.errorDialog?.open(errorMsg.startsWith('falukant.') ? `tr:${errorMsg}` : `tr:falukant.church.available.applyError`);
}
},
async decideOnApplication(applicationId, decision) {
try {
await apiClient.post('/api/falukant/church/applications/decide', {
applicationId: applicationId,
decision: decision
});
const msgKey = decision === 'approve'
? 'falukant.church.applications.approveSuccess'
: 'falukant.church.applications.rejectSuccess';
this.$root.$refs.messageDialog?.open(`tr:${msgKey}`);
await this.loadSupervisedApplications();
await this.loadCurrentPositions();
} catch (err) {
console.error('Error deciding on application', err);
const errorMsg = err.response?.data?.message || 'falukant.church.applications.decideError';
this.$root.$refs.errorDialog?.open(`tr:${errorMsg}`);
}
},
formatDate(date) {
return new Date(date).toLocaleDateString(this.$i18n.locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
}
}
</script>
<style scoped>
h2 {
padding-top: 20px;
}
.simple-tabs {
display: flex;
margin-top: 1rem;
}
.simple-tab {
padding: 0.5rem 1rem;
background: #fff;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.simple-tab.active {
background: #F9A22C;
color: #000;
}
.tab-content {
margin-top: 1rem;
}
.church-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin: 1rem 0 0.75rem;
}
.church-summary-card,
.church-guidance {
border: 1px solid #ddd;
background: #fff;
padding: 0.85rem 1rem;
border-radius: 8px;
}
.church-summary-label {
font-size: 0.85rem;
color: #666;
}
.church-summary-value {
margin-top: 0.25rem;
font-weight: 700;
}
.church-guidance {
margin-bottom: 1rem;
}
input[type="text"] {
width: 140px;
margin-right: 0.5rem;
box-sizing: border-box;
}
th {
text-align: left;
}
.tab-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-scroll {
flex: 1;
overflow-y: auto;
border: 1px solid #ddd;
}
.church-table {
border-collapse: collapse;
width: 100%;
}
.church-table thead th {
position: sticky;
top: 0;
background: #FFF;
z-index: 1;
padding: 8px;
border: 1px solid #ddd;
text-align: left;
}
.church-table tbody td {
padding: 8px;
border: 1px solid #ddd;
}
.church-table tbody tr.own-position {
background-color: #e0e0e0;
font-weight: bold;
}
.loading {
text-align: center;
font-style: italic;
margin: 20px 0;
}
.approve-button {
background-color: #28a745;
color: white;
border: none;
padding: 4px 8px;
margin-right: 4px;
cursor: pointer;
border-radius: 4px;
}
.approve-button:hover {
background-color: #218838;
}
.reject-button {
background-color: #dc3545;
color: white;
border: none;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.reject-button:hover {
background-color: #c82333;
}
</style>