From 5fcd55be4373c720bc91757ceb211404b082719c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 2 Apr 2026 15:06:50 +0200 Subject: [PATCH] feat(vocab): add dashboard learning summary and related endpoints - 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. --- backend/controllers/vocabController.js | 3 +- ...000-politics-benefits-and-daily-salary.cjs | 82 +++++++++ backend/models/falukant/data/user.js | 5 + .../predefine/political_office_benefit.js | 31 ++-- backend/routers/vocabRouter.js | 2 + backend/services/falukantService.js | 153 +++++++++++++++- backend/services/vocabService.js | 105 +++++++++++ ..._political_office_benefit_align_schema.sql | 44 +++++ .../falukant_political_office_benefits.sql | 163 +++++++++++++++++ ...lukant_political_office_benefits_steps.sql | 141 +++++++++++++++ .../utils/falukant/initializeFalukantTypes.js | 59 +++++++ backend/utils/initializeWidgetTypes.js | 3 +- frontend/src/components/DashboardWidget.vue | 4 +- .../components/widgets/VocabCoursesWidget.vue | 165 ++++++++++++++++++ frontend/src/i18n/locales/ceb/falukant.json | 23 ++- frontend/src/i18n/locales/ceb/general.json | 10 ++ frontend/src/i18n/locales/ceb/home.json | 3 +- frontend/src/i18n/locales/de/falukant.json | 14 ++ frontend/src/i18n/locales/de/general.json | 10 ++ frontend/src/i18n/locales/de/home.json | 3 +- frontend/src/i18n/locales/en/falukant.json | 14 ++ frontend/src/i18n/locales/en/general.json | 10 ++ frontend/src/i18n/locales/en/home.json | 3 +- frontend/src/i18n/locales/es/falukant.json | 14 ++ frontend/src/i18n/locales/es/general.json | 10 ++ frontend/src/i18n/locales/es/home.json | 3 +- frontend/src/views/falukant/PoliticsView.vue | 51 +++++- frontend/src/views/home/LoggedInView.vue | 6 +- 28 files changed, 1095 insertions(+), 39 deletions(-) create mode 100644 backend/migrations/20260401120000-politics-benefits-and-daily-salary.cjs create mode 100644 backend/sql/falukant_political_office_benefit_align_schema.sql create mode 100644 backend/sql/falukant_political_office_benefits.sql create mode 100644 backend/sql/falukant_political_office_benefits_steps.sql create mode 100644 frontend/src/components/widgets/VocabCoursesWidget.vue diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index 44322dd..bf334ad 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -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)); diff --git a/backend/migrations/20260401120000-politics-benefits-and-daily-salary.cjs b/backend/migrations/20260401120000-politics-benefits-and-daily-salary.cjs new file mode 100644 index 0000000..0bf4821 --- /dev/null +++ b/backend/migrations/20260401120000-politics-benefits-and-daily-salary.cjs @@ -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 $$; + `); + } +}; diff --git a/backend/models/falukant/data/user.js b/backend/models/falukant/data/user.js index c0747a7..1c1d331 100644 --- a/backend/models/falukant/data/user.js +++ b/backend/models/falukant/data/user.js @@ -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, diff --git a/backend/models/falukant/predefine/political_office_benefit.js b/backend/models/falukant/predefine/political_office_benefit.js index cde1dff..68bae14 100644 --- a/backend/models/falukant/predefine/political_office_benefit.js +++ b/backend/models/falukant/predefine/political_office_benefit.js @@ -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; diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index 35b3a67..00997f3 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -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); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 1535a9b..1be0886 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -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 => { diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 1ffbfe3..ac60a78 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -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. diff --git a/backend/sql/falukant_political_office_benefit_align_schema.sql b/backend/sql/falukant_political_office_benefit_align_schema.sql new file mode 100644 index 0000000..2b6a5ed --- /dev/null +++ b/backend/sql/falukant_political_office_benefit_align_schema.sql @@ -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 $$; diff --git a/backend/sql/falukant_political_office_benefits.sql b/backend/sql/falukant_political_office_benefits.sql new file mode 100644 index 0000000..61fd693 --- /dev/null +++ b/backend/sql/falukant_political_office_benefits.sql @@ -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 +); diff --git a/backend/sql/falukant_political_office_benefits_steps.sql b/backend/sql/falukant_political_office_benefits_steps.sql new file mode 100644 index 0000000..dcc8d86 --- /dev/null +++ b/backend/sql/falukant_political_office_benefits_steps.sql @@ -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 2–6: 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 +); diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index 4088070..c569061 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -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 = [ diff --git a/backend/utils/initializeWidgetTypes.js b/backend/utils/initializeWidgetTypes.js index 72aa8e3..d803008 100644 --- a/backend/utils/initializeWidgetTypes.js +++ b/backend/utils/initializeWidgetTypes.js @@ -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 } ]; /** diff --git a/frontend/src/components/DashboardWidget.vue b/frontend/src/components/DashboardWidget.vue index 397b1e8..b8724d1 100644 --- a/frontend/src/components/DashboardWidget.vue +++ b/frontend/src/components/DashboardWidget.vue @@ -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 }, diff --git a/frontend/src/components/widgets/VocabCoursesWidget.vue b/frontend/src/components/widgets/VocabCoursesWidget.vue new file mode 100644 index 0000000..264f2a0 --- /dev/null +++ b/frontend/src/components/widgets/VocabCoursesWidget.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/frontend/src/i18n/locales/ceb/falukant.json b/frontend/src/i18n/locales/ceb/falukant.json index 97ad7c6..6ea7f54 100644 --- a/frontend/src/i18n/locales/ceb/falukant.json +++ b/frontend/src/i18n/locales/ceb/falukant.json @@ -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": "Wa’y buhis: {regions}", + "tax_exemption_all": "Wa’y 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", diff --git a/frontend/src/i18n/locales/ceb/general.json b/frontend/src/i18n/locales/ceb/general.json index 2bdce43..7b2e1ab 100644 --- a/frontend/src/i18n/locales/ceb/general.json +++ b/frontend/src/i18n/locales/ceb/general.json @@ -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": { diff --git a/frontend/src/i18n/locales/ceb/home.json b/frontend/src/i18n/locales/ceb/home.json index df60f2e..4eebddf 100644 --- a/frontend/src/i18n/locales/ceb/home.json +++ b/frontend/src/i18n/locales/ceb/home.json @@ -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", diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index feafe43..9be9d57 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -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", diff --git a/frontend/src/i18n/locales/de/general.json b/frontend/src/i18n/locales/de/general.json index 757e920..382c884 100644 --- a/frontend/src/i18n/locales/de/general.json +++ b/frontend/src/i18n/locales/de/general.json @@ -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": { diff --git a/frontend/src/i18n/locales/de/home.json b/frontend/src/i18n/locales/de/home.json index 606b7c2..8561671 100644 --- a/frontend/src/i18n/locales/de/home.json +++ b/frontend/src/i18n/locales/de/home.json @@ -23,7 +23,8 @@ "news": "News", "birthdays": "Geburtstage", "upcoming": "Nächste Termine", - "calendar": "Kalender" + "calendar": "Kalender", + "vocabCourses": "Sprachkurse" }, "overview": { "activeWidgetsLabel": "Aktive Widgets", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index a5fae07..2ba024f 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -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", diff --git a/frontend/src/i18n/locales/en/general.json b/frontend/src/i18n/locales/en/general.json index 23e87ba..25991fb 100644 --- a/frontend/src/i18n/locales/en/general.json +++ b/frontend/src/i18n/locales/en/general.json @@ -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": { diff --git a/frontend/src/i18n/locales/en/home.json b/frontend/src/i18n/locales/en/home.json index 4df9581..bae58fb 100644 --- a/frontend/src/i18n/locales/en/home.json +++ b/frontend/src/i18n/locales/en/home.json @@ -23,7 +23,8 @@ "news": "News", "birthdays": "Birthdays", "upcoming": "Upcoming appointments", - "calendar": "Calendar" + "calendar": "Calendar", + "vocabCourses": "Language courses" }, "overview": { "activeWidgetsLabel": "Active widgets", diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index bfdde0d..e4baaa7 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -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", diff --git a/frontend/src/i18n/locales/es/general.json b/frontend/src/i18n/locales/es/general.json index ada0ed0..6b6734d 100644 --- a/frontend/src/i18n/locales/es/general.json +++ b/frontend/src/i18n/locales/es/general.json @@ -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": { diff --git a/frontend/src/i18n/locales/es/home.json b/frontend/src/i18n/locales/es/home.json index d25e537..707cfe9 100644 --- a/frontend/src/i18n/locales/es/home.json +++ b/frontend/src/i18n/locales/es/home.json @@ -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", diff --git a/frontend/src/views/falukant/PoliticsView.vue b/frontend/src/views/falukant/PoliticsView.vue index 1cb72d4..72143fe 100644 --- a/frontend/src/views/falukant/PoliticsView.vue +++ b/frontend/src/views/falukant/PoliticsView.vue @@ -25,9 +25,11 @@ {{ $t('falukant.politics.current.benefit') }}: -