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.ageRequirement') }}
+
+
+
+
{{ $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}`;
},