feat(vocab): add dashboard learning summary and related endpoints
All checks were successful
Deploy to production / deploy (push) Successful in 2m52s

- Introduced `getDashboardLearningSummary` method in `VocabService` to provide a compact overview of enrolled courses and current lessons for users.
- Updated `vocabController` to include a new route for the dashboard widget, allowing users to access their learning summary.
- Enhanced `vocabRouter` to route requests for the new dashboard widget endpoint.
- Added localization support for the new dashboard features across multiple languages, improving user engagement and accessibility.
- Updated UI components to integrate the new dashboard widget, ensuring a seamless user experience.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 15:06:50 +02:00
parent 77e6f8d3e8
commit 5fcd55be43
28 changed files with 1095 additions and 39 deletions

View File

@@ -48,7 +48,8 @@ class VocabController {
this.enrollInCourse = this._wrapWithUser((userId, req) => this.service.enrollInCourse(userId, req.params.courseId), { successStatus: 201 });
this.unenrollFromCourse = this._wrapWithUser((userId, req) => this.service.unenrollFromCourse(userId, req.params.courseId));
this.getMyCourses = this._wrapWithUser((userId) => this.service.getMyCourses(userId));
this.getDashboardLearningSummary = this._wrapWithUser((userId) => this.service.getDashboardLearningSummary(userId));
// Progress
this.getCourseProgress = this._wrapWithUser((userId, req) => this.service.getCourseProgress(userId, req.params.courseId));
this.updateLessonProgress = this._wrapWithUser((userId, req) => this.service.updateLessonProgress(userId, req.params.lessonId, req.body));

View File

@@ -0,0 +1,82 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn(
{
tableName: 'falukant_user',
schema: 'falukant_data'
},
'last_political_daily_salary_on',
{
type: Sequelize.DATEONLY,
allowNull: true
}
);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
ALTER TABLE falukant_predefine.political_office_benefit
RENAME COLUMN political_office_id TO office_type_id;
ELSIF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
UPDATE falukant_predefine.political_office_benefit
SET office_type_id = COALESCE(office_type_id, political_office_id);
ALTER TABLE falukant_predefine.political_office_benefit
DROP COLUMN political_office_id;
END IF;
END $$;
`);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn(
{
tableName: 'falukant_user',
schema: 'falukant_data'
},
'last_political_daily_salary_on'
);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) THEN
ALTER TABLE falukant_predefine.political_office_benefit
RENAME COLUMN office_type_id TO political_office_id;
END IF;
END $$;
`);
}
};

View File

@@ -36,6 +36,11 @@ FalukantUser.init({
lastNobilityAdvanceAt: {
type: DataTypes.DATE,
allowNull: true
},
lastPoliticalDailySalaryOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'last_political_daily_salary_on'
}
}, {
sequelize,

View File

@@ -1,7 +1,6 @@
// falukant/predefine/political_office_benefit.js
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
import PoliticalOfficeBenefitType from '../type/political_office_benefit_type.js';
class PoliticalOfficeBenefit extends Model {}
@@ -9,31 +8,29 @@ PoliticalOfficeBenefit.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true},
politicalOfficeId: {
autoIncrement: true
},
officeTypeId: {
type: DataTypes.INTEGER,
allowNull: false},
allowNull: false,
field: 'office_type_id'
},
benefitTypeId: {
type: DataTypes.INTEGER,
allowNull: false},
allowNull: false,
field: 'benefit_type_id'
},
value: {
type: DataTypes.JSONB,
allowNull: false}}, {
sequelize,
allowNull: false
}
}, {
sequelize,
modelName: 'PoliticalOfficeBenefit',
tableName: 'political_office_benefit',
schema: 'falukant_predefine',
timestamps: false,
underscored: true});
// Association
PoliticalOfficeBenefit.belongsTo(PoliticalOfficeBenefitType, {
foreignKey: 'benefit_type_id',
as: 'benefitType'
});
PoliticalOfficeBenefitType.hasMany(PoliticalOfficeBenefit, {
foreignKey: 'benefit_type_id',
as: 'benefits'
underscored: true
});
export default PoliticalOfficeBenefit;

View File

@@ -7,6 +7,8 @@ const vocabController = new VocabController();
router.use(authenticate);
router.get('/dashboard-widget', vocabController.getDashboardLearningSummary);
router.get('/languages', vocabController.listLanguages);
router.get('/languages/all', vocabController.listAllLanguages);
router.post('/languages', vocabController.createLanguage);

View File

