From 95ea6336b7363c5d988ddae3e4ab199100b09a0d Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 15 Apr 2026 15:30:24 +0200 Subject: [PATCH] feat(falukant): add political office catalog feature and localization updates - Implemented the `getPoliticalOfficeCatalog` method in FalukantService to retrieve available political offices and their prerequisites based on user eligibility. - Updated FalukantController and FalukantRouter to include a new endpoint for accessing the political office catalog. - Enhanced PoliticsView component to display the list of political offices, including their details and application eligibility. - Added localization entries for the new offices tab in both German and English, improving user experience and accessibility. --- backend/controllers/falukantController.js | 1 + backend/routers/falukantRouter.js | 1 + backend/services/falukantService.js | 54 +++++++++++++++ frontend/src/i18n/locales/de/falukant.json | 16 +++++ frontend/src/i18n/locales/en/falukant.json | 16 +++++ frontend/src/views/falukant/PoliticsView.vue | 73 ++++++++++++++++++++ 6 files changed, 161 insertions(+) diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index bc6ebdc..fe2899d 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -210,6 +210,7 @@ class FalukantController { }, { blockInDebtorsPrison: true }); this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId)); + this.getPoliticalOfficeCatalog = this._wrapWithUser((userId) => this.service.getPoliticalOfficeCatalog(userId)); this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId)); this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId)); this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes), { blockInDebtorsPrison: true }); diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index aa42a67..aa29c3e 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -96,6 +96,7 @@ router.post('/nobility', falukantController.advanceNobility); router.get('/health', falukantController.getHealth); router.post('/health', falukantController.healthActivity); router.get('/politics/overview', falukantController.getPoliticsOverview); +router.get('/politics/offices', falukantController.getPoliticalOfficeCatalog); router.get('/politics/my-powers', falukantController.getPoliticalMyPowers); router.get('/politics/tax-jurisdiction', falukantController.getPoliticalTaxJurisdiction); router.put('/politics/region/:regionId/tax', falukantController.setPoliticalRegionTax); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index fd8037f..4137a7b 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -6824,6 +6824,60 @@ class FalukantService extends BaseService { return result; } + async getPoliticalOfficeCatalog(hashedUserId) { + const user = await this.getFalukantUserByHashedId(hashedUserId); + const character = user.character; + const ageDays = character.birthdate ? calcAge(character.birthdate) : 0; + const canApplyByAge = ageDays >= FalukantService.MIN_AGE_POLITICS_DAYS; + + const histories = await PoliticalOfficeHistory.findAll({ + where: { characterId: character.id }, + attributes: ['officeTypeId', 'startDate', 'endDate'] + }); + const heldOfficeTypeIds = new Set(histories.map((h) => Number(h.officeTypeId))); + + const allTypes = await PoliticalOfficeType.findAll({ + attributes: ['id', 'name', 'regionType', 'seatsPerRegion', 'termLength', 'hierarchyLevel'], + include: [{ model: PoliticalOfficePrerequisite, as: 'prerequisites', attributes: ['prerequisite'] }], + order: [['hierarchyLevel', 'ASC'], ['id', 'ASC']] + }); + const nameToId = new Map(allTypes.map((t) => [t.name, t.id])); + const titleId = character.titleOfNobility ?? character.nobleTitle?.id; + const allowedOfficeNames = await getAllowedOfficeTypeNamesByTitle(titleId); + + return allTypes.map((typeRow) => { + const type = typeRow.get({ plain: true }); + const prereqRows = Array.isArray(type.prerequisites) ? type.prerequisites : []; + const requiredOfficeNames = [...new Set( + prereqRows.flatMap((pr) => Array.isArray(pr?.prerequisite?.jobs) ? pr.prerequisite.jobs : []) + )]; + + const matchedOfficeNames = requiredOfficeNames.filter((officeName) => { + const reqId = nameToId.get(officeName); + return reqId != null && heldOfficeTypeIds.has(Number(reqId)); + }); + + const prerequisitesMet = requiredOfficeNames.length === 0 || matchedOfficeNames.length > 0; + const titleEligible = allowedOfficeNames.size === 0 || allowedOfficeNames.has(type.name); + const canApplyNow = canApplyByAge && titleEligible && prerequisitesMet; + + return { + officeTypeId: type.id, + officeTypeName: type.name, + regionType: type.regionType, + seatsPerRegion: type.seatsPerRegion, + termLengthDays: type.termLength, + hierarchyLevel: type.hierarchyLevel, + requiredOfficeNames, + matchedOfficeNames, + prerequisitesMet, + titleEligible, + canApplyByAge, + canApplyNow + }; + }); + } + async applyForElections(hashedUserId, electionIds) { // 1) Hole FalukantUser + Character const user = await this.getFalukantUserByHashedId(hashedUserId); diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index fe9accf..3c99bdb 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -1452,6 +1452,7 @@ "tabs": { "current": "Aktuelle Position", "powers": "Amtsbefugnisse", + "offices": "Alle Ämter", "upcoming": "Anstehende Neuwahl-Positionen", "elections": "Wahlen" }, @@ -1530,6 +1531,21 @@ "minAgeHint": "Kandidatur erst ab 16 Jahren möglich.", "ageRequirement": "Für alle politischen Ämter gilt: Kandidatur erst ab 16 Jahren." }, + "officesTab": { + "loadError": "Ämterliste konnte nicht geladen werden.", + "none": "Keine Ämter gefunden.", + "ageRequirement": "Hier siehst du alle Ämter mit ihren Voraussetzungen. Bewerbungen sind erst ab 16 Jahren möglich.", + "seatsPerRegion": "Sitze pro Region", + "termLengthDays": "Amtsdauer (Tage)", + "prerequisites": "Voraussetzungen", + "noPrerequisites": "Keine (Einstiegsamt)", + "canApplyNow": "Bewerbung aktuell möglich", + "yes": "Ja", + "no": "Nein", + "blockedByAge": "Mindestalter nicht erreicht", + "blockedByTitle": "Standesvoraussetzung nicht erfüllt", + "blockedByPrerequisites": "Vorherige Ämter fehlen" + }, "too_young": "Dein Charakter ist noch zu jung. Eine Bewerbung ist erst ab 16 Jahren möglich.", "upcoming": { "office": "Amt", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index cb89b2a..f342b17 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -658,6 +658,7 @@ "tabs": { "current": "Current Position", "powers": "Office powers", + "offices": "All offices", "upcoming": "Upcoming Positions", "elections": "Elections" }, @@ -736,6 +737,21 @@ "minAgeHint": "Candidacy is only possible from age 16.", "ageRequirement": "All political offices require candidates to be at least 16 years old." }, + "officesTab": { + "loadError": "Could not load office list.", + "none": "No offices found.", + "ageRequirement": "This tab lists all offices and their prerequisites. Applications are only possible from age 16.", + "seatsPerRegion": "Seats per region", + "termLengthDays": "Term length (days)", + "prerequisites": "Prerequisites", + "noPrerequisites": "None (entry office)", + "canApplyNow": "Can apply now", + "yes": "Yes", + "no": "No", + "blockedByAge": "Minimum age not met", + "blockedByTitle": "Title requirement not met", + "blockedByPrerequisites": "Required prior offices missing" + }, "too_young": "Your character is too young. Applications are only possible from age 16.", "upcoming": { "office": "Office", diff --git a/frontend/src/views/falukant/PoliticsView.vue b/frontend/src/views/falukant/PoliticsView.vue index c9ee8a9..2eaef64 100644 --- a/frontend/src/views/falukant/PoliticsView.vue +++ b/frontend/src/views/falukant/PoliticsView.vue @@ -128,6 +128,59 @@ + +
+
{{ $t('loading') }}
+ +

{{ $t('falukant.politics.officesTab.none') }}

+
+

{{ $t('falukant.politics.open.ageRequirement') }}

@@ -235,10 +288,12 @@ export default { tabs: [ { value: 'current', label: 'falukant.politics.tabs.current' }, { value: 'powers', label: 'falukant.politics.tabs.powers' }, + { value: 'offices', label: 'falukant.politics.tabs.offices' }, { value: 'openPolitics', label: 'falukant.politics.tabs.upcoming' }, { value: 'elections', label: 'falukant.politics.tabs.elections' } ], currentPositions: [], + politicalOffices: [], openPolitics: [], elections: [], selectedCandidates: {}, @@ -255,6 +310,7 @@ export default { _appointSearchTimer: null, loading: { current: false, + offices: false, openPolitics: false, elections: false, powers: false @@ -379,6 +435,9 @@ export default { if (tab === 'powers') { this.loadPowers(); } + if (tab === 'offices') { + this.loadPoliticalOffices(); + } if (tab === 'openPolitics') { this.loadOpenPolitics(); } @@ -411,6 +470,20 @@ export default { } }, + async loadPoliticalOffices() { + this.loading.offices = true; + try { + const { data } = await apiClient.get('/api/falukant/politics/offices'); + this.politicalOffices = Array.isArray(data) ? data : []; + } catch (err) { + console.error('[PoliticsView] loadPoliticalOffices', err); + this.politicalOffices = []; + showApiError(this, err, this.$t('falukant.politics.officesTab.loadError')); + } finally { + this.loading.offices = false; + } + }, + appointOptionKey(a) { return `${a.officeTypeId}:${a.regionId}`; },