635 lines
20 KiB
Vue
Executable File
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>
|