Implement church career management features

- Added endpoints for church career functionalities including overview, available positions, application submission, and application decision-making.
- Enhanced the FalukantController to handle church-related requests.
- Updated associations and models to support church office types and requirements.
- Integrated new routes in the falukantRouter for church career operations.
- Implemented service methods for managing church applications and checking church career status.
- Updated frontend components to display current positions, available positions, and manage applications with appropriate UI elements and loading states.
- Localized new church-related strings in both English and German.
This commit is contained in:
Torsten Schulz (local)
2026-01-22 16:46:42 +01:00
parent 8e226615eb
commit 4f786cdcc3
13 changed files with 1424 additions and 2 deletions

View File

@@ -837,6 +837,58 @@
}
},
"church": {
"title": "Kirche",
"tabs": {
"current": "Aktuelle Positionen",
"available": "Verfügbare Positionen",
"applications": "Bewerbungen"
},
"current": {
"office": "Amt",
"region": "Region",
"holder": "Inhaber",
"supervisor": "Vorgesetzter",
"none": "Keine aktuellen Positionen vorhanden."
},
"available": {
"office": "Amt",
"region": "Region",
"supervisor": "Vorgesetzter",
"seats": "Verfügbare Plätze",
"action": "Aktion",
"apply": "Bewerben",
"applySuccess": "Bewerbung erfolgreich eingereicht.",
"applyError": "Fehler beim Einreichen der Bewerbung.",
"none": "Keine verfügbaren Positionen."
},
"applications": {
"office": "Amt",
"region": "Region",
"applicant": "Bewerber",
"date": "Datum",
"action": "Aktion",
"approve": "Annehmen",
"reject": "Ablehnen",
"approveSuccess": "Bewerbung angenommen.",
"rejectSuccess": "Bewerbung abgelehnt.",
"decideError": "Fehler bei der Entscheidung.",
"none": "Keine Bewerbungen vorhanden."
},
"offices": {
"village-priest": "Dorfgeistlicher",
"parish-priest": "Pfarrer",
"dean": "Dekan",
"archdeacon": "Erzdiakon",
"bishop": "Bischof",
"archbishop": "Erzbischof",
"cardinal": "Kardinal",
"pope": "Papst"
},
"application": {
"received": "Neue Bewerbung erhalten",
"approved": "Bewerbung angenommen",
"rejected": "Bewerbung abgelehnt"
},
"title": "Kirche",
"baptism": {
"title": "Taufen",

View File

@@ -376,6 +376,76 @@
"assessor": "Assessor"
}
},
"church": {
"title": "Church",
"tabs": {
"current": "Current Positions",
"available": "Available Positions",
"applications": "Applications"
},
"current": {
"office": "Office",
"region": "Region",
"holder": "Holder",
"supervisor": "Supervisor",
"none": "No current positions available."
},
"available": {
"office": "Office",
"region": "Region",
"supervisor": "Supervisor",
"seats": "Available Seats",
"action": "Action",
"apply": "Apply",
"applySuccess": "Application submitted successfully.",
"applyError": "Error submitting application.",
"none": "No available positions."
},
"applications": {
"office": "Office",
"region": "Region",
"applicant": "Applicant",
"date": "Date",
"action": "Action",
"approve": "Approve",
"reject": "Reject",
"approveSuccess": "Application approved.",
"rejectSuccess": "Application rejected.",
"decideError": "Error making decision.",
"none": "No applications available."
},
"offices": {
"village-priest": "Village Priest",
"parish-priest": "Parish Priest",
"dean": "Dean",
"archdeacon": "Archdeacon",
"bishop": "Bishop",
"archbishop": "Archbishop",
"cardinal": "Cardinal",
"pope": "Pope"
},
"application": {
"received": "New application received",
"approved": "Application approved",
"rejected": "Application rejected"
},
"baptism": {
"title": "Baptism",
"table": {
"name": "First Name",
"gender": "Gender",
"age": "Age",
"baptise": "Baptize (50)",
"newName": "Suggest Name"
},
"gender": {
"male": "Boy",
"female": "Girl"
},
"success": "The child has been baptized.",
"error": "The child could not be baptized."
}
},
"family": {
"children": {
"title": "Children",

View File

@@ -6,6 +6,7 @@
<SimpleTabs v-model="activeTab" :tabs="tabs" />
<div class="tab-content">
<!-- Taufe -->
<div v-if="activeTab === 'baptism'">
<h3>{{ $t('falukant.church.baptism.title') }}</h3>
<table>
@@ -36,6 +37,124 @@
</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.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>{{ 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="5">{{ $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>
@@ -61,12 +180,36 @@ export default {
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: []
baptismList: [],
currentPositions: [],
availablePositions: [],
supervisedApplications: [],
ownCharacterId: null,
loading: {
current: false,
available: false,
applications: false
}
}
},
async mounted() {
await this.loadNotBaptisedChildren()
await this.loadNotBaptisedChildren();
await this.loadOwnCharacterId();
},
watch: {
activeTab(newTab) {
if (newTab === 'current') {
this.loadCurrentPositions();
} else if (newTab === 'available') {
this.loadAvailablePositions();
} else if (newTab === 'applications') {
this.loadSupervisedApplications();
}
}
},
methods: {
async loadNotBaptisedChildren() {
@@ -99,6 +242,102 @@ export default {
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;
},
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(`tr:${errorMsg}`);
}
},
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'
});
}
}
}
@@ -140,4 +379,75 @@ input[type="text"] {
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>