@@ -65,6 +65,8 @@ import PoliticalOfficeHistory from '../models/falukant/log/political_office_hist
import UndergroundType from '../models/falukant/type/underground.js';
import Notification from '../models/falukant/log/notification.js';
import PoliticalOffice from '../models/falukant/data/political_office.js';
import PoliticalOfficeBenefit from '../models/falukant/predefine/political_office_benefit.js';
import PoliticalOfficeBenefitType from '../models/falukant/type/political_office_benefit_type.js';
import Underground from '../models/falukant/data/underground.js';
import VehicleType from '../models/falukant/type/vehicle.js';
import Vehicle from '../models/falukant/data/vehicle.js';
@@ -131,6 +133,17 @@ const POLITICAL_OFFICE_RANKS = {
chancellor: 6
};
function computePoliticalDailySalaryPayout(value, officeName) {
const v = value && typeof value === 'object' ? value : {};
if (v.dailyAmount != null && Number.isFinite(Number(v.dailyAmount))) {
return Math.round(Number(v.dailyAmount) * 100) / 100;
}
const rank = POLITICAL_OFFICE_RANKS[officeName] ?? 0;
const base = Number(v.base ?? 0);
const perRank = Number(v.perRank ?? v.per_rank ?? 0);
return Math.round((base + perRank * rank) * 100) / 100;
}
const CERTIFICATE_THRESHOLDS = {
2: { avgKnowledge: 15, completedProductions: 4, statusMode: 'none', statusRequiredCount: 0 },
3: { avgKnowledge: 28, completedProductions: 15, statusMode: 'none', statusRequiredCount: 0 },
@@ -6067,9 +6080,106 @@ class FalukantService extends BaseService {
return this.healthChange(user, delta);
}
politicsBenefitEntriesFromRows(benefitRows, officeName) {
if (!benefitRows?.length) {
return [];
}
const out = [];
for (const row of benefitRows) {
const plain = row.get ? row.get({ plain: true }) : row;
const tr = plain.benefitDefinition?.tr || '';
const v = plain.value && typeof plain.value === 'object' ? plain.value : {};
if (tr === 'tax_exemption') {
const regions = Array.isArray(v.regions) ? v.regions : [];
if (regions.includes('*')) {
out.push({ tr: 'tax_exemption', params: { all: true } });
} else {
out.push({ tr: 'tax_exemption', params: { regions } });
}
} else if (tr === 'daily_salary' || tr === 'salary') {
const amount = computePoliticalDailySalaryPayout(v, officeName);
if (amount > 0) {
out.push({ tr: 'daily_salary', params: { amount } });
}
} else if (tr) {
out.push({ tr: 'generic_benefit', params: { code: tr } });
}
}
return out;
}
async _maybeGrantPoliticalDailySalary(falukantUser, characterId) {
const rawLast = falukantUser.lastPoliticalDailySalaryOn;
const lastStr = rawLast
? (typeof rawLast === 'string'
? rawLast.slice(0, 10)
: new Date(rawLast).toISOString().slice(0, 10))
: '';
const todayStr = new Date().toISOString().slice(0, 10);
if (lastStr === todayStr) {
return;
}
const held = await PoliticalOffice.findAll({
where: { characterId },
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }]
});
if (!held.length) {
return;
}
const officeTypeIds = [...new Set(held.map((h) => h.officeTypeId))];
const benefitRows = await PoliticalOfficeBenefit.findAll({
where: { officeTypeId: { [Op.in]: officeTypeIds } },
include: [{ model: PoliticalOfficeBenefitType, as: 'benefitDefinition', attributes: ['tr'] }]
});
const byType = new Map();
for (const br of benefitRows) {
const oid = br.officeTypeId;
if (!byType.has(oid)) {
byType.set(oid, []);
}
byType.get(oid).push(br);
}
let total = 0;
for (const h of held) {
const name = h.type?.name;
const rows = byType.get(h.officeTypeId) || [];
for (const row of rows) {
const tr = row.benefitDefinition?.tr;
if (tr !== 'daily_salary') {
continue;
}
const v = typeof row.value === 'object' && row.value ? row.value : {};
total += computePoliticalDailySalaryPayout(v, name);
}
}
total = Math.round(total * 100) / 100;
if (total <= 0) {
return;
}
const moneyResult = await updateFalukantUserMoney(
falukantUser.id,
total,
'Politisches Tagesamtshonorar',
falukantUser.id
);
if (!moneyResult.success) {
console.error('[getPoliticsOverview] Tageshonorar konnte nicht gebucht werden');
return;
}
await FalukantUser.update(
{ lastPoliticalDailySalaryOn: todayStr },
{ where: { id: falukantUser.id } }
);
}
async getPoliticsOverview(hashedUserId) {
// Liefert alle aktuell besetzten Ämter im eigenen Gebiet inklusive
// Inhaber und berechnetem Enddatum der Amtszeit.
// Inhaber, Vorteile (aus political_office_benefit) und berechnetem Enddatum der Amtszeit.
const user = await getFalukantUserOrFail(hashedUserId);
// Charakter des Users bestimmen (Region ist dort hinterlegt)
@@ -6081,6 +6191,8 @@ class FalukantService extends BaseService {
return [];
}
await this._maybeGrantPoliticalDailySalary(user, character.id);
// Alle relevanten Regionen (Region + Eltern) laden
const relevantRegionIds = await this.getRegionAndParentIds(character.regionId);
@@ -6138,7 +6250,23 @@ class FalukantService extends BaseService {
]
});
return offices.map(office => {
const officeTypeIds = [...new Set(offices.map((o) => o.officeTypeId))];
let benefitByType = new Map();
if (officeTypeIds.length) {
const allBenefits = await PoliticalOfficeBenefit.findAll({
where: { officeTypeId: { [Op.in]: officeTypeIds } },
include: [{ model: PoliticalOfficeBenefitType, as: 'benefitDefinition', attributes: ['tr'] }]
});
for (const br of allBenefits) {
const oid = br.officeTypeId;
if (!benefitByType.has(oid)) {
benefitByType.set(oid, []);
}
benefitByType.get(oid).push(br);
}
}
return offices.map((office) => {
const o = office.get({ plain: true });
// Enddatum der Amtszeit berechnen: Start = createdAt, Dauer = termLength Jahre
@@ -6152,10 +6280,16 @@ class FalukantService extends BaseService {
}
}
const officeName = o.type?.name;
const benefit = this.politicsBenefitEntriesFromRows(
benefitByType.get(o.officeTypeId) || [],
officeName
);
return {
id: o.id,
officeType: {
name: o.type?.name
name: officeName
},
region: {
name: o.region?.name,
@@ -6172,7 +6306,8 @@ class FalukantService extends BaseService {
gender: o.holder.gender
}
: null,
termEnds
termEnds,
benefit
};
});
}
@@ -7006,11 +7141,11 @@ ORDER BY r.id`,
// Unikate nach character.id
const map = new Map();
const POLITICAL_TAX_EXEMPTIONS = {
'council': ['city'],
'taxman': ['city','county'],
'treasurerer': ['city','county','shire'],
'super-state-administrator': ['city','county','shire','markgrave','duchy'],
'chancellor': ['*']
council: ['city'],
taxman: ['city', 'county'],
treasurer: ['city', 'county', 'shire'],
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
chancellor: ['*']
};
histories.forEach(h => {

View File

@@ -2636,6 +2636,111 @@ export default class VocabService {
}));
}
/**
* Kompakte Übersicht für das Start-Dashboard: eingeschriebene Kurse und „aktuelle“ Lektion
* (gleiche Logik wie VocabCourseView.currentLesson: erste unvollständige, sonst letzte).
*/
async getDashboardLearningSummary(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [
{
model: VocabCourse,
as: 'course',
required: true,
attributes: ['id', 'title']
}
],
order: [['enrolledAt', 'DESC']]
});
const courseById = new Map();
for (const e of enrollments) {
const c = e.course?.get({ plain: true });
if (!c?.id || courseById.has(c.id)) {
continue;
}
courseById.set(c.id, { id: c.id, title: c.title || '' });
}
const coursesMeta = [...courseById.values()];
if (coursesMeta.length === 0) {
return { courses: [] };
}
const courseIds = coursesMeta.map((c) => c.id);
const lessons = await VocabCourseLesson.findAll({
where: { courseId: { [Op.in]: courseIds } },
attributes: ['id', 'courseId', 'lessonNumber', 'title'],
order: [
['courseId', 'ASC'],
['lessonNumber', 'ASC']
]
});
const progressRows = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: { [Op.in]: courseIds } },
attributes: ['lessonId', 'completed']
});
const completedByLessonId = new Map();
for (const row of progressRows) {
const plain = row.get({ plain: true });
completedByLessonId.set(plain.lessonId, Boolean(plain.completed));
}
const lessonsByCourse = new Map();
for (const row of lessons) {
const plain = row.get({ plain: true });
const list = lessonsByCourse.get(plain.courseId) || [];
list.push(plain);
lessonsByCourse.set(plain.courseId, list);
}
const courses = [];
for (const meta of coursesMeta) {
const sorted = lessonsByCourse.get(meta.id) || [];
if (sorted.length === 0) {
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: null,
allLessonsCompleted: false
});
continue;
}
let current = null;
for (const lesson of sorted) {
if (!completedByLessonId.get(lesson.id)) {
current = lesson;
break;
}
}
if (!current) {
current = sorted[sorted.length - 1];
}
const allLessonsCompleted = sorted.every((lesson) => completedByLessonId.get(lesson.id) === true);
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: {
id: current.id,
lessonNumber: current.lessonNumber,
title: current.title || ''
},
allLessonsCompleted
});
}
return { courses };
}
/**
* Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal,
* bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile.

View File

@@ -0,0 +1,44 @@
-- Nur Schema-Fix für falukant_predefine.political_office_benefit
-- (Fehler 23502: NULL in political_office_id trotz INSERT in office_type_id)
--
-- Ausführen, dann erneut falukant_political_office_benefits.sql
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'falukant_predefine' AND table_name = 'political_office_benefit'
) THEN
RAISE EXCEPTION 'Tabelle fehlt.';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
ALTER TABLE falukant_predefine.political_office_benefit
RENAME COLUMN political_office_id TO office_type_id;
ELSIF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
UPDATE falukant_predefine.political_office_benefit
SET office_type_id = COALESCE(office_type_id, political_office_id);
ALTER TABLE falukant_predefine.political_office_benefit
DROP COLUMN political_office_id;
END IF;
END $$;

View File

@@ -0,0 +1,163 @@
-- =============================================================================
-- Politische Amtsvorteile (falukant_predefine.political_office_benefit)
-- =============================================================================
--
-- Fehler „SQL Error [08003]: This connection has been closed“
-- -----------------------------------------------------------
-- Das ist meist KEIN Inhaltfehler dieses Skripts, sondern:
-- 1) Eine VORHERIGE Anweisung in derselben Session ist fehlgeschlagen → die
-- Transaktion ist abgebrochen; der Client meldet dann oft nur noch 08003.
-- → Neue Verbindung öffnen, ggf. ROLLBACK; dann erneut ausführen.
-- 2) Fehler 23502: political_office_id NOT NULL, aber INSERT nutzt nur office_type_id
-- (beide Spalten nach Sync) → dieses Skript gleicht das Schema zuerst an.
-- 3) Spalte office_type_id fehlt komplett → Migration 20260401120000 ausführen.
-- 4) PgBouncer (Pool) im Transaction-Modus: lange Skripte mit vielen
-- Statements trennen die Verbindung → einzelne Blöcke nacheinander
-- ausführen oder Datei falukant_political_office_benefits_steps.sql nutzen.
-- 5) Netzwerk / Server-Neustart / Idle-Timeout.
--
-- Empfehlung: Im Terminal mit psql und ON_ERROR_STOP (bricht beim ersten Fehler
-- sauber ab und zeigt die echte Meldung):
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f backend/sql/falukant_political_office_benefits.sql
--
-- JSON value daily_salary:
-- • dailyAmount: fester Tagesbetrag (überschreibt base/perRank)
-- • base + perRank: Auszahlung = base + (Amtsrang * perRank)
-- JSON value tax_exemption:
-- • regions: Text-Array (z. B. "city") oder "*" für alle Ebenen
-- =============================================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'falukant_predefine' AND table_name = 'political_office_benefit'
) THEN
RAISE EXCEPTION 'Tabelle falukant_predefine.political_office_benefit fehlt.';
END IF;
-- Legacy: nur political_office_id (speichert fälschlich die office_type_id)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
ALTER TABLE falukant_predefine.political_office_benefit
RENAME COLUMN political_office_id TO office_type_id;
END IF;
-- Doppel-Spalten nach Sequelize: office_type_id wird befüllt, political_office_id bleibt NULL → 23502
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
UPDATE falukant_predefine.political_office_benefit
SET office_type_id = COALESCE(office_type_id, political_office_id);
ALTER TABLE falukant_predefine.political_office_benefit
DROP COLUMN political_office_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
RAISE EXCEPTION
'Spalte office_type_id fehlt. Migration 20260401120000-politics-benefits-and-daily-salary.cjs ausführen.';
END IF;
END $$;
-- Benefit-Typen (falls noch nicht aus App-Init vorhanden)
INSERT INTO falukant_type.political_office_benefit_type (tr)
SELECT 'tax_exemption'
WHERE NOT EXISTS (
SELECT 1 FROM falukant_type.political_office_benefit_type t WHERE t.tr = 'tax_exemption'
);
INSERT INTO falukant_type.political_office_benefit_type (tr)
SELECT 'daily_salary'
WHERE NOT EXISTS (
SELECT 1 FROM falukant_type.political_office_benefit_type t WHERE t.tr = 'daily_salary'
);
-- Steuerbefreiungen (wie POLITICAL_TAX_EXEMPTIONS im Backend)
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'council'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city","county"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'taxman'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city","county","shire"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'treasurer'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city","county","shire","markgrave","duchy"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'super-state-administrator'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["*"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'chancellor'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
-- Tageslohn: base + Amtsrang * perRank (Rang in App: POLITICAL_OFFICE_RANKS)
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"base":4,"perRank":11}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'daily_salary'
WHERE ot.name IN (
'assessor', 'councillor', 'council', 'beadle', 'town-clerk', 'mayor',
'master-builder', 'village-major', 'judge', 'bailif', 'taxman', 'sheriff',
'consultant', 'treasurer', 'hangman', 'territorial-council',
'territorial-council-speaker', 'ruler-consultant', 'state-administrator',
'super-state-administrator', 'governor', 'ministry-helper', 'minister', 'chancellor'
)
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);

View File

@@ -0,0 +1,141 @@
-- Einzelne Schritte (jeweils in neuer Verbindung oder nach ROLLBACK ausführen,
-- falls der Client nach einem Fehler „connection closed“ meldet.)
--
-- Schritt 0: Schema angleichen (political_office_id vs office_type_id, siehe Haupt-SQL)
-- Schritt 1: benefit_type Zeilen
-- Schritt 26: tax_exemption
-- Schritt 7: daily_salary für alle Ämter
-- ========== Schritt 0 ==========
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'falukant_predefine' AND table_name = 'political_office_benefit'
) THEN
RAISE EXCEPTION 'Tabelle falukant_predefine.political_office_benefit fehlt.';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
ALTER TABLE falukant_predefine.political_office_benefit
RENAME COLUMN political_office_id TO office_type_id;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'political_office_id'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
UPDATE falukant_predefine.political_office_benefit
SET office_type_id = COALESCE(office_type_id, political_office_id);
ALTER TABLE falukant_predefine.political_office_benefit
DROP COLUMN political_office_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_predefine'
AND table_name = 'political_office_benefit'
AND column_name = 'office_type_id'
) THEN
RAISE EXCEPTION 'Spalte office_type_id fehlt Migration 20260401120000 ausführen.';
END IF;
END $$;
-- ========== Schritt 1 ==========
INSERT INTO falukant_type.political_office_benefit_type (tr)
SELECT 'tax_exemption'
WHERE NOT EXISTS (
SELECT 1 FROM falukant_type.political_office_benefit_type t WHERE t.tr = 'tax_exemption'
);
INSERT INTO falukant_type.political_office_benefit_type (tr)
SELECT 'daily_salary'
WHERE NOT EXISTS (
SELECT 1 FROM falukant_type.political_office_benefit_type t WHERE t.tr = 'daily_salary'
);
-- ========== Schritt 2 ==========
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'council'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
-- ========== Schritt 3 ==========
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city","county"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'taxman'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
-- ========== Schritt 4 ==========
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city","county","shire"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'treasurer'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
-- ========== Schritt 5 ==========
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["city","county","shire","markgrave","duchy"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'super-state-administrator'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
-- ========== Schritt 6 ==========
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"regions":["*"]}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'tax_exemption'
WHERE ot.name = 'chancellor'
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);
-- ========== Schritt 7 ==========
INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value)
SELECT ot.id, bt.id, '{"base":4,"perRank":11}'::jsonb
FROM falukant_type.political_office_type ot
JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'daily_salary'
WHERE ot.name IN (
'assessor', 'councillor', 'council', 'beadle', 'town-clerk', 'mayor',
'master-builder', 'village-major', 'judge', 'bailif', 'taxman', 'sheriff',
'consultant', 'treasurer', 'hangman', 'territorial-council',
'territorial-council-speaker', 'ruler-consultant', 'state-administrator',
'super-state-administrator', 'governor', 'ministry-helper', 'minister', 'chancellor'
)
AND NOT EXISTS (
SELECT 1 FROM falukant_predefine.political_office_benefit x
WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id
);

View File

@@ -20,6 +20,7 @@ import PoliticalOfficeType from "../../models/falukant/type/political_office_typ
import ChurchOfficeType from "../../models/falukant/type/church_office_type.js";
import ChurchOfficeRequirement from "../../models/falukant/predefine/church_office_requirement.js";
import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js";
import PoliticalOfficeBenefit from "../../models/falukant/predefine/political_office_benefit.js";
import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js";
import UndergroundType from "../../models/falukant/type/underground.js";
import WeatherType from "../../models/falukant/type/weather.js";
@@ -51,6 +52,7 @@ export const initializeFalukantTypes = async () => {
await initializePoliticalOfficeBenefitTypes();
await initializePoliticalOfficeTypes();
await initializePoliticalOfficePrerequisites();
await initializePoliticalOfficeBenefits();
await initializeChurchOfficeTypes();
await initializeChurchOfficePrerequisites();
await initializeUndergroundTypes();
@@ -352,6 +354,7 @@ const vehicleTypes = [
const politicalOfficeBenefitTypes = [
{ tr: 'salary' },
{ tr: 'daily_salary' },
{ tr: 'reputation' },
{ tr: 'influence' },
{ tr: 'access_level' },
@@ -1038,6 +1041,62 @@ export const initializePoliticalOfficePrerequisites = async () => {
console.log(`[Falukant] OfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`);
};
/** Amtsvorteile für Politik-UI und Tageshonorar (Testsysteme / frische DBs). Produktion alternativ: backend/sql/falukant_political_office_benefits.sql */
export const initializePoliticalOfficeBenefits = async () => {
const taxType = await PoliticalOfficeBenefitType.findOne({ where: { tr: 'tax_exemption' } });
const dailyType = await PoliticalOfficeBenefitType.findOne({ where: { tr: 'daily_salary' } });
if (!dailyType) {
if (falukantDebug) console.warn('[Falukant] daily_salary Benefit-Typ fehlt, überspringe Office-Benefits');
return;
}
const taxByOffice = [
{ officeTr: 'council', regions: ['city'] },
{ officeTr: 'taxman', regions: ['city', 'county'] },
{ officeTr: 'treasurer', regions: ['city', 'county', 'shire'] },
{
officeTr: 'super-state-administrator',
regions: ['city', 'county', 'shire', 'markgrave', 'duchy']
},
{ officeTr: 'chancellor', regions: ['*'] }
];
let taxCreated = 0;
if (taxType) {
for (const { officeTr, regions } of taxByOffice) {
const office = await PoliticalOfficeType.findOne({ where: { name: officeTr } });
if (!office) continue;
const [, wasCreated] = await PoliticalOfficeBenefit.findOrCreate({
where: { officeTypeId: office.id, benefitTypeId: taxType.id },
defaults: {
officeTypeId: office.id,
benefitTypeId: taxType.id,
value: { regions }
}
});
if (wasCreated) taxCreated += 1;
}
}
const allOffices = await PoliticalOfficeType.findAll({ attributes: ['id'] });
let dailyCreated = 0;
for (const office of allOffices) {
const [, wasCreated] = await PoliticalOfficeBenefit.findOrCreate({
where: { officeTypeId: office.id, benefitTypeId: dailyType.id },
defaults: {
officeTypeId: office.id,
benefitTypeId: dailyType.id,
value: { base: 4, perRank: 11 }
}
});
if (wasCreated) dailyCreated += 1;
}
console.log(
`[Falukant] PoliticalOfficeBenefits: Steuer neu=${taxCreated}, Tageslohn neu=${dailyCreated} (gesamt Ämter=${allOffices.length})`
);
};
// — Church Offices —
const churchOffices = [

View File

@@ -10,7 +10,8 @@ const DEFAULT_WIDGET_TYPES = [
{ label: 'News', endpoint: '/api/news?language=de&category=top', description: 'Nachrichten (newsdata.io), Counter für Pagination', orderId: 3 },
{ label: 'Geburtstage', endpoint: '/api/calendar/widget/birthdays', description: 'Nächste Geburtstage von Freunden', orderId: 4 },
{ label: 'Nächste Termine', endpoint: '/api/calendar/widget/upcoming', description: 'Anstehende Kalendertermine', orderId: 5 },
{ label: 'Kalender', endpoint: '/api/calendar/widget/mini', description: 'Mini-Kalenderansicht', orderId: 6 }
{ label: 'Kalender', endpoint: '/api/calendar/widget/mini', description: 'Mini-Kalenderansicht', orderId: 6 },
{ label: 'Sprachkurse', endpoint: '/api/vocab/dashboard-widget', description: 'Vokabelkurse, aktuelle Lektion, Sprung zur Lektion', orderId: 7 }
];
/**

View File

@@ -28,10 +28,12 @@ import ListWidget from './widgets/ListWidget.vue';
import BirthdayWidget from './widgets/BirthdayWidget.vue';
import UpcomingEventsWidget from './widgets/UpcomingEventsWidget.vue';
import MiniCalendarWidget from './widgets/MiniCalendarWidget.vue';
import VocabCoursesWidget from './widgets/VocabCoursesWidget.vue';
function getWidgetComponent(endpoint) {
if (!endpoint || typeof endpoint !== 'string') return ListWidget;
const ep = endpoint.toLowerCase();
if (ep.includes('vocab/dashboard-widget')) return VocabCoursesWidget;
if (ep.includes('falukant')) return FalukantWidget;
if (ep.includes('news')) return NewsWidget;
if (ep.includes('calendar/widget/birthdays')) return BirthdayWidget;
@@ -42,7 +44,7 @@ function getWidgetComponent(endpoint) {
export default {
name: 'DashboardWidget',
components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget },
components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget, VocabCoursesWidget },
props: {
widgetId: { type: String, required: true },
title: { type: String, required: true },

View File

@@ -0,0 +1,165 @@
<template>
<div class="vocab-courses-widget">
<p v-if="!courses.length" class="vocab-courses-widget__empty">
{{ $t('widgets.vocabCourses.empty') }}
<router-link class="vocab-courses-widget__link" to="/socialnetwork/vocab/courses">
{{ $t('widgets.vocabCourses.browseCourses') }}
</router-link>
</p>
<ul v-else class="vocab-courses-widget__list">
<li v-for="c in courses" :key="c.courseId" class="vocab-courses-widget__item">
<div class="vocab-courses-widget__main">
<strong class="vocab-courses-widget__course-title">{{ c.title || $t('widgets.vocabCourses.unnamedCourse') }}</strong>
<span v-if="c.currentLesson" class="vocab-courses-widget__lesson">
{{ $t('widgets.vocabCourses.lessonLine', { number: c.currentLesson.lessonNumber, title: c.currentLesson.title }) }}
</span>
<span v-else class="vocab-courses-widget__lesson vocab-courses-widget__lesson--muted">
{{ $t('widgets.vocabCourses.noLessons') }}
</span>
<span v-if="c.allLessonsCompleted && c.currentLesson" class="vocab-courses-widget__badge">
{{ $t('widgets.vocabCourses.allDone') }}
</span>
</div>
<button
v-if="c.currentLesson"
type="button"
class="vocab-courses-widget__btn"
@click="goToLesson(c.courseId, c.currentLesson.id)"
>
{{ $t('widgets.vocabCourses.openLesson') }}
</button>
<button
v-else
type="button"
class="vocab-courses-widget__btn vocab-courses-widget__btn--secondary"
@click="goToCourse(c.courseId)"
>
{{ $t('widgets.vocabCourses.openCourse') }}
</button>
</li>
</ul>
</div>
</template>
<script>
export default {
name: 'VocabCoursesWidget',
props: {
data: { type: Object, default: null }
},
computed: {
courses() {
const raw = this.data;
if (!raw || typeof raw !== 'object') {
return [];
}
const list = raw.courses ?? raw.Courses;
return Array.isArray(list) ? list : [];
}
},
methods: {
goToLesson(courseId, lessonId) {
this.$router.push({
name: 'VocabLesson',
params: { courseId: String(courseId), lessonId: String(lessonId) }
});
},
goToCourse(courseId) {
this.$router.push({
name: 'VocabCourse',
params: { courseId: String(courseId) }
});
}
}
};
</script>
<style scoped>
.vocab-courses-widget__empty {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-secondary, #555);
line-height: 1.45;
}
.vocab-courses-widget__link {
display: inline-block;
margin-top: 0.35rem;
font-weight: 600;
color: var(--color-primary-orange-dark, #c2410c);
}
.vocab-courses-widget__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.vocab-courses-widget__item {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 8px 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--dashboard-widget-border, #e9ecef);
}
.vocab-courses-widget__item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.vocab-courses-widget__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.vocab-courses-widget__course-title {
font-size: 0.92rem;
color: var(--color-text-primary, #222);
}
.vocab-courses-widget__lesson {
font-size: 0.82rem;
color: var(--color-text-secondary, #555);
line-height: 1.35;
}
.vocab-courses-widget__lesson--muted {
font-style: italic;
}
.vocab-courses-widget__badge {
font-size: 0.75rem;
font-weight: 600;
color: #198754;
margin-top: 2px;
}
.vocab-courses-widget__btn {
flex-shrink: 0;
padding: 6px 12px;
font-size: 0.82rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
background: var(--color-primary-orange, #f97316);
color: #fff;
}
.vocab-courses-widget__btn:hover {
filter: brightness(1.05);
}
.vocab-courses-widget__btn--secondary {
background: var(--color-text-secondary, #6c757d);
}
</style>

View File

@@ -433,7 +433,28 @@
"bookmarkCandidate": "Timan-i kining kandidatura",
"voteError": "Sayop sa paghatag sa boto",
"voteAllError": "Sayop sa paghatag sa mga boto",
"applyError": "Dili mapadala ang aplikasyon."
"applyError": "Dili mapadala ang aplikasyon.",
"benefits": {
"daily_salary": "Adlaw-adlaw nga suhol (usa ra kada adlaw): mga {amount}",
"tax_exemption": "Way buhis: {regions}",
"tax_exemption_all": "Way buhis: tanang lebel sa rehiyon",
"generic": "Benepisyo ({code})"
},
"regionLevels": {
"city": "Siyudad",
"county": "County",
"shire": "Shire",
"markgrave": "Margravate",
"duchy": "Duchy",
"country": "Nasud"
},
"current": {
"benefit": "Benepisyo",
"benefit_all": "Tanang rehiyon",
"holder": "Tag-iya",
"none": "Walay karon nga posisyon.",
"termEnds": "Matapos sa"
}
},
"family": {
"title": "Pamilya",

View File

@@ -81,6 +81,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "Wala ka pa nakasulod sa bisan unsang kurso sa bokabularyo.",
"browseCourses": "Tan-awa ang mga kurso",
"unnamedCourse": "Kurso nga walay titulo",
"lessonLine": "Leksyon #{number}: {title}",
"noLessons": "Walay mga leksyon niini nga kurso.",
"allDone": "Natapos na ang tanang leksyon",
"openLesson": "Adto sa leksyon",
"openCourse": "Ablihi ang kurso"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "Balita",
"birthdays": "Mga adlawng natawhan",
"upcoming": "Umaabot nga mga appointment",
"calendar": "Kalendaryo"
"calendar": "Kalendaryo",
"vocabCourses": "Mga kurso sa pinulongan"
},
"overview": {
"activeWidgetsLabel": "Aktibong mga widget",

View File

@@ -1353,6 +1353,20 @@
"voteError": "Fehler beim Abgeben der Stimme",
"voteAllError": "Fehler beim Abgeben der Stimmen",
"applyError": "Bewerbung konnte nicht eingereicht werden.",
"benefits": {
"daily_salary": "Tagesamtshonorar (einmal pro Tag): ca. {amount}",
"tax_exemption": "Steuerbefreiung: {regions}",
"tax_exemption_all": "Steuerbefreiung: alle Regionsebenen",
"generic": "Vorteil ({code})"
},
"regionLevels": {
"city": "Stadt",
"county": "Landkreis",
"shire": "Grafschaft",
"markgrave": "Markgrafschaft",
"duchy": "Herzogtum",
"country": "Land"
},
"current": {
"office": "Amt",
"region": "Region",

View File

@@ -92,6 +92,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "Du bist derzeit in keinem Vokabelkurs eingeschrieben.",
"browseCourses": "Kurse entdecken",
"unnamedCourse": "Kurs ohne Titel",
"lessonLine": "Lektion #{number}: {title}",
"noLessons": "Noch keine Lektionen in diesem Kurs.",
"allDone": "Alle Lektionen absolviert",
"openLesson": "Zur Lektion",
"openCourse": "Zum Kurs"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "News",
"birthdays": "Geburtstage",
"upcoming": "Nächste Termine",
"calendar": "Kalender"
"calendar": "Kalender",
"vocabCourses": "Sprachkurse"
},
"overview": {
"activeWidgetsLabel": "Aktive Widgets",

View File

@@ -574,6 +574,20 @@
"voteError": "Error while submitting the vote",
"voteAllError": "Error while submitting the votes",
"applyError": "Application could not be submitted.",
"benefits": {
"daily_salary": "Daily office stipend (once per day): about {amount}",
"tax_exemption": "Tax exemption: {regions}",
"tax_exemption_all": "Tax exemption: all regional levels",
"generic": "Benefit ({code})"
},
"regionLevels": {
"city": "City",
"county": "County",
"shire": "Shire",
"markgrave": "Margravate",
"duchy": "Duchy",
"country": "Country"
},
"current": {
"office": "Office",
"region": "Region",

View File

@@ -92,6 +92,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "You are not enrolled in any vocabulary course yet.",
"browseCourses": "Browse courses",
"unnamedCourse": "Untitled course",
"lessonLine": "Lesson #{number}: {title}",
"noLessons": "No lessons in this course yet.",
"allDone": "All lessons completed",
"openLesson": "Go to lesson",
"openCourse": "Open course"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "News",
"birthdays": "Birthdays",
"upcoming": "Upcoming appointments",
"calendar": "Calendar"
"calendar": "Calendar",
"vocabCourses": "Language courses"
},
"overview": {
"activeWidgetsLabel": "Active widgets",

View File

@@ -1261,6 +1261,20 @@
"upcoming": "Cargos pendientes de (re)elección",
"elections": "Elecciones"
},
"benefits": {
"daily_salary": "Estipendio diario (una vez al día): unos {amount}",
"tax_exemption": "Exención fiscal: {regions}",
"tax_exemption_all": "Exención fiscal: todos los niveles regionales",
"generic": "Ventaja ({code})"
},
"regionLevels": {
"city": "Ciudad",
"county": "Condado",
"shire": "Condado (shire)",
"markgrave": "Margraviato",
"duchy": "Ducado",
"country": "País"
},
"current": {
"office": "Cargo",
"region": "Región",

View File

@@ -92,6 +92,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "Aún no estás inscrito en ningún curso de vocabulario.",
"browseCourses": "Ver cursos",
"unnamedCourse": "Curso sin título",
"lessonLine": "Lección n.º {number}: {title}",
"noLessons": "Este curso aún no tiene lecciones.",
"allDone": "Todas las lecciones completadas",
"openLesson": "Ir a la lección",
"openCourse": "Abrir curso"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "Noticias",
"birthdays": "Cumpleaños",
"upcoming": "Próximas citas",
"calendar": "Calendario"
"calendar": "Calendario",
"vocabCourses": "Cursos de idiomas"
},
"overview": {
"activeWidgetsLabel": "Widgets activos",

View File

@@ -25,9 +25,11 @@
</span>
<span>
{{ $t('falukant.politics.current.benefit') }}:
<template v-if="pos.benefit && pos.benefit.length">
<span v-if="pos.benefit.includes('*')">{{ $t('falukant.politics.current.benefit_all') }}</span>
<span v-else>{{ pos.benefit.join(', ') }}</span>
<template v-if="politicsBenefitItems(pos).length">
<template v-for="(b, i) in politicsBenefitItems(pos)" :key="i">
<span>{{ formatPoliticsBenefitItem(b) }}</span>
<span v-if="i < politicsBenefitItems(pos).length - 1">, </span>
</template>
</template>
<template v-else></template>
</span>
@@ -178,6 +180,49 @@ export default {
this.loadCurrentPositions();
},
methods: {
politicsBenefitItems(pos) {
const raw = pos?.benefit;
if (!Array.isArray(raw) || !raw.length) {
return [];
}
if (raw.every((x) => typeof x === 'string') && raw.includes('*')) {
return [this.$t('falukant.politics.current.benefit_all')];
}
return raw;
},
formatPoliticsRegionLevel(key) {
const k = String(key || '');
const path = `falukant.politics.regionLevels.${k}`;
const t = this.$t(path);
return t !== path ? t : k;
},
formatPoliticsBenefitItem(b) {
if (b == null) {
return '';
}
if (typeof b === 'string') {
return b;
}
if (typeof b === 'object' && b.tr) {
if (b.tr === 'tax_exemption' && b.params?.all) {
return this.$t('falukant.politics.benefits.tax_exemption_all');
}
if (b.tr === 'tax_exemption' && Array.isArray(b.params?.regions)) {
const labels = b.params.regions.map((r) => this.formatPoliticsRegionLevel(r)).join(', ');
return this.$t('falukant.politics.benefits.tax_exemption', { regions: labels });
}
if (b.tr === 'daily_salary') {
return this.$t('falukant.politics.benefits.daily_salary', { amount: b.params?.amount ?? '—' });
}
if (b.tr === 'generic_benefit') {
return this.$t('falukant.politics.benefits.generic', { code: b.params?.code || '' });
}
}
return String(b);
},
onTabChange(tab) {
if (tab === 'current') {
this.loadCurrentPositions();

View File

@@ -192,7 +192,8 @@ export default {
'/api/news': 'home.dashboard.widgetLabels.news',
'/api/calendar/widget/birthdays': 'home.dashboard.widgetLabels.birthdays',
'/api/calendar/widget/upcoming': 'home.dashboard.widgetLabels.upcoming',
'/api/calendar/widget/mini': 'home.dashboard.widgetLabels.calendar'
'/api/calendar/widget/mini': 'home.dashboard.widgetLabels.calendar',
'/api/vocab/dashboard-widget': 'home.dashboard.widgetLabels.vocabCourses'
}[endpoint];
return key ? this.$t(key) : fallbackLabel;
},
@@ -211,7 +212,8 @@ export default {
'News',
'Geburtstage',
'Nächste Termine',
'Kalender'
'Kalender',
'Sprachkurse'
];
return {
...widget,