Initial commit after fork. Moving Budget categories to Database and adding subcategories, with customization options

This commit is contained in:
Rafael Foster
2026-04-25 10:05:27 -03:00
parent a97f8651ac
commit 140fa78ca1
25 changed files with 1322 additions and 161 deletions
-5
View File
@@ -7,11 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.23.17] - 2026-04-25
### Fixed
- Italian (it) locale: translated all missing strings in the recipes section (`nav.recipes`, `meals.savedRecipeLabel`, `meals.savedRecipePlaceholder`, `meals.saveAsRecipe`, `meals.recipeScaleLabel`, and all `recipes.*` keys) — contributed by @albanobattistella
## [0.23.16] - 2026-04-24 ## [0.23.16] - 2026-04-24
### Changed ### Changed
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.23.17", "version": "0.23.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oikos", "name": "oikos",
"version": "0.23.17", "version": "0.23.16",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.23.17", "version": "0.23.16",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "- مثل {{month}}", "trendNeutral": "- مثل {{month}}",
"validAmountRequired": "أدخل مبلغاً صحيحاً", "validAmountRequired": "أدخل مبلغاً صحيحاً",
"dateRequired": "التاريخ مطلوب", "dateRequired": "التاريخ مطلوب",
"catFood": "الطعام", "catFood": "Food",
"catRent": "الإيجار", "catRent": "الإيجار",
"catInsurance": "التأمين", "catInsurance": "التأمين",
"catMobility": "التنقل", "catMobility": "التنقل",
"catLeisure": "الترفيه", "catLeisure": "Leisure and Entertainment",
"catClothing": "الملابس", "catClothing": "الملابس",
"catHealth": "الصحة", "catHealth": "الصحة",
"catEducation": "التعليم", "catEducation": "Education",
"catMisc": "متنوع", "catMisc": "متنوع",
"catEarnedIncome": "دخل العمل", "catEarnedIncome": "دخل العمل",
"catInvestmentIncome": "دخل الاستثمار", "catInvestmentIncome": "دخل الاستثمار",
"catTransferGiftIncome": "التحويلات والهدايا", "catTransferGiftIncome": "التحويلات والهدايا",
"catGovernmentBenefits": "المزايا الاجتماعية", "catGovernmentBenefits": "المزايا الاجتماعية",
"catOtherIncome": "دخل آخر", "catOtherIncome": "دخل آخر",
"loadingIndicator": "جارٍ التحميل…" "loadingIndicator": "جارٍ التحميل…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "الإعدادات", "title": "الإعدادات",
+50 -3
View File
@@ -469,11 +469,11 @@
"trendNeutral": "- wie {{month}}", "trendNeutral": "- wie {{month}}",
"validAmountRequired": "Gültigen Betrag eingeben", "validAmountRequired": "Gültigen Betrag eingeben",
"dateRequired": "Datum ist erforderlich", "dateRequired": "Datum ist erforderlich",
"catFood": "Lebensmittel", "catFood": "Ernährung",
"catRent": "Miete", "catRent": "Miete",
"catInsurance": "Versicherung", "catInsurance": "Versicherung",
"catMobility": "Mobilität", "catMobility": "Mobilität",
"catLeisure": "Freizeit", "catLeisure": "Freizeit und Unterhaltung",
"catClothing": "Kleidung", "catClothing": "Kleidung",
"catHealth": "Gesundheit", "catHealth": "Gesundheit",
"catEducation": "Bildung", "catEducation": "Bildung",
@@ -483,7 +483,54 @@
"catTransferGiftIncome": "Geschenke & Transfers", "catTransferGiftIncome": "Geschenke & Transfers",
"catGovernmentBenefits": "Sozialleistungen", "catGovernmentBenefits": "Sozialleistungen",
"catOtherIncome": "Sonstiges Einkommen", "catOtherIncome": "Sonstiges Einkommen",
"loadingIndicator": "Lade…" "loadingIndicator": "Lade…",
"subcategoryLabel": "Unterkategorie",
"catHousing": "Wohnen / Zuhause",
"catTransport": "Transport",
"catPersonalHealth": "Körperpflege / Gesundheit",
"catShoppingClothing": "Einkäufe und Kleidung",
"catFinancialOther": "Finanzdienstleistungen und Sonstiges",
"subcatRentMortgage": "Miete / Kreditrate",
"subcatCondominium": "Hausgeld",
"subcatUtilities": "Strom / Wasser / Gas",
"subcatInternetTvPhone": "Internet / TV / Telefon",
"subcatRenovationMaintenance": "Renovierung / Instandhaltung",
"subcatCleaning": "Reinigung",
"subcatGroceries": "Supermarkt",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bäckerei",
"subcatFuel": "Kraftstoff",
"subcatParkingTolls": "Parken / Maut",
"subcatPublicTransport": "Öffentliche Verkehrsmittel",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Wartung / Versicherung",
"subcatPharmacy": "Apotheke",
"subcatHealthInsurance": "Krankenversicherung",
"subcatGymSports": "Fitnessstudio / Sport",
"subcatBeautyCosmetics": "Schönheit / Kosmetik",
"subcatTravel": "Reisen",
"subcatStreaming": "Streaming",
"subcatEvents": "Veranstaltungen",
"subcatHobbies": "Hobbys",
"subcatClothesShoes": "Kleidung / Schuhe",
"subcatElectronics": "Elektronik",
"subcatGifts": "Geschenke",
"subcatCoursesCollege": "Kurse / Hochschule",
"subcatSchoolSupplies": "Schulmaterial",
"subcatLanguages": "Sprachen",
"subcatLoansInterest": "Kredite / Zinsen",
"subcatBankFees": "Bankgebühren",
"subcatInsuranceOther": "Versicherungen",
"subcatInvestments": "Investitionen",
"subcatTaxes": "Steuern",
"metaLoadError": "Budget-Kategorien konnten nicht geladen werden.",
"addCategory": "+ Kategorie",
"addSubcategory": "+ Unterkategorie",
"newCategoryPrompt": "Name der neuen Kategorie:",
"newSubcategoryPrompt": "Name der neuen Unterkategorie:",
"categoryAddedToast": "Kategorie hinzugefügt.",
"subcategoryAddedToast": "Unterkategorie hinzugefügt."
}, },
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "- ίδιο με {{month}}", "trendNeutral": "- ίδιο με {{month}}",
"validAmountRequired": "Παρακαλώ εισαγάγετε έγκυρο ποσό", "validAmountRequired": "Παρακαλώ εισαγάγετε έγκυρο ποσό",
"dateRequired": "Η ημερομηνία είναι υποχρεωτική", "dateRequired": "Η ημερομηνία είναι υποχρεωτική",
"catFood": "Τρόφιμα", "catFood": "Food",
"catRent": "Ενοίκιο", "catRent": "Ενοίκιο",
"catInsurance": "Ασφάλεια", "catInsurance": "Ασφάλεια",
"catMobility": "Μεταφορές", "catMobility": "Μεταφορές",
"catLeisure": "Ελεύθερος χρόνος", "catLeisure": "Leisure and Entertainment",
"catClothing": "Ρουχισμός", "catClothing": "Ρουχισμός",
"catHealth": "Υγεία", "catHealth": "Υγεία",
"catEducation": "Εκπαίδευση", "catEducation": "Education",
"catMisc": "Διάφορα", "catMisc": "Διάφορα",
"catEarnedIncome": "Εισόδημα από εργασία", "catEarnedIncome": "Εισόδημα από εργασία",
"catInvestmentIncome": "Επενδυτικό εισόδημα", "catInvestmentIncome": "Επενδυτικό εισόδημα",
"catTransferGiftIncome": "Μεταφορές και δώρα", "catTransferGiftIncome": "Μεταφορές και δώρα",
"catGovernmentBenefits": "Κοινωνικές παροχές", "catGovernmentBenefits": "Κοινωνικές παροχές",
"catOtherIncome": "Άλλο εισόδημα", "catOtherIncome": "Άλλο εισόδημα",
"loadingIndicator": "Φόρτωση…" "loadingIndicator": "Φόρτωση…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "Ρυθμίσεις", "title": "Ρυθμίσεις",
+50 -3
View File
@@ -463,11 +463,11 @@
"trendNeutral": "- same as {{month}}", "trendNeutral": "- same as {{month}}",
"validAmountRequired": "Please enter a valid amount", "validAmountRequired": "Please enter a valid amount",
"dateRequired": "Date is required", "dateRequired": "Date is required",
"catFood": "Groceries", "catFood": "Food",
"catRent": "Rent", "catRent": "Rent",
"catInsurance": "Insurance", "catInsurance": "Insurance",
"catMobility": "Transport", "catMobility": "Transport",
"catLeisure": "Leisure", "catLeisure": "Leisure and Entertainment",
"catClothing": "Clothing", "catClothing": "Clothing",
"catHealth": "Health", "catHealth": "Health",
"catEducation": "Education", "catEducation": "Education",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transfer & Gift Income", "catTransferGiftIncome": "Transfer & Gift Income",
"catGovernmentBenefits": "Government & Social Benefits", "catGovernmentBenefits": "Government & Social Benefits",
"catOtherIncome": "Other Income", "catOtherIncome": "Other Income",
"loadingIndicator": "Loading…" "loadingIndicator": "Loading…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
+49 -2
View File
@@ -467,7 +467,7 @@
"catRent": "Alquiler", "catRent": "Alquiler",
"catInsurance": "Seguro", "catInsurance": "Seguro",
"catMobility": "Movilidad", "catMobility": "Movilidad",
"catLeisure": "Ocio", "catLeisure": "Ocio y entretenimiento",
"catClothing": "Ropa", "catClothing": "Ropa",
"catHealth": "Salud", "catHealth": "Salud",
"catEducation": "Educación", "catEducation": "Educación",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferencias y Regalos", "catTransferGiftIncome": "Transferencias y Regalos",
"catGovernmentBenefits": "Prestaciones Sociales", "catGovernmentBenefits": "Prestaciones Sociales",
"catOtherIncome": "Otros Ingresos", "catOtherIncome": "Otros Ingresos",
"loadingIndicator": "Cargando…" "loadingIndicator": "Cargando…",
"subcategoryLabel": "Subcategoría",
"catHousing": "Vivienda / Casa",
"catTransport": "Transporte",
"catPersonalHealth": "Cuidado personal / Salud",
"catShoppingClothing": "Compras y ropa",
"catFinancialOther": "Servicios financieros y otros",
"subcatRentMortgage": "Alquiler / Hipoteca",
"subcatCondominium": "Comunidad",
"subcatUtilities": "Luz / Agua / Gas",
"subcatInternetTvPhone": "Internet / TV / Teléfono",
"subcatRenovationMaintenance": "Reforma / Mantenimiento",
"subcatCleaning": "Limpieza",
"subcatGroceries": "Supermercado",
"subcatRestaurantsBars": "Restaurantes / Bares",
"subcatSnacksFastFood": "Snacks / Comida rápida",
"subcatBakery": "Panadería",
"subcatFuel": "Combustible",
"subcatParkingTolls": "Aparcamiento / Peajes",
"subcatPublicTransport": "Transporte público",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Mantenimiento / Seguro",
"subcatPharmacy": "Farmacia",
"subcatHealthInsurance": "Seguro médico",
"subcatGymSports": "Gimnasio / Deportes",
"subcatBeautyCosmetics": "Belleza / Cosmética",
"subcatTravel": "Viajes",
"subcatStreaming": "Streaming",
"subcatEvents": "Eventos",
"subcatHobbies": "Aficiones",
"subcatClothesShoes": "Ropa / Calzado",
"subcatElectronics": "Electrónica",
"subcatGifts": "Regalos",
"subcatCoursesCollege": "Cursos / Universidad",
"subcatSchoolSupplies": "Material escolar",
"subcatLanguages": "Idiomas",
"subcatLoansInterest": "Préstamos / Intereses",
"subcatBankFees": "Comisiones bancarias",
"subcatInsuranceOther": "Seguros",
"subcatInvestments": "Inversiones",
"subcatTaxes": "Impuestos",
"metaLoadError": "No se pudieron cargar las categorías del presupuesto.",
"addCategory": "+ categoría",
"addSubcategory": "+ subcategoría",
"newCategoryPrompt": "Nombre de la nueva categoría:",
"newSubcategoryPrompt": "Nombre de la nueva subcategoría:",
"categoryAddedToast": "Categoría añadida.",
"subcategoryAddedToast": "Subcategoría añadida."
}, },
"settings": { "settings": {
"title": "Ajustes", "title": "Ajustes",
+49 -2
View File
@@ -467,7 +467,7 @@
"catRent": "Loyer", "catRent": "Loyer",
"catInsurance": "Assurance", "catInsurance": "Assurance",
"catMobility": "Transport", "catMobility": "Transport",
"catLeisure": "Loisirs", "catLeisure": "Loisirs et divertissement",
"catClothing": "Vêtements", "catClothing": "Vêtements",
"catHealth": "Santé", "catHealth": "Santé",
"catEducation": "Éducation", "catEducation": "Éducation",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferts et Cadeaux", "catTransferGiftIncome": "Transferts et Cadeaux",
"catGovernmentBenefits": "Allocations Sociales", "catGovernmentBenefits": "Allocations Sociales",
"catOtherIncome": "Autres Revenus", "catOtherIncome": "Autres Revenus",
"loadingIndicator": "Chargement…" "loadingIndicator": "Chargement…",
"subcategoryLabel": "Sous-catégorie",
"catHousing": "Logement / Maison",
"catTransport": "Transport",
"catPersonalHealth": "Soins personnels / Santé",
"catShoppingClothing": "Achats et vêtements",
"catFinancialOther": "Services financiers et autres",
"subcatRentMortgage": "Loyer / Crédit immobilier",
"subcatCondominium": "Copropriété",
"subcatUtilities": "Électricité / Eau / Gaz",
"subcatInternetTvPhone": "Internet / TV / Téléphone",
"subcatRenovationMaintenance": "Rénovation / Entretien",
"subcatCleaning": "Nettoyage",
"subcatGroceries": "Supermarché",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast-food",
"subcatBakery": "Boulangerie",
"subcatFuel": "Carburant",
"subcatParkingTolls": "Parking / Péages",
"subcatPublicTransport": "Transports publics",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Entretien / Assurance",
"subcatPharmacy": "Pharmacie",
"subcatHealthInsurance": "Assurance santé",
"subcatGymSports": "Salle de sport / Sports",
"subcatBeautyCosmetics": "Beauté / Cosmétiques",
"subcatTravel": "Voyages",
"subcatStreaming": "Streaming",
"subcatEvents": "Événements",
"subcatHobbies": "Loisirs",
"subcatClothesShoes": "Vêtements / Chaussures",
"subcatElectronics": "Électronique",
"subcatGifts": "Cadeaux",
"subcatCoursesCollege": "Cours / Université",
"subcatSchoolSupplies": "Fournitures scolaires",
"subcatLanguages": "Langues",
"subcatLoansInterest": "Prêts / Intérêts",
"subcatBankFees": "Frais bancaires",
"subcatInsuranceOther": "Assurances",
"subcatInvestments": "Investissements",
"subcatTaxes": "Impôts",
"metaLoadError": "Impossible de charger les catégories du budget.",
"addCategory": "+ catégorie",
"addSubcategory": "+ sous-catégorie",
"newCategoryPrompt": "Nom de la nouvelle catégorie :",
"newSubcategoryPrompt": "Nom de la nouvelle sous-catégorie :",
"categoryAddedToast": "Catégorie ajoutée.",
"subcategoryAddedToast": "Sous-catégorie ajoutée."
}, },
"settings": { "settings": {
"title": "Paramètres", "title": "Paramètres",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "- {{month}} जैसा", "trendNeutral": "- {{month}} जैसा",
"validAmountRequired": "वैध राशि दर्ज करें", "validAmountRequired": "वैध राशि दर्ज करें",
"dateRequired": "तारीख आवश्यक है", "dateRequired": "तारीख आवश्यक है",
"catFood": "भोजन", "catFood": "Food",
"catRent": "किराया", "catRent": "किराया",
"catInsurance": "बीमा", "catInsurance": "बीमा",
"catMobility": "परिवहन", "catMobility": "परिवहन",
"catLeisure": "मनोरंजन", "catLeisure": "Leisure and Entertainment",
"catClothing": "कपड़े", "catClothing": "कपड़े",
"catHealth": "स्वास्थ्य", "catHealth": "स्वास्थ्य",
"catEducation": "शिक्षा", "catEducation": "Education",
"catMisc": "विविध", "catMisc": "विविध",
"catEarnedIncome": "कमाई आय", "catEarnedIncome": "कमाई आय",
"catInvestmentIncome": "निवेश आय", "catInvestmentIncome": "निवेश आय",
"catTransferGiftIncome": "स्थानांतरण और उपहार", "catTransferGiftIncome": "स्थानांतरण और उपहार",
"catGovernmentBenefits": "सामाजिक लाभ", "catGovernmentBenefits": "सामाजिक लाभ",
"catOtherIncome": "अन्य आय", "catOtherIncome": "अन्य आय",
"loadingIndicator": "लोड हो रहा है…" "loadingIndicator": "लोड हो रहा है…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "सेटिंग्स", "title": "सेटिंग्स",
+76 -29
View File
@@ -43,7 +43,7 @@
"main": "Navigazione principale", "main": "Navigazione principale",
"navigation": "Navigazione", "navigation": "Navigazione",
"quickActions": "Azioni rapide", "quickActions": "Azioni rapide",
"recipes": "Ricette", "recipes": "Recipes",
"more": "Altro" "more": "Altro"
}, },
"dashboard": { "dashboard": {
@@ -255,10 +255,10 @@
"recipeUrlLabel": "Link ricetta (opzionale)", "recipeUrlLabel": "Link ricetta (opzionale)",
"recipeUrlPlaceholder": "https://…", "recipeUrlPlaceholder": "https://…",
"openRecipe": "Apri ricetta", "openRecipe": "Apri ricetta",
"savedRecipeLabel": "Ricette salvate", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Seleziona ricetta", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Salva come ricetta", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scala ingredienti" "recipeScaleLabel": "Scale ingredients"
}, },
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
@@ -463,11 +463,11 @@
"trendNeutral": "- come {{month}}", "trendNeutral": "- come {{month}}",
"validAmountRequired": "Inserisci un importo valido", "validAmountRequired": "Inserisci un importo valido",
"dateRequired": "La data è obbligatoria", "dateRequired": "La data è obbligatoria",
"catFood": "Spesa alimentare", "catFood": "Alimentazione",
"catRent": "Affitto", "catRent": "Affitto",
"catInsurance": "Assicurazione", "catInsurance": "Assicurazione",
"catMobility": "Trasporti", "catMobility": "Trasporti",
"catLeisure": "Tempo libero", "catLeisure": "Tempo libero e intrattenimento",
"catClothing": "Abbigliamento", "catClothing": "Abbigliamento",
"catHealth": "Salute", "catHealth": "Salute",
"catEducation": "Istruzione", "catEducation": "Istruzione",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Trasferimenti e Regali", "catTransferGiftIncome": "Trasferimenti e Regali",
"catGovernmentBenefits": "Prestazioni Sociali", "catGovernmentBenefits": "Prestazioni Sociali",
"catOtherIncome": "Altro Reddito", "catOtherIncome": "Altro Reddito",
"loadingIndicator": "Caricamento…" "loadingIndicator": "Caricamento…",
"subcategoryLabel": "Sottocategoria",
"catHousing": "Abitazione / Casa",
"catTransport": "Trasporti",
"catPersonalHealth": "Cura personale / Salute",
"catShoppingClothing": "Acquisti e abbigliamento",
"catFinancialOther": "Servizi finanziari e altro",
"subcatRentMortgage": "Affitto / Mutuo",
"subcatCondominium": "Condominio",
"subcatUtilities": "Luce / Acqua / Gas",
"subcatInternetTvPhone": "Internet / TV / Telefono",
"subcatRenovationMaintenance": "Ristrutturazione / Manutenzione",
"subcatCleaning": "Pulizia",
"subcatGroceries": "Supermercato",
"subcatRestaurantsBars": "Ristoranti / Bar",
"subcatSnacksFastFood": "Snack / Fast food",
"subcatBakery": "Panetteria",
"subcatFuel": "Carburante",
"subcatParkingTolls": "Parcheggio / Pedaggi",
"subcatPublicTransport": "Trasporto pubblico",
"subcatAppsTaxi": "App / Taxi",
"subcatMaintenanceInsurance": "Manutenzione / Assicurazione",
"subcatPharmacy": "Farmacia",
"subcatHealthInsurance": "Assicurazione sanitaria",
"subcatGymSports": "Palestra / Sport",
"subcatBeautyCosmetics": "Bellezza / Cosmetici",
"subcatTravel": "Viaggi",
"subcatStreaming": "Streaming",
"subcatEvents": "Eventi",
"subcatHobbies": "Hobby",
"subcatClothesShoes": "Vestiti / Scarpe",
"subcatElectronics": "Elettronica",
"subcatGifts": "Regali",
"subcatCoursesCollege": "Corsi / Università",
"subcatSchoolSupplies": "Materiale scolastico",
"subcatLanguages": "Lingue",
"subcatLoansInterest": "Prestiti / Interessi",
"subcatBankFees": "Commissioni bancarie",
"subcatInsuranceOther": "Assicurazioni",
"subcatInvestments": "Investimenti",
"subcatTaxes": "Imposte",
"metaLoadError": "Impossibile caricare le categorie del budget.",
"addCategory": "+ categoria",
"addSubcategory": "+ sottocategoria",
"newCategoryPrompt": "Nome della nuova categoria:",
"newSubcategoryPrompt": "Nome della nuova sottocategoria:",
"categoryAddedToast": "Categoria aggiunta.",
"subcategoryAddedToast": "Sottocategoria aggiunta."
}, },
"settings": { "settings": {
"title": "Impostazioni", "title": "Impostazioni",
@@ -659,28 +706,28 @@
"unitMonths": "mesi" "unitMonths": "mesi"
}, },
"recipes": { "recipes": {
"title": "Ricette", "title": "Recipes",
"addRecipe": "Aggiungi ricetta", "addRecipe": "Add recipe",
"editRecipe": "Modifica ricetta", "editRecipe": "Edit recipe",
"emptyTitle": "Nessuna ricetta", "emptyTitle": "No recipes yet",
"emptyDescription": "Salva le tue ricette preferite e riutilizzale nella pianificazione dei pasti.", "emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
"titleLabel": "Titolo *", "titleLabel": "Title *",
"titlePlaceholder": "es. Pasta Carbonara", "titlePlaceholder": "e.g. Pasta Carbonara",
"notesLabel": "Note", "notesLabel": "Notes",
"notesPlaceholder": "Opzionale...", "notesPlaceholder": "Optional...",
"urlLabel": "Link della ricetta", "urlLabel": "Recipe link",
"urlPlaceholder": "https://...", "urlPlaceholder": "https://...",
"ingredientsLabel": "Ingredienti", "ingredientsLabel": "Ingredients",
"addToMeals": "Aggiungi al piano alimentare", "addToMeals": "Add to meal plan",
"openLink": "Apri il link della ricetta", "openLink": "Open recipe link",
"deleteConfirm": "Eliminare la ricetta \"{{title}}\"?", "deleteConfirm": "Delete recipe \"{{title}}\"?",
"created": "Ricetta salvata.", "created": "Recipe saved.",
"updated": "Ricetta aggiornata.", "updated": "Recipe updated.",
"deleted": "Ricetta eliminata.", "deleted": "Recipe deleted.",
"titleRequired": "È richiesto il titolo", "titleRequired": "Title is required",
"duplicate": "Duplicato", "duplicate": "Duplicate",
"duplicated": "Ricetta duplicata.", "duplicated": "Recipe duplicated.",
"copySuffix": "copia" "copySuffix": "copy"
}, },
"search": { "search": {
"title": "Ricerca", "title": "Ricerca",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "- {{month}} と同じ", "trendNeutral": "- {{month}} と同じ",
"validAmountRequired": "有効な金額を入力してください", "validAmountRequired": "有効な金額を入力してください",
"dateRequired": "日付は必須です", "dateRequired": "日付は必須です",
"catFood": "食費", "catFood": "Food",
"catRent": "家賃", "catRent": "家賃",
"catInsurance": "保険", "catInsurance": "保険",
"catMobility": "交通費", "catMobility": "交通費",
"catLeisure": "娯楽", "catLeisure": "Leisure and Entertainment",
"catClothing": "衣服", "catClothing": "衣服",
"catHealth": "医療", "catHealth": "医療",
"catEducation": "教育", "catEducation": "Education",
"catMisc": "その他", "catMisc": "その他",
"catEarnedIncome": "給与・報酬", "catEarnedIncome": "給与・報酬",
"catInvestmentIncome": "投資収入", "catInvestmentIncome": "投資収入",
"catTransferGiftIncome": "譲渡・贈与", "catTransferGiftIncome": "譲渡・贈与",
"catGovernmentBenefits": "社会保障給付", "catGovernmentBenefits": "社会保障給付",
"catOtherIncome": "その他の収入", "catOtherIncome": "その他の収入",
"loadingIndicator": "読み込み中…" "loadingIndicator": "読み込み中…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "設定", "title": "設定",
+49 -2
View File
@@ -467,7 +467,7 @@
"catRent": "Aluguel", "catRent": "Aluguel",
"catInsurance": "Seguro", "catInsurance": "Seguro",
"catMobility": "Transporte", "catMobility": "Transporte",
"catLeisure": "Lazer", "catLeisure": "Lazer e Entretenimento",
"catClothing": "Roupas", "catClothing": "Roupas",
"catHealth": "Saúde", "catHealth": "Saúde",
"catEducation": "Educação", "catEducation": "Educação",
@@ -477,7 +477,54 @@
"catTransferGiftIncome": "Transferências e Presentes", "catTransferGiftIncome": "Transferências e Presentes",
"catGovernmentBenefits": "Benefícios Sociais", "catGovernmentBenefits": "Benefícios Sociais",
"catOtherIncome": "Outras Rendas", "catOtherIncome": "Outras Rendas",
"loadingIndicator": "Carregando…" "loadingIndicator": "Carregando…",
"subcategoryLabel": "Subcategoria",
"catHousing": "Habitação / Casa",
"catTransport": "Transporte",
"catPersonalHealth": "Cuidados Pessoais / Saúde",
"catShoppingClothing": "Compras e Vestuário",
"catFinancialOther": "Serviços Financeiros e Outros",
"subcatRentMortgage": "Aluguel / Prestação",
"subcatCondominium": "Condomínio",
"subcatUtilities": "Luz / Água / Gás",
"subcatInternetTvPhone": "Internet / TV / Telefone",
"subcatRenovationMaintenance": "Reforma / Manutenção",
"subcatCleaning": "Limpeza",
"subcatGroceries": "Supermercado",
"subcatRestaurantsBars": "Restaurante / Bares",
"subcatSnacksFastFood": "Lanches / Fast Food",
"subcatBakery": "Padaria",
"subcatFuel": "Combustível",
"subcatParkingTolls": "Estacionamento / Pedágio",
"subcatPublicTransport": "Transporte Público",
"subcatAppsTaxi": "Aplicativos / Táxi",
"subcatMaintenanceInsurance": "Manutenção / Seguro",
"subcatPharmacy": "Farmácia",
"subcatHealthInsurance": "Plano de Saúde",
"subcatGymSports": "Academia / Esportes",
"subcatBeautyCosmetics": "Beleza / Cosméticos",
"subcatTravel": "Viagens",
"subcatStreaming": "Streaming",
"subcatEvents": "Eventos",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Roupas / Calçados",
"subcatElectronics": "Eletrônicos",
"subcatGifts": "Presentes",
"subcatCoursesCollege": "Cursos / Faculdade",
"subcatSchoolSupplies": "Material Escolar",
"subcatLanguages": "Idiomas",
"subcatLoansInterest": "Empréstimos / Juros",
"subcatBankFees": "Tarifas Bancárias",
"subcatInsuranceOther": "Seguros",
"subcatInvestments": "Investimentos",
"subcatTaxes": "Impostos",
"metaLoadError": "Categorias do orçamento não puderam ser carregadas.",
"addCategory": "+ categoria",
"addSubcategory": "+ subcategoria",
"newCategoryPrompt": "Nome da nova categoria:",
"newSubcategoryPrompt": "Nome da nova subcategoria:",
"categoryAddedToast": "Categoria adicionada.",
"subcategoryAddedToast": "Subcategoria adicionada."
}, },
"settings": { "settings": {
"title": "Configurações", "title": "Configurações",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "— как в {{month}}", "trendNeutral": "— как в {{month}}",
"validAmountRequired": "Введите корректную сумму", "validAmountRequired": "Введите корректную сумму",
"dateRequired": "Дата обязательна", "dateRequired": "Дата обязательна",
"catFood": "Продукты", "catFood": "Food",
"catRent": "Аренда", "catRent": "Аренда",
"catInsurance": "Страховка", "catInsurance": "Страховка",
"catMobility": "Транспорт", "catMobility": "Транспорт",
"catLeisure": "Досуг", "catLeisure": "Leisure and Entertainment",
"catClothing": "Одежда", "catClothing": "Одежда",
"catHealth": "Здоровье", "catHealth": "Здоровье",
"catEducation": "Образование", "catEducation": "Education",
"catMisc": "Разное", "catMisc": "Разное",
"catEarnedIncome": "Трудовой доход", "catEarnedIncome": "Трудовой доход",
"catInvestmentIncome": "Инвестиционный доход", "catInvestmentIncome": "Инвестиционный доход",
"catTransferGiftIncome": "Переводы и подарки", "catTransferGiftIncome": "Переводы и подарки",
"catGovernmentBenefits": "Социальные пособия", "catGovernmentBenefits": "Социальные пособия",
"catOtherIncome": "Прочие доходы", "catOtherIncome": "Прочие доходы",
"loadingIndicator": "Загрузка…" "loadingIndicator": "Загрузка…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "- samma som {{month}}", "trendNeutral": "- samma som {{month}}",
"validAmountRequired": "Ange ett giltigt belopp", "validAmountRequired": "Ange ett giltigt belopp",
"dateRequired": "Datum krävs", "dateRequired": "Datum krävs",
"catFood": "Specerier", "catFood": "Food",
"catRent": "Hyra", "catRent": "Hyra",
"catInsurance": "Försäkring", "catInsurance": "Försäkring",
"catMobility": "Transport", "catMobility": "Transport",
"catLeisure": "Fritid", "catLeisure": "Leisure and Entertainment",
"catClothing": "Kläder", "catClothing": "Kläder",
"catHealth": "Hälsa", "catHealth": "Hälsa",
"catEducation": "Utbildning", "catEducation": "Education",
"catMisc": "Diverse", "catMisc": "Diverse",
"catEarnedIncome": "Arbetsinkomst", "catEarnedIncome": "Arbetsinkomst",
"catInvestmentIncome": "Investeringsinkomst", "catInvestmentIncome": "Investeringsinkomst",
"catTransferGiftIncome": "Överföringar och gåvor", "catTransferGiftIncome": "Överföringar och gåvor",
"catGovernmentBenefits": "Socialförmåner", "catGovernmentBenefits": "Socialförmåner",
"catOtherIncome": "Övrig inkomst", "catOtherIncome": "Övrig inkomst",
"loadingIndicator": "Laddar…" "loadingIndicator": "Laddar…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "Inställningar", "title": "Inställningar",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "- {{month}} ile aynı", "trendNeutral": "- {{month}} ile aynı",
"validAmountRequired": "Lütfen geçerli bir tutar girin", "validAmountRequired": "Lütfen geçerli bir tutar girin",
"dateRequired": "Tarih zorunludur", "dateRequired": "Tarih zorunludur",
"catFood": "Market", "catFood": "Food",
"catRent": "Kira", "catRent": "Kira",
"catInsurance": "Sigorta", "catInsurance": "Sigorta",
"catMobility": "Ulaşım", "catMobility": "Ulaşım",
"catLeisure": "Eğlence", "catLeisure": "Leisure and Entertainment",
"catClothing": "Giyim", "catClothing": "Giyim",
"catHealth": "Sağlık", "catHealth": "Sağlık",
"catEducation": "Eğitim", "catEducation": "Education",
"catMisc": "Diğer", "catMisc": "Diğer",
"catEarnedIncome": "Kazanç Geliri", "catEarnedIncome": "Kazanç Geliri",
"catInvestmentIncome": "Yatırım Geliri", "catInvestmentIncome": "Yatırım Geliri",
"catTransferGiftIncome": "Transferler ve Hediyeler", "catTransferGiftIncome": "Transferler ve Hediyeler",
"catGovernmentBenefits": "Sosyal Yardımlar", "catGovernmentBenefits": "Sosyal Yardımlar",
"catOtherIncome": "Diğer Gelir", "catOtherIncome": "Diğer Gelir",
"loadingIndicator": "Yükleniyor…" "loadingIndicator": "Yükleniyor…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "Ayarlar", "title": "Ayarlar",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "— так само, як у {{month}}", "trendNeutral": "— так само, як у {{month}}",
"validAmountRequired": "Будь ласка, введіть коректну суму", "validAmountRequired": "Будь ласка, введіть коректну суму",
"dateRequired": "Дата є обов'язковою", "dateRequired": "Дата є обов'язковою",
"catFood": "Продукти", "catFood": "Food",
"catRent": "Оренда", "catRent": "Оренда",
"catInsurance": "Страхування", "catInsurance": "Страхування",
"catMobility": "Транспорт", "catMobility": "Транспорт",
"catLeisure": "Дозвілля", "catLeisure": "Leisure and Entertainment",
"catClothing": "Одяг", "catClothing": "Одяг",
"catHealth": "Здоров'я", "catHealth": "Здоров'я",
"catEducation": "Освіта", "catEducation": "Education",
"catMisc": "Різне", "catMisc": "Різне",
"catEarnedIncome": "Трудовий дохід", "catEarnedIncome": "Трудовий дохід",
"catInvestmentIncome": "Інвестиційний дохід", "catInvestmentIncome": "Інвестиційний дохід",
"catTransferGiftIncome": "Переводи та подарунки", "catTransferGiftIncome": "Переводи та подарунки",
"catGovernmentBenefits": "Соціальні виплати", "catGovernmentBenefits": "Соціальні виплати",
"catOtherIncome": "Інші доходи", "catOtherIncome": "Інші доходи",
"loadingIndicator": "Завантаження…" "loadingIndicator": "Завантаження…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "Налаштування", "title": "Налаштування",
+51 -4
View File
@@ -463,21 +463,68 @@
"trendNeutral": "- 与 {{month}} 相同", "trendNeutral": "- 与 {{month}} 相同",
"validAmountRequired": "请输入有效金额", "validAmountRequired": "请输入有效金额",
"dateRequired": "日期为必填项", "dateRequired": "日期为必填项",
"catFood": "食品", "catFood": "Food",
"catRent": "租金", "catRent": "租金",
"catInsurance": "保险", "catInsurance": "保险",
"catMobility": "出行", "catMobility": "出行",
"catLeisure": "休闲", "catLeisure": "Leisure and Entertainment",
"catClothing": "服装", "catClothing": "服装",
"catHealth": "健康", "catHealth": "健康",
"catEducation": "教育", "catEducation": "Education",
"catMisc": "其他", "catMisc": "其他",
"catEarnedIncome": "劳动收入", "catEarnedIncome": "劳动收入",
"catInvestmentIncome": "投资收入", "catInvestmentIncome": "投资收入",
"catTransferGiftIncome": "转账和礼物", "catTransferGiftIncome": "转账和礼物",
"catGovernmentBenefits": "社会福利", "catGovernmentBenefits": "社会福利",
"catOtherIncome": "其他收入", "catOtherIncome": "其他收入",
"loadingIndicator": "加载中…" "loadingIndicator": "加载中…",
"subcategoryLabel": "Subcategory",
"catHousing": "Housing / Home",
"catTransport": "Transport",
"catPersonalHealth": "Personal Care / Health",
"catShoppingClothing": "Shopping and Clothing",
"catFinancialOther": "Financial Services and Other",
"subcatRentMortgage": "Rent / Mortgage",
"subcatCondominium": "Condominium fees",
"subcatUtilities": "Electricity / Water / Gas",
"subcatInternetTvPhone": "Internet / TV / Phone",
"subcatRenovationMaintenance": "Renovation / Maintenance",
"subcatCleaning": "Cleaning",
"subcatGroceries": "Groceries",
"subcatRestaurantsBars": "Restaurants / Bars",
"subcatSnacksFastFood": "Snacks / Fast Food",
"subcatBakery": "Bakery",
"subcatFuel": "Fuel",
"subcatParkingTolls": "Parking / Tolls",
"subcatPublicTransport": "Public transport",
"subcatAppsTaxi": "Apps / Taxi",
"subcatMaintenanceInsurance": "Maintenance / Insurance",
"subcatPharmacy": "Pharmacy",
"subcatHealthInsurance": "Health insurance",
"subcatGymSports": "Gym / Sports",
"subcatBeautyCosmetics": "Beauty / Cosmetics",
"subcatTravel": "Travel",
"subcatStreaming": "Streaming",
"subcatEvents": "Events",
"subcatHobbies": "Hobbies",
"subcatClothesShoes": "Clothes / Shoes",
"subcatElectronics": "Electronics",
"subcatGifts": "Gifts",
"subcatCoursesCollege": "Courses / College",
"subcatSchoolSupplies": "School supplies",
"subcatLanguages": "Languages",
"subcatLoansInterest": "Loans / Interest",
"subcatBankFees": "Bank fees",
"subcatInsuranceOther": "Insurance",
"subcatInvestments": "Investments",
"subcatTaxes": "Taxes",
"metaLoadError": "Budget categories could not be loaded.",
"addCategory": "+ category",
"addSubcategory": "+ subcategory",
"newCategoryPrompt": "Name of the new category:",
"newSubcategoryPrompt": "Name of the new subcategory:",
"categoryAddedToast": "Category added.",
"subcategoryAddedToast": "Subcategory added."
}, },
"settings": { "settings": {
"title": "设置", "title": "设置",
+191 -38
View File
@@ -15,29 +15,16 @@ import { esc } from '/utils/html.js';
// Konstanten // Konstanten
// -------------------------------------------------------- // --------------------------------------------------------
const EXPENSE_CATEGORIES = [ const CATEGORY_I18N = () => ({
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
];
const INCOME_CATEGORIES = [
'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers',
'Sozialleistungen', 'Sonstiges Einkommen',
];
const CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES];
const CATEGORY_LABELS = () => ({
// Expense categories // Expense categories
'Lebensmittel': t('budget.catFood'), housing: t('budget.catHousing'),
'Miete': t('budget.catRent'), food: t('budget.catFood'),
'Versicherung': t('budget.catInsurance'), transport: t('budget.catTransport'),
'Mobilität': t('budget.catMobility'), personal_health: t('budget.catPersonalHealth'),
'Freizeit': t('budget.catLeisure'), leisure: t('budget.catLeisure'),
'Kleidung': t('budget.catClothing'), shopping_clothing: t('budget.catShoppingClothing'),
'Gesundheit': t('budget.catHealth'), education: t('budget.catEducation'),
'Bildung': t('budget.catEducation'), financial_other: t('budget.catFinancialOther'),
'Sonstiges': t('budget.catMisc'),
// Income categories // Income categories
'Erwerbseinkommen': t('budget.catEarnedIncome'), 'Erwerbseinkommen': t('budget.catEarnedIncome'),
'Kapitalerträge': t('budget.catInvestmentIncome'), 'Kapitalerträge': t('budget.catInvestmentIncome'),
@@ -46,6 +33,82 @@ const CATEGORY_LABELS = () => ({
'Sonstiges Einkommen': t('budget.catOtherIncome'), 'Sonstiges Einkommen': t('budget.catOtherIncome'),
}); });
const SUBCATEGORY_I18N = () => ({
rent_mortgage: t('budget.subcatRentMortgage'),
condominium: t('budget.subcatCondominium'),
utilities: t('budget.subcatUtilities'),
internet_tv_phone: t('budget.subcatInternetTvPhone'),
renovation_maintenance: t('budget.subcatRenovationMaintenance'),
cleaning: t('budget.subcatCleaning'),
groceries: t('budget.subcatGroceries'),
restaurants_bars: t('budget.subcatRestaurantsBars'),
snacks_fast_food: t('budget.subcatSnacksFastFood'),
bakery: t('budget.subcatBakery'),
fuel: t('budget.subcatFuel'),
parking_tolls: t('budget.subcatParkingTolls'),
public_transport: t('budget.subcatPublicTransport'),
apps_taxi: t('budget.subcatAppsTaxi'),
maintenance_insurance: t('budget.subcatMaintenanceInsurance'),
pharmacy: t('budget.subcatPharmacy'),
health_insurance: t('budget.subcatHealthInsurance'),
gym_sports: t('budget.subcatGymSports'),
beauty_cosmetics: t('budget.subcatBeautyCosmetics'),
travel: t('budget.subcatTravel'),
streaming: t('budget.subcatStreaming'),
events: t('budget.subcatEvents'),
hobbies: t('budget.subcatHobbies'),
clothes_shoes: t('budget.subcatClothesShoes'),
electronics: t('budget.subcatElectronics'),
gifts: t('budget.subcatGifts'),
courses_college: t('budget.subcatCoursesCollege'),
school_supplies: t('budget.subcatSchoolSupplies'),
languages: t('budget.subcatLanguages'),
loans_interest: t('budget.subcatLoansInterest'),
bank_fees: t('budget.subcatBankFees'),
insurance_other: t('budget.subcatInsuranceOther'),
investments: t('budget.subcatInvestments'),
taxes: t('budget.subcatTaxes'),
});
function categoryLabel(category) {
const item = typeof category === 'object'
? category
: [...expenseCategories(), ...incomeCategories()].find((c) => c.key === category);
const key = item?.key ?? category;
const name = item?.name ?? category;
return CATEGORY_I18N()[key] ?? name;
}
function subcategoryLabel(subcategory) {
const item = typeof subcategory === 'object'
? subcategory
: Object.values(state.meta.expenseSubcategories ?? {}).flat().find((s) => s.key === subcategory);
const key = item?.key ?? subcategory;
const name = item?.name ?? subcategory;
return SUBCATEGORY_I18N()[key] ?? name;
}
function expenseCategories() {
return state.meta.expenseCategories ?? [];
}
function incomeCategories() {
return state.meta.incomeCategories ?? [];
}
function getSubcategories(category) {
return state.meta.expenseSubcategories?.[category] || [];
}
function defaultSubcategory(category) {
return getSubcategories(category)[0]?.key || '';
}
function defaultCategory(type) {
const cats = type === 'income' ? incomeCategories() : expenseCategories();
return cats[0]?.key || '';
}
function getMonthName(monthIndex) { function getMonthName(monthIndex) {
// monthIndex: 0-based (0=Januar, 11=Dezember) // monthIndex: 0-based (0=Januar, 11=Dezember)
const date = new Date(2000, monthIndex, 1); const date = new Date(2000, monthIndex, 1);
@@ -62,6 +125,7 @@ let state = {
summary: null, summary: null,
prevSummary: null, // Vormonat für Monatsvergleich prevSummary: null, // Vormonat für Monatsvergleich
currency: 'EUR', currency: 'EUR',
meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} },
}; };
let _container = null; let _container = null;
@@ -110,6 +174,21 @@ async function loadMonth(month) {
} }
} }
async function loadBudgetMeta() {
try {
const res = await api.get('/budget/meta');
state.meta = {
expenseCategories: res.data?.expenseCategories ?? [],
incomeCategories: res.data?.incomeCategories ?? [],
expenseSubcategories: res.data?.expenseSubcategories ?? {},
};
} catch (err) {
console.error('[Budget] meta Fehler:', err);
state.meta = { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} };
window.oikos?.showToast(t('budget.metaLoadError'), 'danger');
}
}
// -------------------------------------------------------- // --------------------------------------------------------
// Entry Point // Entry Point
// -------------------------------------------------------- // --------------------------------------------------------
@@ -120,7 +199,10 @@ export async function render(container, { user }) {
state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
try { try {
const prefsRes = await api.get('/preferences'); const [prefsRes] = await Promise.all([
api.get('/preferences'),
loadBudgetMeta(),
]);
state.currency = prefsRes.data?.currency ?? 'EUR'; state.currency = prefsRes.data?.currency ?? 'EUR';
} catch (_) { /* Fallback auf EUR */ } } catch (_) { /* Fallback auf EUR */ }
@@ -274,7 +356,7 @@ function renderCategoryBars(byCategory) {
return ` return `
<div class="budget-bar-row"> <div class="budget-bar-row">
<div class="budget-bar-row__label" title="${esc(CATEGORY_LABELS()[c.category] ?? c.category)}">${esc(CATEGORY_LABELS()[c.category] ?? c.category)}</div> <div class="budget-bar-row__label" title="${esc(categoryLabel(c.category))}">${esc(categoryLabel(c.category))}</div>
<div class="budget-bar-row__track"> <div class="budget-bar-row__track">
<div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div> <div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div>
</div> </div>
@@ -305,13 +387,16 @@ function renderEntries() {
const sign = isIncome ? '+' : ''; const sign = isIncome ? '+' : '';
const date = formatEntryDate(e.date); const date = formatEntryDate(e.date);
const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : ''); const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : '');
const categoryMeta = isIncome || !e.subcategory
? categoryLabel(e.category)
: `${categoryLabel(e.category)} · ${subcategoryLabel(e.subcategory)}`;
return ` return `
<div class="budget-entry" data-id="${e.id}"> <div class="budget-entry" data-id="${e.id}">
<div class="budget-entry__indicator ${indClass}"></div> <div class="budget-entry__indicator ${indClass}"></div>
<div class="budget-entry__body"> <div class="budget-entry__body">
<div class="budget-entry__title">${esc(e.title)}</div> <div class="budget-entry__title">${esc(e.title)}</div>
<div class="budget-entry__meta">${date} · ${esc(CATEGORY_LABELS()[e.category] ?? e.category)}${recurTag}</div> <div class="budget-entry__meta">${date} · ${esc(categoryMeta)}${recurTag}</div>
</div> </div>
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div> <div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}"> <button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}">
@@ -359,10 +444,14 @@ function openBudgetModal({ mode, entry = null }) {
const isExpense = isEdit ? entry.amount < 0 : true; const isExpense = isEdit ? entry.amount < 0 : true;
const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : ''; const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : '';
const catLabels = CATEGORY_LABELS(); const initialCats = isExpense ? expenseCategories() : incomeCategories();
const initialCats = isExpense ? EXPENSE_CATEGORIES : INCOME_CATEGORIES;
const catOpts = initialCats.map((c) => const catOpts = initialCats.map((c) =>
`<option value="${c}" ${isEdit && entry.category === c ? 'selected' : ''}>${catLabels[c] || c}</option>` `<option value="${esc(c.key)}" ${isEdit && entry.category === c.key ? 'selected' : ''}>${esc(categoryLabel(c))}</option>`
).join('');
const initialCategory = isEdit ? entry.category : initialCats[0]?.key;
const initialSubcategory = isEdit ? entry.subcategory : defaultSubcategory(initialCategory);
const subcatOpts = getSubcategories(initialCategory).map((s) =>
`<option value="${esc(s.key)}" ${initialSubcategory === s.key ? 'selected' : ''}>${esc(subcategoryLabel(s))}</option>`
).join(''); ).join('');
const content = ` const content = `
@@ -387,10 +476,21 @@ function openBudgetModal({ mode, entry = null }) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bm-category">${t('budget.categoryLabel')}</label> <div class="budget-field-header">
<label class="form-label" for="bm-category">${t('budget.categoryLabel')}</label>
<button class="btn btn--secondary budget-inline-add" type="button" id="bm-add-category">${t('budget.addCategory')}</button>
</div>
<select class="form-input" id="bm-category">${catOpts}</select> <select class="form-input" id="bm-category">${catOpts}</select>
</div> </div>
<div class="form-group" id="bm-subcategory-group" ${isExpense ? '' : 'hidden'}>
<div class="budget-field-header">
<label class="form-label" for="bm-subcategory">${t('budget.subcategoryLabel')}</label>
<button class="btn btn--secondary budget-inline-add" type="button" id="bm-add-subcategory">${t('budget.addSubcategory')}</button>
</div>
<select class="form-input" id="bm-subcategory">${subcatOpts}</select>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bm-date">${t('budget.dateLabel')}</label> <label class="form-label" for="bm-date">${t('budget.dateLabel')}</label>
<input type="date" class="form-input" id="bm-date" <input type="date" class="form-input" id="bm-date"
@@ -422,20 +522,70 @@ function openBudgetModal({ mode, entry = null }) {
onSave(panel) { onSave(panel) {
let currentType = isExpense ? 'expense' : 'income'; let currentType = isExpense ? 'expense' : 'income';
const updateCategoryOptions = () => { const updateCategoryOptions = (preferredCategory = '') => {
const catLabels = CATEGORY_LABELS(); const cats = currentType === 'income' ? incomeCategories() : expenseCategories();
const cats = currentType === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
const catSelect = panel.querySelector('#bm-category'); const catSelect = panel.querySelector('#bm-category');
const currentValue = catSelect.value; const currentValue = preferredCategory || catSelect.value;
const options = cats.map((c) => { const options = cats.map((c) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = c; opt.value = c.key;
opt.textContent = catLabels[c] || c; opt.textContent = categoryLabel(c);
opt.selected = currentValue === c; opt.selected = currentValue === c.key;
return opt; return opt;
}); });
catSelect.replaceChildren(...options); catSelect.replaceChildren(...options);
if (!cats.some((c) => c.key === catSelect.value)) catSelect.value = cats[0]?.key || '';
updateSubcategoryOptions();
};
const updateSubcategoryOptions = (preferredSubcategory = '') => {
const catSelect = panel.querySelector('#bm-category');
const subcatGroup = panel.querySelector('#bm-subcategory-group');
const subcatSelect = panel.querySelector('#bm-subcategory');
const subcategories = currentType === 'expense' ? getSubcategories(catSelect.value) : [];
const currentValue = preferredSubcategory || subcatSelect.value;
subcatGroup.hidden = currentType !== 'expense';
subcatSelect.replaceChildren(...subcategories.map((s) => {
const opt = document.createElement('option');
opt.value = s.key;
opt.textContent = subcategoryLabel(s);
opt.selected = currentValue === s.key;
return opt;
}));
if (subcategories.length && !subcategories.some((s) => s.key === subcatSelect.value)) {
subcatSelect.value = subcategories[0].key;
}
};
const addCategory = async () => {
const name = window.prompt(t('budget.newCategoryPrompt'));
if (!name?.trim()) return;
try {
const res = await api.post('/budget/categories', { name: name.trim(), type: currentType });
await loadBudgetMeta();
updateCategoryOptions(res.data.key);
window.oikos?.showToast(t('budget.categoryAddedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
}
};
const addSubcategory = async () => {
if (currentType !== 'expense') return;
const category = panel.querySelector('#bm-category').value;
if (!category) return;
const name = window.prompt(t('budget.newSubcategoryPrompt'));
if (!name?.trim()) return;
try {
const res = await api.post(`/budget/categories/${encodeURIComponent(category)}/subcategories`, { name: name.trim() });
await loadBudgetMeta();
updateSubcategoryOptions(res.data.key);
window.oikos?.showToast(t('budget.subcategoryAddedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
}
}; };
panel.querySelector('#type-expense').addEventListener('click', () => { panel.querySelector('#type-expense').addEventListener('click', () => {
@@ -450,6 +600,9 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#type-expense').classList.remove('amount-type-btn--active'); panel.querySelector('#type-expense').classList.remove('amount-type-btn--active');
updateCategoryOptions(); updateCategoryOptions();
}); });
panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions());
panel.querySelector('#bm-add-category').addEventListener('click', addCategory);
panel.querySelector('#bm-add-subcategory').addEventListener('click', addSubcategory);
panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
@@ -463,6 +616,7 @@ function openBudgetModal({ mode, entry = null }) {
const title = panel.querySelector('#bm-title').value.trim(); const title = panel.querySelector('#bm-title').value.trim();
const absVal = parseFloat(panel.querySelector('#bm-amount').value); const absVal = parseFloat(panel.querySelector('#bm-amount').value);
const category = panel.querySelector('#bm-category').value; const category = panel.querySelector('#bm-category').value;
const subcategory = currentType === 'expense' ? panel.querySelector('#bm-subcategory').value : '';
const date = panel.querySelector('#bm-date').value; const date = panel.querySelector('#bm-date').value;
const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0; const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
@@ -476,7 +630,7 @@ function openBudgetModal({ mode, entry = null }) {
saveBtn.textContent = '…'; saveBtn.textContent = '…';
try { try {
const body = { title, amount, category, date, is_recurring: recurring }; const body = { title, amount, category, subcategory, date, is_recurring: recurring };
if (mode === 'create') { if (mode === 'create') {
const res = await api.post('/budget', body); const res = await api.post('/budget', body);
state.entries.unshift(res.data); state.entries.unshift(res.data);
@@ -523,4 +677,3 @@ async function deleteEntry(id) {
// -------------------------------------------------------- // --------------------------------------------------------
// Hilfsfunktion // Hilfsfunktion
// -------------------------------------------------------- // --------------------------------------------------------
+17
View File
@@ -331,3 +331,20 @@
color: var(--color-text-on-accent); color: var(--color-text-on-accent);
} }
.budget-field-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.budget-field-header .form-label {
margin-bottom: 0;
}
.budget-inline-add {
min-height: auto;
padding: 2px var(--space-2);
font-size: var(--text-xs);
}
+3 -3
View File
@@ -13,9 +13,9 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/ */
const SHELL_CACHE = 'oikos-shell-v50'; const SHELL_CACHE = 'oikos-shell-v52';
const PAGES_CACHE = 'oikos-pages-v45'; const PAGES_CACHE = 'oikos-pages-v47';
const ASSETS_CACHE = 'oikos-assets-v45'; const ASSETS_CACHE = 'oikos-assets-v47';
const BYPASS_CACHE = 'oikos-bypass-flag'; const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
+16
View File
@@ -121,6 +121,7 @@ const MIGRATIONS_SQL = {
title TEXT NOT NULL, title TEXT NOT NULL,
amount REAL NOT NULL, amount REAL NOT NULL,
category TEXT NOT NULL DEFAULT 'Sonstiges', category TEXT NOT NULL DEFAULT 'Sonstiges',
subcategory TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL, date TEXT NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0, is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT, recurrence_rule TEXT,
@@ -128,6 +129,21 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
); );
CREATE TABLE IF NOT EXISTS budget_categories (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('expense', 'income')),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS budget_subcategories (
key TEXT PRIMARY KEY,
category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(category_key, name)
);
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW AFTER UPDATE ON users FOR EACH ROW
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
+127
View File
@@ -544,6 +544,133 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id); CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id);
`, `,
}, },
{
version: 15,
description: 'Budget-Ausgabenkategorien als stabile Schlüssel mit Unterkategorien',
up: `
ALTER TABLE budget_entries ADD COLUMN subcategory TEXT NOT NULL DEFAULT '';
UPDATE budget_entries
SET category = CASE category
WHEN 'Lebensmittel' THEN 'food'
WHEN 'Miete' THEN 'housing'
WHEN 'Versicherung' THEN 'financial_other'
WHEN 'Mobilität' THEN 'transport'
WHEN 'Freizeit' THEN 'leisure'
WHEN 'Kleidung' THEN 'shopping_clothing'
WHEN 'Gesundheit' THEN 'personal_health'
WHEN 'Bildung' THEN 'education'
WHEN 'Sonstiges' THEN 'financial_other'
ELSE category
END
WHERE amount < 0;
UPDATE budget_entries
SET subcategory = CASE category
WHEN 'housing' THEN 'rent_mortgage'
WHEN 'food' THEN 'groceries'
WHEN 'transport' THEN 'fuel'
WHEN 'personal_health' THEN 'pharmacy'
WHEN 'leisure' THEN 'events'
WHEN 'shopping_clothing' THEN 'clothes_shoes'
WHEN 'education' THEN 'courses_college'
WHEN 'financial_other' THEN 'insurance_other'
ELSE ''
END
WHERE amount < 0 AND subcategory = '';
UPDATE budget_entries
SET category = 'Sonstiges Einkommen'
WHERE amount > 0 AND category = 'Sonstiges';
`,
},
{
version: 16,
description: 'Budget-Kategorien und Unterkategorien in eigene Tabellen auslagern',
up: `
CREATE TABLE IF NOT EXISTS budget_categories (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('expense', 'income')),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS budget_subcategories (
key TEXT PRIMARY KEY,
category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(category_key, name)
);
INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order) VALUES
('housing', 'Habitação / Casa', 'expense', 0),
('food', 'Alimentação', 'expense', 1),
('transport', 'Transporte', 'expense', 2),
('personal_health', 'Cuidados Pessoais / Saúde', 'expense', 3),
('leisure', 'Lazer e Entretenimento', 'expense', 4),
('shopping_clothing', 'Compras e Vestuário', 'expense', 5),
('education', 'Educação', 'expense', 6),
('financial_other', 'Serviços Financeiros e Outros', 'expense', 7),
('Erwerbseinkommen', 'Erwerbseinkommen', 'income', 0),
('Kapitalerträge', 'Kapitalerträge', 'income', 1),
('Geschenke & Transfers', 'Geschenke & Transfers', 'income', 2),
('Sozialleistungen', 'Sozialleistungen', 'income', 3),
('Sonstiges Einkommen', 'Sonstiges Einkommen', 'income', 4);
INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order) VALUES
('rent_mortgage', 'housing', 'Aluguel / Prestação', 0),
('condominium', 'housing', 'Condomínio', 1),
('utilities', 'housing', 'Luz / Água / Gás', 2),
('internet_tv_phone', 'housing', 'Internet / TV / Telefone', 3),
('renovation_maintenance', 'housing', 'Reforma / Manutenção', 4),
('cleaning', 'housing', 'Limpeza', 5),
('groceries', 'food', 'Supermercado', 0),
('restaurants_bars', 'food', 'Restaurante / Bares', 1),
('snacks_fast_food', 'food', 'Lanches / Fast Food', 2),
('bakery', 'food', 'Padaria', 3),
('fuel', 'transport', 'Combustível', 0),
('parking_tolls', 'transport', 'Estacionamento / Pedágio', 1),
('public_transport', 'transport', 'Transporte Público', 2),
('apps_taxi', 'transport', 'Aplicativos / Táxi', 3),
('maintenance_insurance', 'transport', 'Manutenção / Seguro', 4),
('pharmacy', 'personal_health', 'Farmácia', 0),
('health_insurance', 'personal_health', 'Plano de Saúde', 1),
('gym_sports', 'personal_health', 'Academia / Esportes', 2),
('beauty_cosmetics', 'personal_health', 'Beleza / Cosméticos', 3),
('travel', 'leisure', 'Viagens', 0),
('streaming', 'leisure', 'Streaming', 1),
('events', 'leisure', 'Eventos', 2),
('hobbies', 'leisure', 'Hobbies', 3),
('clothes_shoes', 'shopping_clothing', 'Roupas / Calçados', 0),
('electronics', 'shopping_clothing', 'Eletrônicos', 1),
('gifts', 'shopping_clothing', 'Presentes', 2),
('courses_college', 'education', 'Cursos / Faculdade', 0),
('school_supplies', 'education', 'Material Escolar', 1),
('languages', 'education', 'Idiomas', 2),
('loans_interest', 'financial_other', 'Empréstimos / Juros', 0),
('bank_fees', 'financial_other', 'Tarifas Bancárias', 1),
('insurance_other', 'financial_other', 'Seguros', 2),
('investments', 'financial_other', 'Investimentos', 3),
('taxes', 'financial_other', 'Impostos', 4);
INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order)
SELECT category, category, CASE WHEN amount < 0 THEN 'expense' ELSE 'income' END, 1000
FROM budget_entries
WHERE category NOT IN (SELECT key FROM budget_categories)
GROUP BY category;
INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order)
SELECT subcategory, category, subcategory, 1000
FROM budget_entries
WHERE subcategory != ''
AND subcategory NOT IN (SELECT key FROM budget_subcategories)
AND category IN (SELECT key FROM budget_categories WHERE type = 'expense')
GROUP BY category, subcategory;
`,
},
]; ];
/** /**
+165 -23
View File
@@ -7,7 +7,7 @@
import { createLogger } from '../logger.js'; import { createLogger } from '../logger.js';
import express from 'express'; import express from 'express';
import * as db from '../db.js'; import * as db from '../db.js';
import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } from '../middleware/validate.js'; import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js';
const log = createLogger('Budget'); const log = createLogger('Budget');
@@ -57,23 +57,87 @@ function generateRecurringInstances(database, month) {
database.prepare(` database.prepare(`
INSERT INTO budget_entries INSERT INTO budget_entries
(title, amount, category, date, is_recurring, recurrence_parent_id, created_by) (title, amount, category, subcategory, date, is_recurring, recurrence_parent_id, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
`).run(orig.title, orig.amount, orig.category, instanceDate, orig.id, orig.created_by); `).run(orig.title, orig.amount, orig.category, orig.subcategory || '', instanceDate, orig.id, orig.created_by);
} }
} }
const EXPENSE_CATEGORIES = [ function slugify(value) {
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität', return String(value || '')
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', .normalize('NFD')
]; .replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48) || 'category';
}
const INCOME_CATEGORIES = [ function uniqueKey(table, base) {
'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers', const normalized = slugify(base);
'Sozialleistungen', 'Sonstiges Einkommen', let key = normalized;
]; let i = 2;
const exists = db.get().prepare(`SELECT 1 FROM ${table} WHERE key = ?`);
while (exists.get(key)) {
key = `${normalized}_${i}`;
i += 1;
}
return key;
}
const VALID_CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES]; function loadBudgetMeta() {
const categories = db.get().prepare(`
SELECT key, name, type, sort_order
FROM budget_categories
ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC
`).all();
const subcategories = db.get().prepare(`
SELECT key, category_key, name, sort_order
FROM budget_subcategories
ORDER BY sort_order ASC, name COLLATE NOCASE ASC
`).all();
const expenseCategories = categories.filter((c) => c.type === 'expense');
const incomeCategories = categories.filter((c) => c.type === 'income');
const expenseSubcategories = {};
for (const sub of subcategories) {
if (!expenseSubcategories[sub.category_key]) expenseSubcategories[sub.category_key] = [];
expenseSubcategories[sub.category_key].push(sub);
}
return { categories, expenseCategories, incomeCategories, expenseSubcategories };
}
function validCategoryKeys() {
return db.get().prepare('SELECT key FROM budget_categories').all().map((c) => c.key);
}
function validExpenseCategoryKeys() {
return db.get().prepare("SELECT key FROM budget_categories WHERE type = 'expense'").all().map((c) => c.key);
}
function defaultCategory(type) {
const row = db.get().prepare(`
SELECT key FROM budget_categories WHERE type = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1
`).get(type);
return row?.key || (type === 'expense' ? 'financial_other' : 'Sonstiges Einkommen');
}
function defaultSubcategory(category) {
const row = db.get().prepare(`
SELECT key FROM budget_subcategories WHERE category_key = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1
`).get(category);
return row?.key || '';
}
function validateSubcategory(category, subcategory) {
if (!validExpenseCategoryKeys().includes(category)) return '';
if (!subcategory) return defaultSubcategory(category);
const row = db.get().prepare(`
SELECT 1 FROM budget_subcategories WHERE category_key = ? AND key = ?
`).get(category, subcategory);
return row ? subcategory : null;
}
// -------------------------------------------------------- // --------------------------------------------------------
// Statische Routen vor /:id // Statische Routen vor /:id
@@ -155,7 +219,7 @@ router.get('/export', (req, res) => {
ORDER BY b.date ASC ORDER BY b.date ASC
`).all(from, to); `).all(from, to);
const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n'; const header = 'Datum,Titel,Betrag,Kategorie,Unterkategorie,Wiederkehrend,Erstellt von\n';
const csvSafe = (val) => { const csvSafe = (val) => {
let s = String(val || '').replace(/"/g, '""'); let s = String(val || '').replace(/"/g, '""');
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
@@ -167,6 +231,7 @@ router.get('/export', (req, res) => {
csvSafe(e.title), csvSafe(e.title),
e.amount.toFixed(2).replace('.', ','), e.amount.toFixed(2).replace('.', ','),
e.category, e.category,
e.subcategory || '',
e.is_recurring ? 'Ja' : 'Nein', e.is_recurring ? 'Ja' : 'Nein',
csvSafe(e.creator_name), csvSafe(e.creator_name),
].join(',') ].join(',')
@@ -187,7 +252,70 @@ router.get('/export', (req, res) => {
* Response: { data: { categories } } * Response: { data: { categories } }
*/ */
router.get('/meta', (req, res) => { router.get('/meta', (req, res) => {
res.json({ data: { categories: VALID_CATEGORIES } }); res.json({ data: loadBudgetMeta() });
});
router.post('/categories', (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
const vType = oneOf(req.body.type || 'expense', ['expense', 'income'], 'Typ');
const errors = collectErrors([vName, vType]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const conflict = db.get().prepare(`
SELECT key FROM budget_categories WHERE type = ? AND name = ? COLLATE NOCASE
`).get(vType.value, vName.value);
if (conflict) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 });
const maxOrder = db.get().prepare(`
SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_categories WHERE type = ?
`).get(vType.value).m;
const key = uniqueKey('budget_categories', vName.value);
db.get().prepare(`
INSERT INTO budget_categories (key, name, type, sort_order) VALUES (?, ?, ?, ?)
`).run(key, vName.value, vType.value, maxOrder + 1);
const cat = db.get().prepare('SELECT key, name, type, sort_order FROM budget_categories WHERE key = ?').get(key);
res.status(201).json({ data: cat });
} catch (err) {
log.error('POST /categories Fehler:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
router.post('/categories/:categoryKey/subcategories', (req, res) => {
try {
const cat = db.get().prepare(`
SELECT * FROM budget_categories WHERE key = ? AND type = 'expense'
`).get(req.params.categoryKey);
if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 });
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
const conflict = db.get().prepare(`
SELECT key FROM budget_subcategories WHERE category_key = ? AND name = ? COLLATE NOCASE
`).get(cat.key, vName.value);
if (conflict) return res.status(409).json({ error: 'Unterkategorie existiert bereits.', code: 409 });
const maxOrder = db.get().prepare(`
SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_subcategories WHERE category_key = ?
`).get(cat.key).m;
const key = uniqueKey('budget_subcategories', `${cat.key}_${vName.value}`);
db.get().prepare(`
INSERT INTO budget_subcategories (key, category_key, name, sort_order) VALUES (?, ?, ?, ?)
`).run(key, cat.key, vName.value, maxOrder + 1);
const sub = db.get().prepare(`
SELECT key, category_key, name, sort_order FROM budget_subcategories WHERE key = ?
`).get(key);
res.status(201).json({ data: sub });
} catch (err) {
log.error('POST /categories/:categoryKey/subcategories Fehler:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
}); });
// -------------------------------------------------------- // --------------------------------------------------------
@@ -220,7 +348,7 @@ router.get('/', (req, res) => {
`; `;
const params = [from, to]; const params = [from, to];
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) { if (req.query.category && validCategoryKeys().includes(req.query.category)) {
sql += ' AND b.category = ?'; sql += ' AND b.category = ?';
params.push(req.query.category); params.push(req.query.category);
} }
@@ -238,24 +366,29 @@ router.get('/', (req, res) => {
/** /**
* POST /api/v1/budget * POST /api/v1/budget
* Neuen Eintrag anlegen. * Neuen Eintrag anlegen.
* Body: { title, amount, category?, date, is_recurring?, recurrence_rule? } * Body: { title, amount, category?, subcategory?, date, is_recurring?, recurrence_rule? }
* Response: { data: Entry } * Response: { data: Entry }
*/ */
router.post('/', (req, res) => { router.post('/', (req, res) => {
try { try {
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE }); const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vAmount = num(req.body.amount, 'Betrag', { required: true }); const vAmount = num(req.body.amount, 'Betrag', { required: true });
const vCat = oneOf(req.body.category || 'Sonstiges', VALID_CATEGORIES, 'Kategorie'); const fallbackCategory = defaultCategory(Number(req.body.amount) < 0 ? 'expense' : 'income');
const vCat = oneOf(req.body.category || fallbackCategory, validCategoryKeys(), 'Kategorie');
const vDate = validateDate(req.body.date, 'Datum', true); const vDate = validateDate(req.body.date, 'Datum', true);
const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung'); const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung');
const errors = collectErrors([vTitle, vAmount, vCat, vDate, vRrule]); const errors = collectErrors([vTitle, vAmount, vCat, vDate, vRrule]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const subcategory = validateSubcategory(vCat.value, req.body.subcategory);
if (subcategory === null) {
return res.status(400).json({ error: 'Ungültige Unterkategorie.', code: 400 });
}
const result = db.get().prepare(` const result = db.get().prepare(`
INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by) INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
vTitle.value, vAmount.value, vCat.value || 'Sonstiges', vDate.value, vTitle.value, vAmount.value, vCat.value || fallbackCategory, subcategory, vDate.value,
req.body.is_recurring ? 1 : 0, vRrule.value, req.body.is_recurring ? 1 : 0, vRrule.value,
req.session.userId req.session.userId
); );
@@ -288,18 +421,26 @@ router.put('/:id', (req, res) => {
const checks = []; const checks = [];
if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false })); if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false }));
if (req.body.amount !== undefined) checks.push(num(req.body.amount, 'Betrag')); if (req.body.amount !== undefined) checks.push(num(req.body.amount, 'Betrag'));
if (req.body.category !== undefined) checks.push(oneOf(req.body.category, VALID_CATEGORIES, 'Kategorie')); if (req.body.category !== undefined) checks.push(oneOf(req.body.category, validCategoryKeys(), 'Kategorie'));
if (req.body.date !== undefined) checks.push(validateDate(req.body.date, 'Datum')); if (req.body.date !== undefined) checks.push(validateDate(req.body.date, 'Datum'));
if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung')); if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung'));
const errors = collectErrors(checks); const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const { title, amount, category, date, is_recurring, recurrence_rule } = req.body; const { title, amount, category, subcategory: requestedSubcategory, date, is_recurring, recurrence_rule } = req.body;
const nextCategory = category ?? entry.category;
const subcategory = requestedSubcategory !== undefined || category !== undefined
? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory)
: undefined;
if (subcategory === null) {
return res.status(400).json({ error: 'Ungültige Unterkategorie.', code: 400 });
}
db.get().prepare(` db.get().prepare(`
UPDATE budget_entries UPDATE budget_entries
SET title = COALESCE(?, title), SET title = COALESCE(?, title),
amount = COALESCE(?, amount), amount = COALESCE(?, amount),
category = COALESCE(?, category), category = COALESCE(?, category),
subcategory = COALESCE(?, subcategory),
date = COALESCE(?, date), date = COALESCE(?, date),
is_recurring = COALESCE(?, is_recurring), is_recurring = COALESCE(?, is_recurring),
recurrence_rule = ? recurrence_rule = ?
@@ -308,6 +449,7 @@ router.put('/:id', (req, res) => {
title?.trim() ?? null, title?.trim() ?? null,
amount !== undefined ? Number(amount) : null, amount !== undefined ? Number(amount) : null,
category ?? null, category ?? null,
subcategory !== undefined ? subcategory : null,
date ?? null, date ?? null,
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null, is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule, recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
+18 -12
View File
@@ -178,30 +178,30 @@ console.log('\n[Budget-Test] Einnahmen, Ausgaben, Saldo, Aggregation, CSV-Vorber
let bId1, bId2, bId3, bId4; let bId1, bId2, bId3, bId4;
test('Ausgabe eintragen (Lebensmittel)', () => { test('Ausgabe eintragen (Supermarkt)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
VALUES ('REWE', -85.40, 'Lebensmittel', '2026-03-10', ?)`).run(uid); VALUES ('REWE', -85.40, 'food', 'groceries', '2026-03-10', ?)`).run(uid);
bId1 = r.lastInsertRowid; bId1 = r.lastInsertRowid;
assert(bId1 > 0); assert(bId1 > 0);
}); });
test('Einnahme eintragen (Gehalt)', () => { test('Einnahme eintragen (Gehalt)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by)
VALUES ('Gehalt März', 2800.00, 'Sonstiges', '2026-03-01', ?)`).run(uid); VALUES ('Gehalt März', 2800.00, 'Sonstiges Einkommen', '2026-03-01', ?)`).run(uid);
bId2 = r.lastInsertRowid; bId2 = r.lastInsertRowid;
assert(bId2 > 0); assert(bId2 > 0);
}); });
test('Ausgabe (Miete)', () => { test('Ausgabe (Aluguel / Prestação)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, is_recurring, created_by) const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, created_by)
VALUES ('Miete', -950.00, 'Miete', '2026-03-01', 1, ?)`).run(uid); VALUES ('Miete', -950.00, 'housing', 'rent_mortgage', '2026-03-01', 1, ?)`).run(uid);
bId3 = r.lastInsertRowid; bId3 = r.lastInsertRowid;
assert(bId3 > 0); assert(bId3 > 0);
}); });
test('Ausgabe im anderen Monat (April)', () => { test('Ausgabe im anderen Monat (April)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by) const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
VALUES ('Strom April', -55.00, 'Sonstiges', '2026-04-15', ?)`).run(uid); VALUES ('Strom April', -55.00, 'housing', 'utilities', '2026-04-15', ?)`).run(uid);
bId4 = r.lastInsertRowid; bId4 = r.lastInsertRowid;
assert(bId4 > 0); assert(bId4 > 0);
}); });
@@ -258,12 +258,18 @@ test('Aggregation nach Kategorie', () => {
GROUP BY category ORDER BY ABS(SUM(amount)) DESC GROUP BY category ORDER BY ABS(SUM(amount)) DESC
`).all(); `).all();
assert(cats.length >= 2, `Mindestens 2 Kategorien, erhalten ${cats.length}`); assert(cats.length >= 2, `Mindestens 2 Kategorien, erhalten ${cats.length}`);
// Miete sollte die größte Ausgabe sein // Housing should be the largest expense category.
const miete = cats.find((c) => c.category === 'Miete'); const miete = cats.find((c) => c.category === 'housing');
assert(miete, 'Miete in Kategorien vorhanden'); assert(miete, 'Housing in Kategorien vorhanden');
assert(Math.abs(miete.expenses + 950.00) < 0.01, `Miete-Ausgaben: ${miete.expenses}`); assert(Math.abs(miete.expenses + 950.00) < 0.01, `Miete-Ausgaben: ${miete.expenses}`);
}); });
test('Unterkategorie gespeichert', () => {
const r = db.prepare('SELECT category, subcategory FROM budget_entries WHERE id = ?').get(bId1);
assert(r.category === 'food', `Kategorie: ${r.category}`);
assert(r.subcategory === 'groceries', `Unterkategorie: ${r.subcategory}`);
});
test('Wiederkehrend-Flag korrekt', () => { test('Wiederkehrend-Flag korrekt', () => {
const r = db.prepare('SELECT is_recurring FROM budget_entries WHERE id = ?').get(bId3); const r = db.prepare('SELECT is_recurring FROM budget_entries WHERE id = ?').get(bId3);
assert(r.is_recurring === 1, 'Miete ist wiederkehrend'); assert(r.is_recurring === 1, 'Miete ist wiederkehrend');