feat(political-benefits): implement political powers and benefits system
All checks were successful
Deploy to production / deploy (push) Successful in 3m3s
All checks were successful
Deploy to production / deploy (push) Successful in 3m3s
- Added new political powers and benefits functionalities, including reputation ticks, tax jurisdiction management, and appointment capabilities. - Introduced a new job for periodic reputation updates and created necessary database tables for tracking political benefits. - Enhanced the FalukantController and services to support new endpoints for managing political powers and appointments. - Updated localization files to reflect new features and improve user experience across multiple languages. - Modified the UI to display new political powers and benefits, ensuring accurate representation in the PoliticsView.
This commit is contained in:
@@ -342,6 +342,9 @@
|
||||
<!-- Liebhaber / Geliebte -->
|
||||
<div class="lovers-section">
|
||||
<h3>{{ $t('falukant.family.lovers.title') }}</h3>
|
||||
<p v-if="politicalFreeLoverSlots > 0" class="lovers-political-hint">
|
||||
{{ $t('falukant.family.lovers.politicalFreeSlotsHint', { count: politicalFreeLoverSlots }) }}
|
||||
</p>
|
||||
<div v-if="lovers && lovers.length > 0" class="lovers-grid">
|
||||
<article v-for="lover in lovers" :key="lover.relationshipId" class="lover-card surface-card">
|
||||
<div class="lover-card__header">
|
||||
@@ -377,7 +380,12 @@
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ $t('falukant.family.lovers.monthlyCost') }}</dt>
|
||||
<dd>{{ formatCost(lover.monthlyCost || 0) }}</dd>
|
||||
<dd>
|
||||
{{ formatCost(lover.monthlyCost || 0) }}
|
||||
<span v-if="lover.politicalFreeMaintenance" class="lover-political-free">
|
||||
{{ $t('falukant.family.lovers.politicalFreeMaintenance') }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ $t('falukant.family.lovers.statusFit') }}</dt>
|
||||
@@ -476,6 +484,7 @@ export default {
|
||||
relationships: [],
|
||||
children: [],
|
||||
lovers: [],
|
||||
politicalFreeLoverSlots: 0,
|
||||
possibleLovers: [],
|
||||
candidateRoles: {},
|
||||
deathPartners: [],
|
||||
@@ -655,6 +664,7 @@ export default {
|
||||
this.relationships = response.data.relationships;
|
||||
this.children = response.data.children;
|
||||
this.lovers = response.data.lovers;
|
||||
this.politicalFreeLoverSlots = Number(response.data.politicalFreeLoverSlots) || 0;
|
||||
this.possibleLovers = response.data.possibleLovers || [];
|
||||
this.syncCandidateRoles();
|
||||
this.proposals = response.data.possiblePartners;
|
||||
@@ -1525,6 +1535,18 @@ export default {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.lovers-political-hint {
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.lover-political-free {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.lovers-section {
|
||||
ul {
|
||||
list-style: none;
|
||||
|
||||
@@ -44,6 +44,81 @@
|
||||
<p v-else class="loading">{{ $t('falukant.politics.current.none') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'powers'" class="tab-pane powers-tab">
|
||||
<div v-if="loading.powers" class="loading">{{ $t('loading') }}</div>
|
||||
<template v-else-if="myPowers">
|
||||
<p v-if="!hasAnyPowers" class="politics-powers-empty">{{ $t('falukant.politics.powers.none') }}</p>
|
||||
<template v-else>
|
||||
<section v-if="myPowers.freeLoverSlots > 0" class="powers-section">
|
||||
<h3>{{ $t('falukant.politics.powers.freeLoversTitle') }}</h3>
|
||||
<p>{{ $t('falukant.politics.powers.freeLoversHint', { count: myPowers.freeLoverSlots }) }}</p>
|
||||
</section>
|
||||
<section v-if="myPowers.reputationPeriodic && myPowers.reputationPeriodic.length" class="powers-section">
|
||||
<h3>{{ $t('falukant.politics.powers.reputationTitle') }}</h3>
|
||||
<ul class="powers-list">
|
||||
<li v-for="(r, i) in myPowers.reputationPeriodic" :key="i">
|
||||
{{ $t('falukant.politics.powers.reputationLine', {
|
||||
office: $t(`falukant.politics.offices.${r.officeTypeName}`),
|
||||
days: r.daysUntilNext,
|
||||
gain: r.gain
|
||||
}) }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section v-if="taxJurisdiction && taxJurisdiction.regions && taxJurisdiction.regions.length" class="powers-section">
|
||||
<h3>{{ $t('falukant.politics.powers.taxTitle') }}</h3>
|
||||
<p class="powers-hint">{{ $t('falukant.politics.powers.taxRange', { min: taxJurisdiction.taxMin, max: taxJurisdiction.taxMax }) }}</p>
|
||||
<div v-for="reg in taxJurisdiction.regions" :key="reg.id" class="powers-tax-row">
|
||||
<div>
|
||||
<strong>{{ reg.name }}</strong>
|
||||
<span class="powers-muted">({{ formatPoliticsRegionLevel(reg.regionType) }})</span>
|
||||
</div>
|
||||
<div class="powers-tax-edit">
|
||||
<input
|
||||
type="number"
|
||||
:min="taxJurisdiction.taxMin"
|
||||
:max="taxJurisdiction.taxMax"
|
||||
step="0.1"
|
||||
v-model.number="taxDraft[reg.id]"
|
||||
/>
|
||||
<button type="button" @click="saveRegionTax(reg.id)">{{ $t('falukant.politics.powers.taxSave') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="appointable.length" class="powers-section">
|
||||
<h3>{{ $t('falukant.politics.powers.appointTitle') }}</h3>
|
||||
<label class="powers-label">{{ $t('falukant.politics.powers.appointSlot') }}</label>
|
||||
<select v-model="selectedAppointKey" class="powers-select">
|
||||
<option value="">{{ $t('falukant.politics.powers.appointPick') }}</option>
|
||||
<option v-for="a in appointable" :key="appointOptionKey(a)" :value="appointOptionKey(a)">
|
||||
{{ $t(`falukant.politics.offices.${a.officeTypeName}`) }} — {{ a.regionName }} ({{ a.seatsFree }})
|
||||
</option>
|
||||
</select>
|
||||
<label class="powers-label">{{ $t('falukant.politics.powers.appointSearch') }}</label>
|
||||
<input v-model="appointSearch" type="text" class="powers-input" @input="debouncedAppointSearch" />
|
||||
<ul v-if="appointSearchResults.length" class="powers-search-results">
|
||||
<li v-for="u in appointSearchResults" :key="u.characterId">
|
||||
<button type="button" class="powers-linkish" @click="selectAppointTarget(u)">
|
||||
{{ u.username }} — {{ u.firstname }} {{ u.lastname }} ({{ u.town }})
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="selectedAppointTarget" class="powers-selected-target">
|
||||
{{ $t('falukant.politics.powers.appointSelected', { name: selectedAppointTarget.username }) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="powers-submit"
|
||||
:disabled="!canSubmitAppointment"
|
||||
@click="submitAppointment"
|
||||
>
|
||||
{{ $t('falukant.politics.powers.appointSubmit') }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- OPEN Tab: hier zeigen wir 'openPolitics' -->
|
||||
<div v-else-if="activeTab === 'openPolitics'" class="tab-pane">
|
||||
<p class="politics-age-requirement">{{ $t('falukant.politics.open.ageRequirement') }}</p>
|
||||
@@ -150,6 +225,7 @@ export default {
|
||||
activeTab: 'current',
|
||||
tabs: [
|
||||
{ value: 'current', label: 'falukant.politics.tabs.current' },
|
||||
{ value: 'powers', label: 'falukant.politics.tabs.powers' },
|
||||
{ value: 'openPolitics', label: 'falukant.politics.tabs.upcoming' },
|
||||
{ value: 'elections', label: 'falukant.politics.tabs.elections' }
|
||||
],
|
||||
@@ -159,14 +235,37 @@ export default {
|
||||
selectedCandidates: {},
|
||||
selectedApplications: [],
|
||||
ownCharacterId: null,
|
||||
myPowers: null,
|
||||
taxJurisdiction: null,
|
||||
taxDraft: {},
|
||||
appointable: [],
|
||||
selectedAppointKey: '',
|
||||
appointSearch: '',
|
||||
appointSearchResults: [],
|
||||
selectedAppointTarget: null,
|
||||
_appointSearchTimer: null,
|
||||
loading: {
|
||||
current: false,
|
||||
openPolitics: false,
|
||||
elections: false
|
||||
elections: false,
|
||||
powers: false
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasAnyPowers() {
|
||||
const p = this.myPowers;
|
||||
if (!p) return false;
|
||||
return (
|
||||
(p.freeLoverSlots > 0) ||
|
||||
(p.reputationPeriodic && p.reputationPeriodic.length) ||
|
||||
(this.taxJurisdiction?.regions?.length > 0) ||
|
||||
(this.appointable.length > 0)
|
||||
);
|
||||
},
|
||||
canSubmitAppointment() {
|
||||
return Boolean(this.selectedAppointKey && this.selectedAppointTarget?.characterId);
|
||||
},
|
||||
hasAnySelection() {
|
||||
return Object.values(this.selectedCandidates)
|
||||
.some(arr => Array.isArray(arr) && arr.length > 0);
|
||||
@@ -266,6 +365,9 @@ export default {
|
||||
if (tab === 'current') {
|
||||
this.loadCurrentPositions();
|
||||
}
|
||||
if (tab === 'powers') {
|
||||
this.loadPowers();
|
||||
}
|
||||
if (tab === 'openPolitics') {
|
||||
this.loadOpenPolitics();
|
||||
}
|
||||
@@ -274,6 +376,95 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async loadPowers() {
|
||||
this.loading.powers = true;
|
||||
try {
|
||||
const [mp, tj, ap] = await Promise.all([
|
||||
apiClient.get('/api/falukant/politics/my-powers'),
|
||||
apiClient.get('/api/falukant/politics/tax-jurisdiction'),
|
||||
apiClient.get('/api/falukant/politics/appointable-offices')
|
||||
]);
|
||||
this.myPowers = mp.data;
|
||||
this.taxJurisdiction = tj.data;
|
||||
this.appointable = Array.isArray(ap.data) ? ap.data : [];
|
||||
const draft = {};
|
||||
(this.taxJurisdiction?.regions || []).forEach((r) => {
|
||||
draft[r.id] = r.taxPercent;
|
||||
});
|
||||
this.taxDraft = draft;
|
||||
} catch (err) {
|
||||
console.error('[PoliticsView] loadPowers', err);
|
||||
showApiError(this, err, this.$t('falukant.politics.powers.loadError'));
|
||||
} finally {
|
||||
this.loading.powers = false;
|
||||
}
|
||||
},
|
||||
|
||||
appointOptionKey(a) {
|
||||
return `${a.officeTypeId}:${a.regionId}`;
|
||||
},
|
||||
|
||||
parseAppointKey(key) {
|
||||
const [officeTypeId, regionId] = String(key).split(':').map((x) => parseInt(x, 10));
|
||||
if (Number.isNaN(officeTypeId) || Number.isNaN(regionId)) return null;
|
||||
return { officeTypeId, regionId };
|
||||
},
|
||||
|
||||
async saveRegionTax(regionId) {
|
||||
const percent = this.taxDraft[regionId];
|
||||
try {
|
||||
await apiClient.put(`/api/falukant/politics/region/${regionId}/tax`, { percent });
|
||||
showSuccess(this, this.$t('falukant.politics.powers.taxSaved'));
|
||||
await this.loadPowers();
|
||||
} catch (err) {
|
||||
showApiError(this, err, this.$t('falukant.politics.powers.taxError'));
|
||||
}
|
||||
},
|
||||
|
||||
debouncedAppointSearch() {
|
||||
if (this._appointSearchTimer) clearTimeout(this._appointSearchTimer);
|
||||
this._appointSearchTimer = setTimeout(() => this.runAppointSearch(), 350);
|
||||
},
|
||||
|
||||
async runAppointSearch() {
|
||||
const q = (this.appointSearch || '').trim();
|
||||
if (q.length < 2) {
|
||||
this.appointSearchResults = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await apiClient.get('/api/falukant/users/search', { params: { q } });
|
||||
this.appointSearchResults = Array.isArray(data) ? data : [];
|
||||
} catch (err) {
|
||||
this.appointSearchResults = [];
|
||||
}
|
||||
},
|
||||
|
||||
selectAppointTarget(u) {
|
||||
this.selectedAppointTarget = u;
|
||||
},
|
||||
|
||||
async submitAppointment() {
|
||||
const parsed = this.parseAppointKey(this.selectedAppointKey);
|
||||
if (!parsed || !this.selectedAppointTarget?.characterId) return;
|
||||
try {
|
||||
await apiClient.post('/api/falukant/politics/appointments', {
|
||||
targetCharacterId: this.selectedAppointTarget.characterId,
|
||||
officeTypeId: parsed.officeTypeId,
|
||||
regionId: parsed.regionId
|
||||
});
|
||||
showSuccess(this, this.$t('falukant.politics.powers.appointSuccess'));
|
||||
this.selectedAppointKey = '';
|
||||
this.selectedAppointTarget = null;
|
||||
this.appointSearch = '';
|
||||
this.appointSearchResults = [];
|
||||
await this.loadPowers();
|
||||
await this.loadCurrentPositions();
|
||||
} catch (err) {
|
||||
showApiError(this, err, this.$t('falukant.politics.powers.appointError'));
|
||||
}
|
||||
},
|
||||
|
||||
async loadCurrentPositions() {
|
||||
this.loading.current = true;
|
||||
try {
|
||||
@@ -535,4 +726,82 @@ export default {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.powers-tab .powers-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.powers-tab h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.powers-hint,
|
||||
.politics-powers-empty {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.powers-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.powers-tax-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.powers-tax-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.powers-tax-edit input {
|
||||
width: 5.5rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.powers-muted {
|
||||
margin-left: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.powers-label {
|
||||
display: block;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.powers-input,
|
||||
.powers-select {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.powers-search-results {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
.powers-linkish {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-primary, #6b4a2a);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.powers-selected-target {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.powers-submit {
|
||||
margin-top: 12px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user