feat(budget): configurable currency in settings (#20)

Add household-wide currency preference for the budget section.
Users can select from 13 currencies (EUR, USD, GBP, SEK, NOK, DKK,
CHF, PLN, CZK, HUF, JPY, AUD, CAD) in Settings → Budget.

- preferences API (GET/PUT) now includes currency field
- budget page loads currency from preferences on render
- formatAmount() uses locale-aware Intl.NumberFormat with chosen currency
- settings page gains a Budget section with a currency select
- all three locales (de, en, it) updated with new i18n keys
This commit is contained in:
Ulas
2026-04-05 11:55:38 +02:00
parent 212a8bdb0a
commit 446b9b1388
8 changed files with 98 additions and 17 deletions
+6 -2
View File
@@ -418,7 +418,7 @@
"typeIncome": "Einnahme",
"titleLabel": "Titel *",
"titlePlaceholder": "z.B. REWE Einkauf",
"amountLabel": "Betrag (€) *",
"amountLabel": "Betrag *",
"amountPlaceholder": "0,00",
"categoryLabel": "Kategorie",
"dateLabel": "Datum *",
@@ -518,7 +518,11 @@
"mealTypesLabel": "Sichtbare Mahlzeiten",
"mealTypesHint": "Nur ausgewaehlte Mahlzeit-Typen werden im Essensplan angezeigt.",
"mealTypesSaved": "Essensplan-Einstellungen gespeichert.",
"mealTypesMinOne": "Mindestens ein Mahlzeit-Typ muss aktiv sein."
"mealTypesMinOne": "Mindestens ein Mahlzeit-Typ muss aktiv sein.",
"sectionBudget": "Budget",
"currencyLabel": "Währung",
"currencyHint": "Legt die Währung für den gesamten Budget-Bereich fest.",
"currencySaved": "Währung gespeichert."
},
"login": {
+6 -2
View File
@@ -418,7 +418,7 @@
"typeIncome": "Income",
"titleLabel": "Title *",
"titlePlaceholder": "e.g. Supermarket",
"amountLabel": "Amount (€) *",
"amountLabel": "Amount *",
"amountPlaceholder": "0.00",
"categoryLabel": "Category",
"dateLabel": "Date *",
@@ -518,7 +518,11 @@
"mealTypesLabel": "Visible meals",
"mealTypesHint": "Only selected meal types are shown in the meal planner.",
"mealTypesSaved": "Meal plan settings saved.",
"mealTypesMinOne": "At least one meal type must be active."
"mealTypesMinOne": "At least one meal type must be active.",
"sectionBudget": "Budget",
"currencyLabel": "Currency",
"currencyHint": "Sets the currency used throughout the budget section.",
"currencySaved": "Currency saved."
},
"login": {
+6 -2
View File
@@ -418,7 +418,7 @@
"typeIncome": "Entrata",
"titleLabel": "Titolo *",
"titlePlaceholder": "es. Supermercato",
"amountLabel": "Importo (€) *",
"amountLabel": "Importo *",
"amountPlaceholder": "0,00",
"categoryLabel": "Categoria",
"dateLabel": "Data *",
@@ -518,7 +518,11 @@
"mealTypesLabel": "Pasti visibili",
"mealTypesHint": "Solo i tipi di pasto selezionati vengono mostrati nel piano pasti.",
"mealTypesSaved": "Impostazioni del piano pasti salvate.",
"mealTypesMinOne": "Almeno un tipo di pasto deve essere attivo."
"mealTypesMinOne": "Almeno un tipo di pasto deve essere attivo.",
"sectionBudget": "Bilancio",
"currencyLabel": "Valuta",
"currencyHint": "Imposta la valuta utilizzata in tutta la sezione budget.",
"currencySaved": "Valuta salvata."
},
"login": {
+7 -1
View File
@@ -47,6 +47,7 @@ let state = {
entries: [],
summary: null,
prevSummary: null, // Vormonat für Monatsvergleich
currency: 'EUR',
};
let _container = null;
@@ -55,7 +56,7 @@ let _container = null;
// --------------------------------------------------------
function formatAmount(n) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
return new Intl.NumberFormat(getLocale(), { style: 'currency', currency: state.currency }).format(n);
}
function formatMonthLabel(ym) {
@@ -104,6 +105,11 @@ export async function render(container, { user }) {
const today = new Date();
state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
try {
const prefsRes = await api.get('/preferences');
state.currency = prefsRes.data?.currency ?? 'EUR';
} catch (_) { /* Fallback auf EUR */ }
container.innerHTML = `
<div class="budget-page">
<h1 class="sr-only">${t('budget.title')}</h1>
+41 -1
View File
@@ -9,6 +9,21 @@ import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js';
const SUPPORTED_CURRENCIES = ['AUD', 'CAD', 'CHF', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'JPY', 'NOK', 'PLN', 'SEK', 'USD'];
function buildCurrencyOptions(selected) {
const display = typeof Intl.DisplayNames !== 'undefined'
? new Intl.DisplayNames([document.documentElement.lang || 'en'], { type: 'currency' })
: null;
return SUPPORTED_CURRENCIES
.map((code) => {
const label = display ? `${code} - ${display.of(code)}` : code;
const sel = code === selected ? ' selected' : '';
return `<option value="${code}"${sel}>${label}</option>`;
})
.join('');
}
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
@@ -23,7 +38,7 @@ export async function render(container, { user }) {
let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'] };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
try {
const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([
@@ -114,6 +129,18 @@ export async function render(container, { user }) {
</div>
</section>
<!-- Budget -->
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionBudget')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.currencyLabel')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.currencyHint')}</p>
<select class="form-input" id="currency-select">
${buildCurrencyOptions(prefs.currency)}
</select>
</div>
</section>
<!-- Mein Konto -->
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
@@ -330,6 +357,19 @@ function bindEvents(container, user) {
});
}
// Währungs-Auswahl
const currencySelect = container.querySelector('#currency-select');
if (currencySelect) {
currencySelect.addEventListener('change', async () => {
try {
await api.put('/preferences', { currency: currencySelect.value });
window.oikos?.showToast(t('settings.currencySaved'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
}
});
}
// Passwort ändern
const passwordForm = container.querySelector('#password-form');
if (passwordForm) {