From ce107c80a43ad9afc300b3853012697f5e63d473 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Thu, 30 Apr 2026 23:12:38 -0300 Subject: [PATCH 1/9] Add budget loan tracking --- public/locales/ar.json | 35 +++- public/locales/de.json | 33 +++- public/locales/el.json | 35 +++- public/locales/en.json | 33 +++- public/locales/es.json | 35 +++- public/locales/fr.json | 35 +++- public/locales/hi.json | 35 +++- public/locales/it.json | 35 +++- public/locales/ja.json | 35 +++- public/locales/pt.json | 35 +++- public/locales/ru.json | 35 +++- public/locales/sv.json | 35 +++- public/locales/tr.json | 35 +++- public/locales/uk.json | 35 +++- public/locales/zh.json | 35 +++- public/pages/budget.js | 241 ++++++++++++++++++++++++++- public/styles/budget.css | 171 ++++++++++++++++++++ server/db-schema-test.js | 69 ++++++++ server/db.js | 41 +++++ server/routes/budget.js | 295 +++++++++++++++++++++++++++++++++- test-notes-contacts-budget.js | 34 ++++ 21 files changed, 1338 insertions(+), 34 deletions(-) diff --git a/public/locales/ar.json b/public/locales/ar.json index e247dd4..9684c58 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "إضافة إدخال" + "emptyAction": "إضافة إدخال", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "الإعدادات", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "المطبخ" } -} \ No newline at end of file +} diff --git a/public/locales/de.json b/public/locales/de.json index aec8962..4500f45 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -590,7 +590,38 @@ "newCategoryPrompt": "Name der neuen Kategorie:", "newSubcategoryPrompt": "Name der neuen Unterkategorie:", "categoryAddedToast": "Kategorie hinzugefügt.", - "subcategoryAddedToast": "Unterkategorie hinzugefügt." + "subcategoryAddedToast": "Unterkategorie hinzugefügt.", + "loansTitle": "Darlehen", + "loansSummary": "{{count}} aktiv · {{amount}} offen", + "newLoan": "Neues Darlehen", + "createLoan": "Darlehen erstellen", + "editLoan": "Darlehen bearbeiten", + "deleteLoan": "Darlehen löschen", + "deleteLoanConfirm": "Darlehen \"{{title}}\" löschen? Bereits im Budget verbuchte Zahlungen werden ebenfalls entfernt.", + "loanRemainingAmount": "Offen", + "loanRemainingInstallments": "Raten offen", + "loanPaidAmount": "Bezahlt", + "loansEmpty": "Keine aktiven Darlehen.", + "loanInstallmentMeta": "{{paid}} von {{total}} Raten bezahlt", + "loanRemainingOf": "von {{total}}", + "loanNextDue": "Nächste: {{month}}", + "loanPaidStatus": "Bezahlt", + "markLoanPaid": "Bezahlt", + "loanBorrowerLabel": "Person *", + "loanBorrowerPlaceholder": "z. B. Lais", + "loanTitleLabel": "Darlehenstitel", + "loanTitlePlaceholder": "z. B. Persönliches Darlehen", + "loanAmountLabel": "Gesamtbetrag *", + "loanInstallmentsLabel": "Raten *", + "loanStartMonthLabel": "Erster Fälligkeitsmonat *", + "loanNotesLabel": "Notizen", + "loanBorrowerRequired": "Person ist erforderlich", + "loanInstallmentsRequired": "Anzahl der Raten eingeben", + "loanStartMonthRequired": "Ersten Fälligkeitsmonat eingeben", + "loanAddedToast": "Darlehen hinzugefügt", + "loanSavedToast": "Darlehen gespeichert", + "loanDeletedToast": "Darlehen gelöscht", + "loanPaymentAddedToast": "Zahlung erfasst" }, "settings": { "title": "Einstellungen", diff --git a/public/locales/el.json b/public/locales/el.json index e3230bb..268662d 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Προσθήκη εγγραφής" + "emptyAction": "Προσθήκη εγγραφής", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Ρυθμίσεις", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Κουζίνα" } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index 21462d9..460ba14 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Add entry" + "emptyAction": "Add entry", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Settings", diff --git a/public/locales/es.json b/public/locales/es.json index 6c4ca09..aa7206d 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nombre de la nueva subcategoría:", "categoryAddedToast": "Categoría añadida.", "subcategoryAddedToast": "Subcategoría añadida.", - "emptyAction": "Agregar entrada" + "emptyAction": "Agregar entrada", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Ajustes", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Cocina" } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index d804651..767d26d 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nom de la nouvelle sous-catégorie :", "categoryAddedToast": "Catégorie ajoutée.", "subcategoryAddedToast": "Sous-catégorie ajoutée.", - "emptyAction": "Ajouter une entrée" + "emptyAction": "Ajouter une entrée", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Paramètres", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Cuisine" } -} \ No newline at end of file +} diff --git a/public/locales/hi.json b/public/locales/hi.json index 028f1a1..e173bac 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "प्रविष्टि जोड़ें" + "emptyAction": "प्रविष्टि जोड़ें", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "सेटिंग्स", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "रसोई" } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index f8dceed..b6351bc 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nome della nuova sottocategoria:", "categoryAddedToast": "Categoria aggiunta.", "subcategoryAddedToast": "Sottocategoria aggiunta.", - "emptyAction": "Aggiungi voce" + "emptyAction": "Aggiungi voce", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Impostazioni", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Cucina" } -} \ No newline at end of file +} diff --git a/public/locales/ja.json b/public/locales/ja.json index fa047db..e365f1b 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "エントリを追加" + "emptyAction": "エントリを追加", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "設定", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "キッチン" } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index 32b1e7f..7f5557d 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nome da nova subcategoria:", "categoryAddedToast": "Categoria adicionada.", "subcategoryAddedToast": "Subcategoria adicionada.", - "emptyAction": "Adicionar entrada" + "emptyAction": "Adicionar entrada", + "loansTitle": "Empréstimos", + "loansSummary": "{{count}} ativos · {{amount}} restantes", + "newLoan": "Novo empréstimo", + "createLoan": "Criar empréstimo", + "editLoan": "Editar empréstimo", + "deleteLoan": "Excluir empréstimo", + "deleteLoanConfirm": "Excluir empréstimo \"{{title}}\"? Pagamentos já lançados no orçamento também serão removidos.", + "loanRemainingAmount": "Restante", + "loanRemainingInstallments": "Parcelas restantes", + "loanPaidAmount": "Pago", + "loansEmpty": "Nenhum empréstimo ativo.", + "loanInstallmentMeta": "{{paid}} de {{total}} parcelas pagas", + "loanRemainingOf": "de {{total}}", + "loanNextDue": "Próxima: {{month}}", + "loanPaidStatus": "Pago", + "markLoanPaid": "Dar baixa", + "loanBorrowerLabel": "Pessoa *", + "loanBorrowerPlaceholder": "Ex.: Lais", + "loanTitleLabel": "Título do empréstimo", + "loanTitlePlaceholder": "Ex.: Empréstimo pessoal", + "loanAmountLabel": "Valor total *", + "loanInstallmentsLabel": "Parcelas *", + "loanStartMonthLabel": "Primeiro mês de vencimento *", + "loanNotesLabel": "Observações", + "loanBorrowerRequired": "Informe a pessoa", + "loanInstallmentsRequired": "Informe a quantidade de parcelas", + "loanStartMonthRequired": "Informe o primeiro mês de vencimento", + "loanAddedToast": "Empréstimo adicionado", + "loanSavedToast": "Empréstimo salvo", + "loanDeletedToast": "Empréstimo excluído", + "loanPaymentAddedToast": "Pagamento registrado" }, "settings": { "title": "Configurações", @@ -1006,4 +1037,4 @@ "shortcuts": { "goKitchen": "Cozinha" } -} \ No newline at end of file +} diff --git a/public/locales/ru.json b/public/locales/ru.json index 8de0150..1b405a3 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Добавить запись" + "emptyAction": "Добавить запись", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Настройки", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Кухня" } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index 37a2198..a6830c9 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Lägg till post" + "emptyAction": "Lägg till post", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Inställningar", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Kök" } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index e0e9dbd..a0829fa 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Giriş ekle" + "emptyAction": "Giriş ekle", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Ayarlar", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Mutfak" } -} \ No newline at end of file +} diff --git a/public/locales/uk.json b/public/locales/uk.json index 7d97282..f615f46 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Додати запис" + "emptyAction": "Додати запис", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Налаштування", @@ -1013,4 +1044,4 @@ "shortcuts": { "goKitchen": "Кухня" } -} \ No newline at end of file +} diff --git a/public/locales/zh.json b/public/locales/zh.json index babffb6..4288973 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "添加记录" + "emptyAction": "添加记录", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "设置", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "厨房" } -} \ No newline at end of file +} diff --git a/public/pages/budget.js b/public/pages/budget.js index 1335266..10c18a5 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -124,6 +124,7 @@ let state = { entries: [], summary: null, prevSummary: null, // Vormonat für Monatsvergleich + loans: { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } }, currency: 'EUR', meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} }, }; @@ -148,6 +149,11 @@ function addMonths(ym, n) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } +function setHtml(element, html) { + element.replaceChildren(); + element.insertAdjacentHTML('afterbegin', html); +} + // -------------------------------------------------------- // API // -------------------------------------------------------- @@ -155,21 +161,24 @@ function addMonths(ym, n) { async function loadMonth(month) { const prevMonth = addMonths(month, -1); try { - const [entriesRes, summaryRes, prevSummaryRes] = await Promise.all([ + const [entriesRes, summaryRes, prevSummaryRes, loansRes] = await Promise.all([ api.get(`/budget?month=${month}`), api.get(`/budget/summary?month=${month}`), api.get(`/budget/summary?month=${prevMonth}`), + api.get('/budget/loans'), ]); state.month = month; state.entries = entriesRes.data; state.summary = summaryRes.data; state.prevSummary = prevSummaryRes.data; + state.loans = loansRes.data; } catch (err) { console.error('[Budget] loadMonth Fehler:', err); state.month = month; state.entries = []; state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] }; state.prevSummary = null; + state.loans = { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } }; window.oikos?.showToast(t('budget.loadError'), 'danger'); } } @@ -206,7 +215,7 @@ export async function render(container, { user }) { state.currency = prefsRes.data?.currency ?? 'EUR'; } catch (_) { /* Fallback auf EUR */ } - container.innerHTML = ` + setHtml(container, `

${t('budget.title')}

@@ -229,7 +238,7 @@ export async function render(container, { user }) {
- `; + `); if (window.lucide) lucide.createIcons(); @@ -286,7 +295,7 @@ function renderBody() { const balanceClass = s.balance >= 0 ? 'budget-summary-card--balance-positive' : 'budget-summary-card--balance-negative'; const prevLabel = p ? formatMonthLabel(p.month).split(' ')[0].slice(0, 3) : ''; - body.innerHTML = ` + setHtml(body, `
@@ -315,6 +324,8 @@ function renderBody() {
` : ''} + ${renderLoansDashboard()} +
@@ -329,12 +340,29 @@ function renderBody() { ${renderEntries()}
- `; + `); if (window.lucide) lucide.createIcons(); _container.querySelector('#empty-cta-budget')?.addEventListener('click', () => { document.querySelector('.page-fab')?.click(); }); + _container.querySelector('#budget-add-loan')?.addEventListener('click', () => openLoanModal()); + _container.querySelectorAll('[data-action="loan-pay"]').forEach((btn) => { + btn.addEventListener('click', async () => { + await markLoanPayment(parseInt(btn.dataset.id, 10)); + }); + }); + _container.querySelectorAll('[data-action="loan-edit"]').forEach((btn) => { + btn.addEventListener('click', () => { + const loan = state.loans.loans.find((item) => item.id === parseInt(btn.dataset.id, 10)); + if (loan) openLoanModal(loan); + }); + }); + _container.querySelectorAll('[data-action="loan-delete"]').forEach((btn) => { + btn.addEventListener('click', async () => { + await deleteLoan(parseInt(btn.dataset.id, 10)); + }); + }); stagger(_container.querySelector('#budget-list')?.querySelectorAll('.budget-entry') ?? []); _container.querySelector('#budget-list')?.addEventListener('click', async (e) => { @@ -415,6 +443,90 @@ function renderEntries() { }).join(''); } +function renderLoansDashboard() { + const loans = state.loans?.loans ?? []; + const summary = state.loans?.summary ?? {}; + const activeLoans = loans.filter((loan) => loan.status === 'active'); + + return ` +
+
+
+
${t('budget.loansTitle')}
+
${t('budget.loansSummary', { + count: summary.active_count ?? 0, + amount: formatAmount(summary.remaining_amount ?? 0), + })}
+
+ +
+
+
+ ${t('budget.loanRemainingAmount')} + ${formatAmount(summary.remaining_amount ?? 0)} +
+
+ ${t('budget.loanRemainingInstallments')} + ${summary.remaining_installments ?? 0} +
+
+ ${t('budget.loanPaidAmount')} + ${formatAmount(summary.paid_amount ?? 0)} +
+
+ ${activeLoans.length ? ` +
+ ${activeLoans.map(renderLoanCard).join('')} +
+ ` : ` +
${t('budget.loansEmpty')}
+ `} +
+ `; +} + +function renderLoanCard(loan) { + const paidPct = Math.min(100, Math.round((loan.paid_amount / loan.total_amount) * 100)); + const nextDue = loan.next_due_month ? formatMonthLabel(loan.next_due_month) : t('budget.loanPaidStatus'); + const payDisabled = loan.remaining_installments <= 0 ? 'disabled' : ''; + + return ` +
+
+
${esc(loan.title)}
+
${esc(loan.borrower)} · ${t('budget.loanInstallmentMeta', { + paid: loan.paid_installments, + total: loan.installment_count, + })}
+
+
+ ${formatAmount(loan.remaining_amount)} + ${t('budget.loanRemainingOf', { total: formatAmount(loan.total_amount) })} +
+
+ +
+ +
+ `; +} + /** * Rendert eine Trend-Zeile im Vergleich zum Vormonat. * Alle drei Metriken (income, expenses, balance) nutzen dieselbe Logik: @@ -670,6 +782,125 @@ function openBudgetModal({ mode, entry = null }) { }); } +function openLoanModal(loan = null) { + const isEdit = Boolean(loan); + const todayMonth = new Date().toISOString().slice(0, 7); + const content = ` +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ `; + + openSharedModal({ + title: isEdit ? t('budget.editLoan') : t('budget.newLoan'), + content, + size: 'sm', + onSave(panel) { + panel.querySelector('#lm-cancel').addEventListener('click', closeModal); + panel.querySelector('#lm-save').addEventListener('click', async () => { + const saveBtn = panel.querySelector('#lm-save'); + const borrower = panel.querySelector('#lm-borrower').value.trim(); + const title = panel.querySelector('#lm-title').value.trim() || borrower; + const total_amount = parseFloat(panel.querySelector('#lm-amount').value); + const installment_count = parseInt(panel.querySelector('#lm-installments').value, 10); + const start_month = panel.querySelector('#lm-start').value; + const notes = panel.querySelector('#lm-notes').value.trim(); + + if (!borrower) { window.oikos?.showToast(t('budget.loanBorrowerRequired'), 'error'); return; } + if (isNaN(total_amount) || total_amount <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; } + if (!Number.isInteger(installment_count) || installment_count < 1) { window.oikos?.showToast(t('budget.loanInstallmentsRequired'), 'error'); return; } + if (!/^\d{4}-\d{2}$/.test(start_month)) { window.oikos?.showToast(t('budget.loanStartMonthRequired'), 'error'); return; } + + saveBtn.disabled = true; + saveBtn.textContent = '...'; + try { + const body = { borrower, title, total_amount, installment_count, start_month, notes }; + if (isEdit) { + await api.put(`/budget/loans/${loan.id}`, body); + } else { + await api.post('/budget/loans', body); + } + await loadMonth(state.month); + closeModal({ force: true }); + renderBody(); + window.oikos?.showToast(isEdit ? t('budget.loanSavedToast') : t('budget.loanAddedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + saveBtn.disabled = false; + saveBtn.textContent = isEdit ? t('common.save') : t('budget.createLoan'); + } + }); + }, + }); +} + +async function markLoanPayment(id) { + const loan = state.loans.loans.find((item) => item.id === id); + if (!loan?.next_installment_number) return; + const today = new Date().toISOString().slice(0, 10); + try { + await api.post(`/budget/loans/${id}/payments`, { + installment_number: loan.next_installment_number, + amount: loan.next_installment_number === loan.installment_count + ? loan.remaining_amount + : Math.min(loan.installment_amount, loan.remaining_amount), + paid_date: today, + }); + await loadMonth(state.month); + renderBody(); + window.oikos?.showToast(t('budget.loanPaymentAddedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + } +} + +async function deleteLoan(id) { + const loan = state.loans.loans.find((item) => item.id === id); + if (!loan) return; + if (!window.confirm(t('budget.deleteLoanConfirm', { title: loan.title }))) return; + try { + await api.delete(`/budget/loans/${id}`); + await loadMonth(state.month); + renderBody(); + window.oikos?.showToast(t('budget.loanDeletedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + } +} + // -------------------------------------------------------- // Eintrag löschen // -------------------------------------------------------- diff --git a/public/styles/budget.css b/public/styles/budget.css index d9c0e3e..b73cf47 100644 --- a/public/styles/budget.css +++ b/public/styles/budget.css @@ -176,6 +176,177 @@ flex-shrink: 0; } +/* -------------------------------------------------------- + * Empréstimos + * -------------------------------------------------------- */ +.budget-loans { + margin: 0 var(--space-4) var(--space-3); + padding: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.budget-loans__header, +.budget-loan-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.budget-loans__eyebrow { + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.budget-loans__summary { + font-size: var(--text-sm); + color: var(--color-text-secondary); + margin-top: 2px; +} + +.budget-loans__add { + flex-shrink: 0; +} + +.budget-loans__stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + margin-top: var(--space-3); +} + +.budget-loans__stats > div { + min-width: 0; + padding: var(--space-2); + border-radius: var(--radius-sm); + background: var(--color-surface-2); +} + +.budget-loans__stats span, +.budget-loan-card__amounts span, +.budget-loan-card__meta, +.budget-loan-card__footer > span { + display: block; + font-size: var(--text-xs); + color: var(--color-text-secondary); +} + +.budget-loans__stats strong, +.budget-loan-card__amounts strong { + display: block; + font-size: var(--text-base); + color: var(--color-text-primary); + margin-top: 2px; + white-space: nowrap; +} + +.budget-loans__list { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-2); + margin-top: var(--space-3); + max-height: 260px; + overflow-y: auto; +} + +.budget-loans__empty { + color: var(--color-text-secondary); + font-size: var(--text-sm); + padding: var(--space-3) 0 0; +} + +.budget-loan-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-2) var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); +} + +.budget-loan-card__main { + min-width: 0; +} + +.budget-loan-card__title { + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.budget-loan-card__amounts { + text-align: right; +} + +.budget-loan-card__progress { + grid-column: 1 / -1; + height: 6px; + overflow: hidden; + border-radius: var(--radius-full); + background: var(--color-surface-2); +} + +.budget-loan-card__progress span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--color-success); +} + +.budget-loan-card__footer { + grid-column: 1 / -1; +} + +.budget-loan-card__actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.budget-page .form-grid-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +@media (max-width: 560px) { + .budget-loans__header, + .budget-loan-card__footer { + align-items: stretch; + flex-direction: column; + } + + .budget-loans__stats { + grid-template-columns: 1fr; + } + + .budget-loan-card { + grid-template-columns: 1fr; + } + + .budget-loan-card__amounts { + text-align: left; + } + + .budget-loan-card__actions { + justify-content: flex-end; + flex-wrap: wrap; + } + + .budget-page .form-grid-2 { + grid-template-columns: 1fr; + } +} + /* -------------------------------------------------------- * Transaktions-Liste * -------------------------------------------------------- */ diff --git a/server/db-schema-test.js b/server/db-schema-test.js index e3fb25e..cec0981 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -159,6 +159,31 @@ const MIGRATIONS_SQL = { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(category_key, name) ); + CREATE TABLE IF NOT EXISTS budget_loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + borrower TEXT NOT NULL, + total_amount REAL NOT NULL CHECK(total_amount > 0), + installment_count INTEGER NOT NULL CHECK(installment_count > 0), + start_month TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'paid')), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + 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')) + ); + CREATE TABLE IF NOT EXISTS budget_loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES budget_loans(id) ON DELETE CASCADE, + installment_number INTEGER NOT NULL CHECK(installment_number > 0), + amount REAL NOT NULL CHECK(amount > 0), + paid_date TEXT NOT NULL, + budget_entry_id INTEGER REFERENCES budget_entries(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(loan_id, installment_number) + ); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -203,6 +228,9 @@ const MIGRATIONS_SQL = { CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at AFTER UPDATE ON budget_entries FOR EACH ROW BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + CREATE TRIGGER IF NOT EXISTS trg_budget_loans_updated_at + AFTER UPDATE ON budget_loans FOR EACH ROW + BEGIN UPDATE budget_loans SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to); CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); @@ -214,6 +242,10 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned); CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date); CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by); + CREATE INDEX IF NOT EXISTS idx_budget_loans_status ON budget_loans(status); + CREATE INDEX IF NOT EXISTS idx_budget_loans_start_month ON budget_loans(start_month); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_loan ON budget_loan_payments(loan_id); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date); CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name); CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date); CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by); @@ -425,6 +457,43 @@ const MIGRATIONS_SQL = { ALTER TABLE calendar_events ADD COLUMN attachment_size INTEGER; ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT; `, + 21: ` + CREATE TABLE IF NOT EXISTS budget_loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + borrower TEXT NOT NULL, + total_amount REAL NOT NULL CHECK(total_amount > 0), + installment_count INTEGER NOT NULL CHECK(installment_count > 0), + start_month TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'paid')), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + 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')) + ); + + CREATE TABLE IF NOT EXISTS budget_loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES budget_loans(id) ON DELETE CASCADE, + installment_number INTEGER NOT NULL CHECK(installment_number > 0), + amount REAL NOT NULL CHECK(amount > 0), + paid_date TEXT NOT NULL, + budget_entry_id INTEGER REFERENCES budget_entries(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(loan_id, installment_number) + ); + + CREATE TRIGGER IF NOT EXISTS trg_budget_loans_updated_at + AFTER UPDATE ON budget_loans FOR EACH ROW + BEGIN UPDATE budget_loans SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_budget_loans_status ON budget_loans(status); + CREATE INDEX IF NOT EXISTS idx_budget_loans_start_month ON budget_loans(start_month); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_loan ON budget_loan_payments(loan_id); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date); + `, }; export { MIGRATIONS_SQL }; diff --git a/server/db.js b/server/db.js index d9adb6c..235005a 100644 --- a/server/db.js +++ b/server/db.js @@ -873,6 +873,47 @@ const MIGRATIONS = [ ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT; `, }, + { + version: 28, + description: 'Budget loans and installment payments', + up: ` + CREATE TABLE IF NOT EXISTS budget_loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + borrower TEXT NOT NULL, + total_amount REAL NOT NULL CHECK(total_amount > 0), + installment_count INTEGER NOT NULL CHECK(installment_count > 0), + start_month TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'paid')), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + 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')) + ); + + CREATE TABLE IF NOT EXISTS budget_loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES budget_loans(id) ON DELETE CASCADE, + installment_number INTEGER NOT NULL CHECK(installment_number > 0), + amount REAL NOT NULL CHECK(amount > 0), + paid_date TEXT NOT NULL, + budget_entry_id INTEGER REFERENCES budget_entries(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(loan_id, installment_number) + ); + + CREATE TRIGGER IF NOT EXISTS trg_budget_loans_updated_at + AFTER UPDATE ON budget_loans FOR EACH ROW + BEGIN UPDATE budget_loans SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_budget_loans_status ON budget_loans(status); + CREATE INDEX IF NOT EXISTS idx_budget_loans_start_month ON budget_loans(start_month); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_loan ON budget_loan_payments(loan_id); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date); + `, + }, ]; /** diff --git a/server/routes/budget.js b/server/routes/budget.js index 36c7386..00e123c 100644 --- a/server/routes/budget.js +++ b/server/routes/budget.js @@ -9,7 +9,7 @@ import express from 'express'; import { readFileSync } from 'node:fs'; import path from 'path'; import * as db from '../db.js'; -import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js'; +import { str, oneOf, date as validateDate, month as validateMonth, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js'; const log = createLogger('Budget'); @@ -228,6 +228,65 @@ function validateSubcategory(category, subcategory) { return row ? subcategory : null; } +function addMonths(ym, n) { + const [y, m] = ym.split('-').map(Number); + const d = new Date(y, m - 1 + n, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; +} + +function cents(value) { + return Math.round(Number(value || 0) * 100) / 100; +} + +function loanSummaryRow(loan) { + const payments = db.get().prepare(` + SELECT p.*, u.display_name AS creator_name + FROM budget_loan_payments p + LEFT JOIN users u ON u.id = p.created_by + WHERE p.loan_id = ? + ORDER BY p.installment_number ASC + `).all(loan.id); + const paidAmount = cents(payments.reduce((sum, p) => sum + Number(p.amount || 0), 0)); + const paidInstallments = payments.length; + const remainingAmount = Math.max(0, cents(loan.total_amount - paidAmount)); + const remainingInstallments = Math.max(0, loan.installment_count - paidInstallments); + const installmentAmount = cents(loan.total_amount / loan.installment_count); + + return { + ...loan, + total_amount: cents(loan.total_amount), + installment_amount: installmentAmount, + paid_amount: paidAmount, + paid_installments: paidInstallments, + remaining_amount: remainingAmount, + remaining_installments: remainingInstallments, + next_installment_number: remainingInstallments > 0 ? paidInstallments + 1 : null, + next_due_month: remainingInstallments > 0 ? addMonths(loan.start_month, paidInstallments) : null, + payments, + }; +} + +function loadLoan(id) { + const loan = db.get().prepare(` + SELECT l.*, u.display_name AS creator_name + FROM budget_loans l + LEFT JOIN users u ON u.id = l.created_by + WHERE l.id = ? + `).get(id); + return loan ? loanSummaryRow(loan) : null; +} + +function refreshLoanStatus(loanId) { + const loan = loadLoan(loanId); + if (!loan) return null; + const status = loan.remaining_installments === 0 || loan.remaining_amount <= 0.005 ? 'paid' : 'active'; + if (status !== loan.status) { + db.get().prepare('UPDATE budget_loans SET status = ? WHERE id = ?').run(status, loanId); + return loadLoan(loanId); + } + return loan; +} + // -------------------------------------------------------- // Statische Routen vor /:id // -------------------------------------------------------- @@ -391,6 +450,240 @@ router.get('/categories/:categoryKey/subcategories', (req, res) => { } }); +router.get('/loans', (req, res) => { + try { + const loans = db.get().prepare(` + SELECT l.*, u.display_name AS creator_name + FROM budget_loans l + LEFT JOIN users u ON u.id = l.created_by + ORDER BY CASE l.status WHEN 'active' THEN 0 ELSE 1 END, + l.start_month ASC, + l.created_at DESC + `).all().map(loanSummaryRow); + const active = loans.filter((loan) => loan.status === 'active'); + const totals = loans.reduce((acc, loan) => { + acc.total_amount += loan.total_amount; + acc.paid_amount += loan.paid_amount; + acc.remaining_amount += loan.remaining_amount; + acc.remaining_installments += loan.remaining_installments; + return acc; + }, { total_amount: 0, paid_amount: 0, remaining_amount: 0, remaining_installments: 0 }); + + res.json({ + data: { + loans, + summary: { + active_count: active.length, + total_count: loans.length, + total_amount: cents(totals.total_amount), + paid_amount: cents(totals.paid_amount), + remaining_amount: cents(totals.remaining_amount), + remaining_installments: totals.remaining_installments, + }, + }, + }); + } catch (err) { + log.error('GET /loans error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.post('/loans', (req, res) => { + try { + const vTitle = str(req.body.title || req.body.borrower, 'Title', { max: MAX_TITLE }); + const vBorrower = str(req.body.borrower, 'Borrower', { max: MAX_SHORT }); + const vAmount = num(req.body.total_amount, 'Amount', { required: true }); + const vStartMonth = validateMonth(req.body.start_month, 'Start month'); + const vNotes = str(req.body.notes, 'Notes', { max: 1000, required: false }); + const installmentCount = parseInt(req.body.installment_count, 10); + const errors = collectErrors([vTitle, vBorrower, vAmount, vStartMonth, vNotes]); + if (!Number.isInteger(installmentCount) || installmentCount < 1 || installmentCount > 240) { + errors.push('Installment count must be between 1 and 240.'); + } + if (vAmount.value !== null && vAmount.value <= 0) errors.push('Amount must be greater than zero.'); + if (!vStartMonth.value) errors.push('Start month is required.'); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const result = db.get().prepare(` + INSERT INTO budget_loans (title, borrower, total_amount, installment_count, start_month, notes, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + vTitle.value, + vBorrower.value, + cents(vAmount.value), + installmentCount, + vStartMonth.value, + vNotes.value, + req.session.userId + ); + + res.status(201).json({ data: loadLoan(result.lastInsertRowid) }); + } catch (err) { + log.error('POST /loans error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.put('/loans/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const loan = db.get().prepare('SELECT * FROM budget_loans WHERE id = ?').get(id); + if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); + + const checks = []; + if (req.body.title !== undefined) checks.push(str(req.body.title, 'Title', { max: MAX_TITLE })); + if (req.body.borrower !== undefined) checks.push(str(req.body.borrower, 'Borrower', { max: MAX_SHORT })); + if (req.body.total_amount !== undefined) checks.push(num(req.body.total_amount, 'Amount')); + if (req.body.start_month !== undefined) checks.push(validateMonth(req.body.start_month, 'Start month')); + if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notes', { max: 1000, required: false })); + const errors = collectErrors(checks); + const installmentCount = req.body.installment_count === undefined ? null : parseInt(req.body.installment_count, 10); + if (req.body.installment_count !== undefined && (!Number.isInteger(installmentCount) || installmentCount < 1 || installmentCount > 240)) { + errors.push('Installment count must be between 1 and 240.'); + } + const paidCount = db.get().prepare('SELECT COUNT(*) AS c FROM budget_loan_payments WHERE loan_id = ?').get(id).c; + if (installmentCount !== null && installmentCount < paidCount) { + errors.push('Installment count cannot be lower than paid installments.'); + } + if (req.body.total_amount !== undefined && Number(req.body.total_amount) <= 0) errors.push('Amount must be greater than zero.'); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + db.get().prepare(` + UPDATE budget_loans + SET title = COALESCE(?, title), + borrower = COALESCE(?, borrower), + total_amount = COALESCE(?, total_amount), + installment_count = COALESCE(?, installment_count), + start_month = COALESCE(?, start_month), + notes = ? + WHERE id = ? + `).run( + req.body.title?.trim() ?? null, + req.body.borrower?.trim() ?? null, + req.body.total_amount !== undefined ? cents(req.body.total_amount) : null, + installmentCount, + req.body.start_month ?? null, + req.body.notes !== undefined ? (req.body.notes?.trim() || null) : loan.notes, + id + ); + + res.json({ data: refreshLoanStatus(id) }); + } catch (err) { + log.error('PUT /loans/:id error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.post('/loans/:id/payments', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const loan = loadLoan(id); + if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); + if (loan.remaining_installments <= 0) return res.status(409).json({ error: 'Loan is already paid.', code: 409 }); + + const installmentNumber = req.body.installment_number === undefined + ? loan.next_installment_number + : parseInt(req.body.installment_number, 10); + const defaultAmount = installmentNumber === loan.installment_count + ? loan.remaining_amount + : Math.min(loan.installment_amount, loan.remaining_amount); + const vAmount = num(req.body.amount ?? defaultAmount, 'Amount', { required: true }); + const vDate = validateDate(req.body.paid_date, 'Paid date', true); + const errors = collectErrors([vAmount, vDate]); + if (!Number.isInteger(installmentNumber) || installmentNumber < 1 || installmentNumber > loan.installment_count) { + errors.push('Installment number is invalid.'); + } + if (vAmount.value !== null && vAmount.value <= 0) errors.push('Amount must be greater than zero.'); + if (vAmount.value !== null && vAmount.value - loan.remaining_amount > 0.005) { + errors.push('Amount cannot be greater than the remaining loan amount.'); + } + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const existing = db.get().prepare(` + SELECT 1 FROM budget_loan_payments WHERE loan_id = ? AND installment_number = ? + `).get(id, installmentNumber); + if (existing) return res.status(409).json({ error: 'Installment already paid.', code: 409 }); + + const paymentAmount = cents(vAmount.value); + const tx = db.get().transaction(() => { + const budgetResult = db.get().prepare(` + INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, created_by) + VALUES (?, ?, ?, '', ?, 0, ?) + `).run( + `Loan repayment: ${loan.borrower}`, + paymentAmount, + 'Geschenke & Transfers', + vDate.value, + req.session.userId + ); + const paymentResult = db.get().prepare(` + INSERT INTO budget_loan_payments + (loan_id, installment_number, amount, paid_date, budget_entry_id, created_by) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, installmentNumber, paymentAmount, vDate.value, budgetResult.lastInsertRowid, req.session.userId); + return paymentResult.lastInsertRowid; + }); + + const paymentId = tx(); + res.status(201).json({ + data: { + payment: db.get().prepare('SELECT * FROM budget_loan_payments WHERE id = ?').get(paymentId), + loan: refreshLoanStatus(id), + }, + }); + } catch (err) { + log.error('POST /loans/:id/payments error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.delete('/loans/:id/payments/:paymentId', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const paymentId = parseInt(req.params.paymentId, 10); + const payment = db.get().prepare(` + SELECT * FROM budget_loan_payments WHERE id = ? AND loan_id = ? + `).get(paymentId, id); + if (!payment) return res.status(404).json({ error: 'Payment not found.', code: 404 }); + + const tx = db.get().transaction(() => { + db.get().prepare('DELETE FROM budget_loan_payments WHERE id = ?').run(paymentId); + if (payment.budget_entry_id) { + db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(payment.budget_entry_id); + } + }); + tx(); + refreshLoanStatus(id); + res.status(204).end(); + } catch (err) { + log.error('DELETE /loans/:id/payments/:paymentId error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.delete('/loans/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const loan = db.get().prepare('SELECT * FROM budget_loans WHERE id = ?').get(id); + if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); + + const payments = db.get().prepare('SELECT budget_entry_id FROM budget_loan_payments WHERE loan_id = ?').all(id); + const tx = db.get().transaction(() => { + db.get().prepare('DELETE FROM budget_loans WHERE id = ?').run(id); + for (const payment of payments) { + if (payment.budget_entry_id) { + db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(payment.budget_entry_id); + } + } + }); + tx(); + res.status(204).end(); + } catch (err) { + log.error('DELETE /loans/:id error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + router.post('/categories', (req, res) => { try { const vName = str(req.body.name, 'Name', { max: MAX_SHORT }); diff --git a/test-notes-contacts-budget.js b/test-notes-contacts-budget.js index a2cb3e1..a6803f4 100644 --- a/test-notes-contacts-budget.js +++ b/test-notes-contacts-budget.js @@ -307,6 +307,40 @@ test('Index idx_budget_date genutzt', () => { assert(usesIndex, JSON.stringify(plan)); }); +test('Empréstimo com parcelas calcula restante', () => { + const loan = db.prepare(` + INSERT INTO budget_loans (title, borrower, total_amount, installment_count, start_month, created_by) + VALUES ('Empréstimo Lais', 'Lais', 1000, 5, '2026-03', ?) + `).run(uid); + const loanId = loan.lastInsertRowid; + + const entry = db.prepare(` + INSERT INTO budget_entries (title, amount, category, date, created_by) + VALUES ('Loan repayment: Lais', 200, 'Geschenke & Transfers', '2026-03-05', ?) + `).run(uid); + db.prepare(` + INSERT INTO budget_loan_payments + (loan_id, installment_number, amount, paid_date, budget_entry_id, created_by) + VALUES (?, 1, 200, '2026-03-05', ?, ?) + `).run(loanId, entry.lastInsertRowid, uid); + + const totals = db.prepare(` + SELECT l.total_amount, + l.installment_count, + COUNT(p.id) AS paid_installments, + COALESCE(SUM(p.amount), 0) AS paid_amount + FROM budget_loans l + LEFT JOIN budget_loan_payments p ON p.loan_id = l.id + WHERE l.id = ? + GROUP BY l.id + `).get(loanId); + + assert(totals.paid_installments === 1, `Parcelas pagas: ${totals.paid_installments}`); + assert(Math.abs(totals.paid_amount - 200) < 0.01, `Pago: ${totals.paid_amount}`); + assert(Math.abs((totals.total_amount - totals.paid_amount) - 800) < 0.01, 'Restante deve ser 800'); + assert(totals.installment_count - totals.paid_installments === 4, 'Devem restar 4 parcelas'); +}); + // -------------------------------------------------------- // Ergebnis // -------------------------------------------------------- From 9a80b785c8e777681d77abcae801d004561b0829 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Thu, 30 Apr 2026 23:20:25 -0300 Subject: [PATCH 2/9] Refine budget loan entry flow --- public/locales/ar.json | 65 ++++++++--------- public/locales/de.json | 5 +- public/locales/el.json | 65 ++++++++--------- public/locales/en.json | 3 +- public/locales/es.json | 63 ++++++++-------- public/locales/fr.json | 61 ++++++++-------- public/locales/hi.json | 65 ++++++++--------- public/locales/it.json | 63 ++++++++-------- public/locales/ja.json | 65 ++++++++--------- public/locales/pt.json | 3 +- public/locales/ru.json | 65 ++++++++--------- public/locales/sv.json | 65 ++++++++--------- public/locales/tr.json | 65 ++++++++--------- public/locales/uk.json | 65 ++++++++--------- public/locales/zh.json | 65 ++++++++--------- public/pages/budget.js | 152 ++++++++++++++++++++++++++------------- public/styles/budget.css | 11 ++- 17 files changed, 511 insertions(+), 435 deletions(-) diff --git a/public/locales/ar.json b/public/locales/ar.json index 9684c58..12a0bef 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "القروض / الفوائد", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "إضافة إدخال", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "القروض", + "loansSummary": "{{count}} نشط · المتبقي {{amount}}", + "newLoan": "قرض جديد", + "createLoan": "إنشاء قرض", + "editLoan": "تعديل القرض", + "deleteLoan": "حذف القرض", + "deleteLoanConfirm": "هل تريد حذف القرض \"{{title}}\"؟ ستتم إزالة الدفعات المسجلة في الميزانية أيضًا.", + "loanRemainingAmount": "المتبقي", + "loanRemainingInstallments": "الأقساط المتبقية", + "loanPaidAmount": "المدفوع", + "loansEmpty": "لا توجد قروض نشطة.", + "loanInstallmentMeta": "تم دفع {{paid}} من {{total}} أقساط", + "loanRemainingOf": "من {{total}}", + "loanNextDue": "التالي: {{month}}", + "loanPaidStatus": "مدفوع", + "markLoanPaid": "تسجيل الدفع", + "loanBorrowerLabel": "الشخص *", + "loanBorrowerPlaceholder": "مثال: Lais", + "loanTitleLabel": "عنوان القرض", + "loanTitlePlaceholder": "مثال: قرض شخصي", + "loanAmountLabel": "المبلغ الإجمالي *", + "loanInstallmentsLabel": "الأقساط *", + "loanStartMonthLabel": "أول شهر استحقاق *", + "loanNotesLabel": "ملاحظات", + "loanBorrowerRequired": "الشخص مطلوب", + "loanInstallmentsRequired": "أدخل عدد الأقساط", + "loanStartMonthRequired": "أدخل أول شهر استحقاق", + "loanAddedToast": "تمت إضافة القرض", + "loanSavedToast": "تم حفظ القرض", + "loanDeletedToast": "تم حذف القرض", + "loanPaymentAddedToast": "تم تسجيل الدفع", + "typeLoan": "قرض" }, "settings": { "title": "الإعدادات", diff --git a/public/locales/de.json b/public/locales/de.json index 4500f45..b31a104 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -606,7 +606,7 @@ "loanRemainingOf": "von {{total}}", "loanNextDue": "Nächste: {{month}}", "loanPaidStatus": "Bezahlt", - "markLoanPaid": "Bezahlt", + "markLoanPaid": "Als bezahlt markieren", "loanBorrowerLabel": "Person *", "loanBorrowerPlaceholder": "z. B. Lais", "loanTitleLabel": "Darlehenstitel", @@ -621,7 +621,8 @@ "loanAddedToast": "Darlehen hinzugefügt", "loanSavedToast": "Darlehen gespeichert", "loanDeletedToast": "Darlehen gelöscht", - "loanPaymentAddedToast": "Zahlung erfasst" + "loanPaymentAddedToast": "Zahlung erfasst", + "typeLoan": "Darlehen" }, "settings": { "title": "Einstellungen", diff --git a/public/locales/el.json b/public/locales/el.json index 268662d..dc489f3 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "Δάνεια / Τόκοι", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "Προσθήκη εγγραφής", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "Δάνεια", + "loansSummary": "{{count}} ενεργά · απομένουν {{amount}}", + "newLoan": "Νέο δάνειο", + "createLoan": "Δημιουργία δανείου", + "editLoan": "Επεξεργασία δανείου", + "deleteLoan": "Διαγραφή δανείου", + "deleteLoanConfirm": "Να διαγραφεί το δάνειο «{{title}}»; Οι πληρωμές που έχουν ήδη περαστεί στον προϋπολογισμό θα αφαιρεθούν επίσης.", + "loanRemainingAmount": "Υπόλοιπο", + "loanRemainingInstallments": "Δόσεις που απομένουν", + "loanPaidAmount": "Πληρωμένο", + "loansEmpty": "Δεν υπάρχουν ενεργά δάνεια.", + "loanInstallmentMeta": "{{paid}} από {{total}} δόσεις πληρωμένες", + "loanRemainingOf": "από {{total}}", + "loanNextDue": "Επόμενη: {{month}}", + "loanPaidStatus": "Πληρωμένο", + "markLoanPaid": "Σήμανση πληρωμής", + "loanBorrowerLabel": "Άτομο *", + "loanBorrowerPlaceholder": "π.χ. Lais", + "loanTitleLabel": "Τίτλος δανείου", + "loanTitlePlaceholder": "π.χ. Προσωπικό δάνειο", + "loanAmountLabel": "Συνολικό ποσό *", + "loanInstallmentsLabel": "Δόσεις *", + "loanStartMonthLabel": "Πρώτος μήνας λήξης *", + "loanNotesLabel": "Σημειώσεις", + "loanBorrowerRequired": "Το άτομο είναι υποχρεωτικό", + "loanInstallmentsRequired": "Εισαγάγετε τον αριθμό δόσεων", + "loanStartMonthRequired": "Εισαγάγετε τον πρώτο μήνα λήξης", + "loanAddedToast": "Το δάνειο προστέθηκε", + "loanSavedToast": "Το δάνειο αποθηκεύτηκε", + "loanDeletedToast": "Το δάνειο διαγράφηκε", + "loanPaymentAddedToast": "Η πληρωμή καταγράφηκε", + "typeLoan": "Δάνειο" }, "settings": { "title": "Ρυθμίσεις", diff --git a/public/locales/en.json b/public/locales/en.json index 460ba14..e302cb3 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -596,7 +596,8 @@ "loanAddedToast": "Loan added", "loanSavedToast": "Loan saved", "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loanPaymentAddedToast": "Payment recorded", + "typeLoan": "Loan" }, "settings": { "title": "Settings", diff --git a/public/locales/es.json b/public/locales/es.json index aa7206d..42a3eae 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -566,37 +566,38 @@ "categoryAddedToast": "Categoría añadida.", "subcategoryAddedToast": "Subcategoría añadida.", "emptyAction": "Agregar entrada", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "Préstamos", + "loansSummary": "{{count}} activos · {{amount}} restantes", + "newLoan": "Nuevo préstamo", + "createLoan": "Crear préstamo", + "editLoan": "Editar préstamo", + "deleteLoan": "Eliminar préstamo", + "deleteLoanConfirm": "¿Eliminar el préstamo \"{{title}}\"? También se eliminarán los pagos ya registrados en el presupuesto.", + "loanRemainingAmount": "Restante", + "loanRemainingInstallments": "Cuotas restantes", + "loanPaidAmount": "Pagado", + "loansEmpty": "No hay préstamos activos.", + "loanInstallmentMeta": "{{paid}} de {{total}} cuotas pagadas", + "loanRemainingOf": "de {{total}}", + "loanNextDue": "Siguiente: {{month}}", + "loanPaidStatus": "Pagado", + "markLoanPaid": "Marcar pagado", + "loanBorrowerLabel": "Persona *", + "loanBorrowerPlaceholder": "Ej. Lais", + "loanTitleLabel": "Título del préstamo", + "loanTitlePlaceholder": "Ej. Préstamo personal", + "loanAmountLabel": "Importe total *", + "loanInstallmentsLabel": "Cuotas *", + "loanStartMonthLabel": "Primer mes de vencimiento *", + "loanNotesLabel": "Notas", + "loanBorrowerRequired": "La persona es obligatoria", + "loanInstallmentsRequired": "Introduce el número de cuotas", + "loanStartMonthRequired": "Introduce el primer mes de vencimiento", + "loanAddedToast": "Préstamo añadido", + "loanSavedToast": "Préstamo guardado", + "loanDeletedToast": "Préstamo eliminado", + "loanPaymentAddedToast": "Pago registrado", + "typeLoan": "Préstamo" }, "settings": { "title": "Ajustes", diff --git a/public/locales/fr.json b/public/locales/fr.json index 767d26d..2375324 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -566,37 +566,38 @@ "categoryAddedToast": "Catégorie ajoutée.", "subcategoryAddedToast": "Sous-catégorie ajoutée.", "emptyAction": "Ajouter une entrée", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", + "loansTitle": "Prêts", + "loansSummary": "{{count}} actifs · {{amount}} restants", + "newLoan": "Nouveau prêt", + "createLoan": "Créer le prêt", + "editLoan": "Modifier le prêt", + "deleteLoan": "Supprimer le prêt", + "deleteLoanConfirm": "Supprimer le prêt \"{{title}}\" ? Les paiements déjà enregistrés dans le budget seront aussi supprimés.", + "loanRemainingAmount": "Restant", + "loanRemainingInstallments": "Échéances restantes", + "loanPaidAmount": "Payé", + "loansEmpty": "Aucun prêt actif.", + "loanInstallmentMeta": "{{paid}} sur {{total}} échéances payées", + "loanRemainingOf": "sur {{total}}", + "loanNextDue": "Prochaine : {{month}}", + "loanPaidStatus": "Payé", + "markLoanPaid": "Marquer payé", + "loanBorrowerLabel": "Personne *", + "loanBorrowerPlaceholder": "Ex. Lais", + "loanTitleLabel": "Titre du prêt", + "loanTitlePlaceholder": "Ex. Prêt personnel", + "loanAmountLabel": "Montant total *", + "loanInstallmentsLabel": "Échéances *", + "loanStartMonthLabel": "Premier mois d’échéance *", "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loanBorrowerRequired": "La personne est obligatoire", + "loanInstallmentsRequired": "Indiquez le nombre d’échéances", + "loanStartMonthRequired": "Indiquez le premier mois d’échéance", + "loanAddedToast": "Prêt ajouté", + "loanSavedToast": "Prêt enregistré", + "loanDeletedToast": "Prêt supprimé", + "loanPaymentAddedToast": "Paiement enregistré", + "typeLoan": "Prêt" }, "settings": { "title": "Paramètres", diff --git a/public/locales/hi.json b/public/locales/hi.json index e173bac..a71d971 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "उधार / ब्याज", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "प्रविष्टि जोड़ें", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "उधार", + "loansSummary": "{{count}} सक्रिय · {{amount}} बाकी", + "newLoan": "नया उधार", + "createLoan": "उधार बनाएं", + "editLoan": "उधार संपादित करें", + "deleteLoan": "उधार हटाएं", + "deleteLoanConfirm": "उधार \"{{title}}\" हटाएं? बजट में दर्ज भुगतान भी हटा दिए जाएंगे।", + "loanRemainingAmount": "बाकी", + "loanRemainingInstallments": "बाकी किस्तें", + "loanPaidAmount": "भुगतान किया", + "loansEmpty": "कोई सक्रिय उधार नहीं।", + "loanInstallmentMeta": "{{total}} में से {{paid}} किस्तें चुकाई गईं", + "loanRemainingOf": "{{total}} में से", + "loanNextDue": "अगली: {{month}}", + "loanPaidStatus": "चुकाया गया", + "markLoanPaid": "भुगतान दर्ज करें", + "loanBorrowerLabel": "व्यक्ति *", + "loanBorrowerPlaceholder": "जैसे Lais", + "loanTitleLabel": "उधार का शीर्षक", + "loanTitlePlaceholder": "जैसे व्यक्तिगत उधार", + "loanAmountLabel": "कुल राशि *", + "loanInstallmentsLabel": "किस्तें *", + "loanStartMonthLabel": "पहला देय महीना *", + "loanNotesLabel": "नोट्स", + "loanBorrowerRequired": "व्यक्ति आवश्यक है", + "loanInstallmentsRequired": "किस्तों की संख्या दर्ज करें", + "loanStartMonthRequired": "पहला देय महीना दर्ज करें", + "loanAddedToast": "उधार जोड़ा गया", + "loanSavedToast": "उधार सहेजा गया", + "loanDeletedToast": "उधार हटाया गया", + "loanPaymentAddedToast": "भुगतान दर्ज किया गया", + "typeLoan": "उधार" }, "settings": { "title": "सेटिंग्स", diff --git a/public/locales/it.json b/public/locales/it.json index b6351bc..6d52c9d 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -566,37 +566,38 @@ "categoryAddedToast": "Categoria aggiunta.", "subcategoryAddedToast": "Sottocategoria aggiunta.", "emptyAction": "Aggiungi voce", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "Prestiti", + "loansSummary": "{{count}} attivi · {{amount}} rimanenti", + "newLoan": "Nuovo prestito", + "createLoan": "Crea prestito", + "editLoan": "Modifica prestito", + "deleteLoan": "Elimina prestito", + "deleteLoanConfirm": "Eliminare il prestito \"{{title}}\"? Verranno rimossi anche i pagamenti già registrati nel bilancio.", + "loanRemainingAmount": "Rimanente", + "loanRemainingInstallments": "Rate rimanenti", + "loanPaidAmount": "Pagato", + "loansEmpty": "Nessun prestito attivo.", + "loanInstallmentMeta": "{{paid}} di {{total}} rate pagate", + "loanRemainingOf": "di {{total}}", + "loanNextDue": "Prossima: {{month}}", + "loanPaidStatus": "Pagato", + "markLoanPaid": "Segna pagato", + "loanBorrowerLabel": "Persona *", + "loanBorrowerPlaceholder": "Es. Lais", + "loanTitleLabel": "Titolo del prestito", + "loanTitlePlaceholder": "Es. Prestito personale", + "loanAmountLabel": "Importo totale *", + "loanInstallmentsLabel": "Rate *", + "loanStartMonthLabel": "Primo mese di scadenza *", + "loanNotesLabel": "Note", + "loanBorrowerRequired": "La persona è obbligatoria", + "loanInstallmentsRequired": "Inserisci il numero di rate", + "loanStartMonthRequired": "Inserisci il primo mese di scadenza", + "loanAddedToast": "Prestito aggiunto", + "loanSavedToast": "Prestito salvato", + "loanDeletedToast": "Prestito eliminato", + "loanPaymentAddedToast": "Pagamento registrato", + "typeLoan": "Prestito" }, "settings": { "title": "Impostazioni", diff --git a/public/locales/ja.json b/public/locales/ja.json index e365f1b..4d8d440 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "貸付 / 利息", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "エントリを追加", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "貸付", + "loansSummary": "{{count}} 件が進行中 · 残り {{amount}}", + "newLoan": "新しい貸付", + "createLoan": "貸付を作成", + "editLoan": "貸付を編集", + "deleteLoan": "貸付を削除", + "deleteLoanConfirm": "貸付「{{title}}」を削除しますか?予算に記録済みの返済も削除されます。", + "loanRemainingAmount": "残額", + "loanRemainingInstallments": "残り回数", + "loanPaidAmount": "返済済み", + "loansEmpty": "進行中の貸付はありません。", + "loanInstallmentMeta": "{{total}} 回中 {{paid}} 回返済済み", + "loanRemainingOf": "{{total}} のうち", + "loanNextDue": "次回:{{month}}", + "loanPaidStatus": "完済", + "markLoanPaid": "返済済みにする", + "loanBorrowerLabel": "相手 *", + "loanBorrowerPlaceholder": "例:Lais", + "loanTitleLabel": "貸付タイトル", + "loanTitlePlaceholder": "例:個人貸付", + "loanAmountLabel": "合計金額 *", + "loanInstallmentsLabel": "分割回数 *", + "loanStartMonthLabel": "初回支払月 *", + "loanNotesLabel": "メモ", + "loanBorrowerRequired": "相手を入力してください", + "loanInstallmentsRequired": "分割回数を入力してください", + "loanStartMonthRequired": "初回支払月を入力してください", + "loanAddedToast": "貸付を追加しました", + "loanSavedToast": "貸付を保存しました", + "loanDeletedToast": "貸付を削除しました", + "loanPaymentAddedToast": "返済を記録しました", + "typeLoan": "貸付" }, "settings": { "title": "設定", diff --git a/public/locales/pt.json b/public/locales/pt.json index 7f5557d..065fc1f 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -596,7 +596,8 @@ "loanAddedToast": "Empréstimo adicionado", "loanSavedToast": "Empréstimo salvo", "loanDeletedToast": "Empréstimo excluído", - "loanPaymentAddedToast": "Pagamento registrado" + "loanPaymentAddedToast": "Pagamento registrado", + "typeLoan": "Empréstimo" }, "settings": { "title": "Configurações", diff --git a/public/locales/ru.json b/public/locales/ru.json index 1b405a3..464f901 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "Займы / Проценты", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "Добавить запись", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "Займы", + "loansSummary": "{{count}} активных · осталось {{amount}}", + "newLoan": "Новый займ", + "createLoan": "Создать займ", + "editLoan": "Изменить займ", + "deleteLoan": "Удалить займ", + "deleteLoanConfirm": "Удалить займ «{{title}}»? Платежи, уже добавленные в бюджет, тоже будут удалены.", + "loanRemainingAmount": "Осталось", + "loanRemainingInstallments": "Осталось платежей", + "loanPaidAmount": "Оплачено", + "loansEmpty": "Нет активных займов.", + "loanInstallmentMeta": "Оплачено {{paid}} из {{total}} платежей", + "loanRemainingOf": "из {{total}}", + "loanNextDue": "Следующий: {{month}}", + "loanPaidStatus": "Оплачено", + "markLoanPaid": "Отметить оплату", + "loanBorrowerLabel": "Человек *", + "loanBorrowerPlaceholder": "Напр. Lais", + "loanTitleLabel": "Название займа", + "loanTitlePlaceholder": "Напр. Личный займ", + "loanAmountLabel": "Общая сумма *", + "loanInstallmentsLabel": "Платежи *", + "loanStartMonthLabel": "Первый месяц оплаты *", + "loanNotesLabel": "Заметки", + "loanBorrowerRequired": "Укажите человека", + "loanInstallmentsRequired": "Укажите количество платежей", + "loanStartMonthRequired": "Укажите первый месяц оплаты", + "loanAddedToast": "Займ добавлен", + "loanSavedToast": "Займ сохранён", + "loanDeletedToast": "Займ удалён", + "loanPaymentAddedToast": "Платёж записан", + "typeLoan": "Займ" }, "settings": { "title": "Настройки", diff --git a/public/locales/sv.json b/public/locales/sv.json index a6830c9..8bf7ca4 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "Lån / Ränta", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "Lägg till post", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "Lån", + "loansSummary": "{{count}} aktiva · {{amount}} kvar", + "newLoan": "Nytt lån", + "createLoan": "Skapa lån", + "editLoan": "Redigera lån", + "deleteLoan": "Ta bort lån", + "deleteLoanConfirm": "Ta bort lånet \"{{title}}\"? Betalningar som redan bokförts i budgeten tas också bort.", + "loanRemainingAmount": "Kvar", + "loanRemainingInstallments": "Delbetalningar kvar", + "loanPaidAmount": "Betalt", + "loansEmpty": "Inga aktiva lån.", + "loanInstallmentMeta": "{{paid}} av {{total}} delbetalningar betalda", + "loanRemainingOf": "av {{total}}", + "loanNextDue": "Nästa: {{month}}", + "loanPaidStatus": "Betalt", + "markLoanPaid": "Markera betalt", + "loanBorrowerLabel": "Person *", + "loanBorrowerPlaceholder": "t.ex. Lais", + "loanTitleLabel": "Lånetitel", + "loanTitlePlaceholder": "t.ex. Privat lån", + "loanAmountLabel": "Totalbelopp *", + "loanInstallmentsLabel": "Delbetalningar *", + "loanStartMonthLabel": "Första förfallomånaden *", + "loanNotesLabel": "Anteckningar", + "loanBorrowerRequired": "Person krävs", + "loanInstallmentsRequired": "Ange antal delbetalningar", + "loanStartMonthRequired": "Ange första förfallomånaden", + "loanAddedToast": "Lån tillagt", + "loanSavedToast": "Lån sparat", + "loanDeletedToast": "Lån borttaget", + "loanPaymentAddedToast": "Betalning registrerad", + "typeLoan": "Lån" }, "settings": { "title": "Inställningar", diff --git a/public/locales/tr.json b/public/locales/tr.json index a0829fa..a4cb4e3 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "Borçlar / Faiz", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "Giriş ekle", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "Borçlar", + "loansSummary": "{{count}} aktif · {{amount}} kaldı", + "newLoan": "Yeni borç", + "createLoan": "Borç oluştur", + "editLoan": "Borcu düzenle", + "deleteLoan": "Borcu sil", + "deleteLoanConfirm": "\"{{title}}\" borcu silinsin mi? Bütçeye işlenmiş ödemeler de kaldırılır.", + "loanRemainingAmount": "Kalan", + "loanRemainingInstallments": "Kalan taksit", + "loanPaidAmount": "Ödenen", + "loansEmpty": "Aktif borç yok.", + "loanInstallmentMeta": "{{paid}} / {{total}} taksit ödendi", + "loanRemainingOf": "{{total}} içinden", + "loanNextDue": "Sonraki: {{month}}", + "loanPaidStatus": "Ödendi", + "markLoanPaid": "Ödendi işaretle", + "loanBorrowerLabel": "Kişi *", + "loanBorrowerPlaceholder": "Örn. Lais", + "loanTitleLabel": "Borç başlığı", + "loanTitlePlaceholder": "Örn. Kişisel borç", + "loanAmountLabel": "Toplam tutar *", + "loanInstallmentsLabel": "Taksitler *", + "loanStartMonthLabel": "İlk vade ayı *", + "loanNotesLabel": "Notlar", + "loanBorrowerRequired": "Kişi gerekli", + "loanInstallmentsRequired": "Taksit sayısını girin", + "loanStartMonthRequired": "İlk vade ayını girin", + "loanAddedToast": "Borç eklendi", + "loanSavedToast": "Borç kaydedildi", + "loanDeletedToast": "Borç silindi", + "loanPaymentAddedToast": "Ödeme kaydedildi", + "typeLoan": "Borç" }, "settings": { "title": "Ayarlar", diff --git a/public/locales/uk.json b/public/locales/uk.json index f615f46..9f2ebc2 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "Позики / Відсотки", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "Додати запис", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "Позики", + "loansSummary": "{{count}} активних · залишилось {{amount}}", + "newLoan": "Нова позика", + "createLoan": "Створити позику", + "editLoan": "Редагувати позику", + "deleteLoan": "Видалити позику", + "deleteLoanConfirm": "Видалити позику «{{title}}»? Платежі, вже додані до бюджету, також буде видалено.", + "loanRemainingAmount": "Залишилось", + "loanRemainingInstallments": "Залишилось платежів", + "loanPaidAmount": "Сплачено", + "loansEmpty": "Немає активних позик.", + "loanInstallmentMeta": "Сплачено {{paid}} з {{total}} платежів", + "loanRemainingOf": "з {{total}}", + "loanNextDue": "Наступний: {{month}}", + "loanPaidStatus": "Сплачено", + "markLoanPaid": "Позначити сплату", + "loanBorrowerLabel": "Людина *", + "loanBorrowerPlaceholder": "Напр. Lais", + "loanTitleLabel": "Назва позики", + "loanTitlePlaceholder": "Напр. Особиста позика", + "loanAmountLabel": "Загальна сума *", + "loanInstallmentsLabel": "Платежі *", + "loanStartMonthLabel": "Перший місяць сплати *", + "loanNotesLabel": "Нотатки", + "loanBorrowerRequired": "Вкажіть людину", + "loanInstallmentsRequired": "Вкажіть кількість платежів", + "loanStartMonthRequired": "Вкажіть перший місяць сплати", + "loanAddedToast": "Позику додано", + "loanSavedToast": "Позику збережено", + "loanDeletedToast": "Позику видалено", + "loanPaymentAddedToast": "Платіж записано", + "typeLoan": "Позика" }, "settings": { "title": "Налаштування", diff --git a/public/locales/zh.json b/public/locales/zh.json index 4288973..9bcade2 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -553,7 +553,7 @@ "subcatCoursesCollege": "Courses / College", "subcatSchoolSupplies": "School supplies", "subcatLanguages": "Languages", - "subcatLoansInterest": "Loans / Interest", + "subcatLoansInterest": "借款 / 利息", "subcatBankFees": "Bank fees", "subcatInsuranceOther": "Insurance", "subcatInvestments": "Investments", @@ -566,37 +566,38 @@ "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", "emptyAction": "添加记录", - "loansTitle": "Loans", - "loansSummary": "{{count}} active · {{amount}} remaining", - "newLoan": "New loan", - "createLoan": "Create loan", - "editLoan": "Edit loan", - "deleteLoan": "Delete loan", - "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", - "loanRemainingAmount": "Remaining", - "loanRemainingInstallments": "Installments left", - "loanPaidAmount": "Paid", - "loansEmpty": "No active loans.", - "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", - "loanRemainingOf": "of {{total}}", - "loanNextDue": "Next: {{month}}", - "loanPaidStatus": "Paid", - "markLoanPaid": "Mark paid", - "loanBorrowerLabel": "Borrower *", - "loanBorrowerPlaceholder": "e.g. Lais", - "loanTitleLabel": "Loan title", - "loanTitlePlaceholder": "e.g. Personal loan", - "loanAmountLabel": "Total amount *", - "loanInstallmentsLabel": "Installments *", - "loanStartMonthLabel": "First due month *", - "loanNotesLabel": "Notes", - "loanBorrowerRequired": "Borrower is required", - "loanInstallmentsRequired": "Enter the number of installments", - "loanStartMonthRequired": "Enter the first due month", - "loanAddedToast": "Loan added", - "loanSavedToast": "Loan saved", - "loanDeletedToast": "Loan deleted", - "loanPaymentAddedToast": "Payment recorded" + "loansTitle": "借款", + "loansSummary": "{{count}} 笔进行中 · 剩余 {{amount}}", + "newLoan": "新建借款", + "createLoan": "创建借款", + "editLoan": "编辑借款", + "deleteLoan": "删除借款", + "deleteLoanConfirm": "删除借款“{{title}}”?已记入预算的还款也会被删除。", + "loanRemainingAmount": "剩余金额", + "loanRemainingInstallments": "剩余期数", + "loanPaidAmount": "已还金额", + "loansEmpty": "没有进行中的借款。", + "loanInstallmentMeta": "已还 {{paid}} / {{total}} 期", + "loanRemainingOf": "共 {{total}}", + "loanNextDue": "下一期:{{month}}", + "loanPaidStatus": "已还清", + "markLoanPaid": "标记已还", + "loanBorrowerLabel": "借款人 *", + "loanBorrowerPlaceholder": "例如:Lais", + "loanTitleLabel": "借款标题", + "loanTitlePlaceholder": "例如:个人借款", + "loanAmountLabel": "总金额 *", + "loanInstallmentsLabel": "期数 *", + "loanStartMonthLabel": "首期月份 *", + "loanNotesLabel": "备注", + "loanBorrowerRequired": "请填写借款人", + "loanInstallmentsRequired": "请输入期数", + "loanStartMonthRequired": "请输入首期月份", + "loanAddedToast": "借款已添加", + "loanSavedToast": "借款已保存", + "loanDeletedToast": "借款已删除", + "loanPaymentAddedToast": "还款已记录", + "typeLoan": "借款" }, "settings": { "title": "设置", diff --git a/public/pages/budget.js b/public/pages/budget.js index 10c18a5..dc35df9 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -346,7 +346,6 @@ function renderBody() { _container.querySelector('#empty-cta-budget')?.addEventListener('click', () => { document.querySelector('.page-fab')?.click(); }); - _container.querySelector('#budget-add-loan')?.addEventListener('click', () => openLoanModal()); _container.querySelectorAll('[data-action="loan-pay"]').forEach((btn) => { btn.addEventListener('click', async () => { await markLoanPayment(parseInt(btn.dataset.id, 10)); @@ -445,6 +444,8 @@ function renderEntries() { function renderLoansDashboard() { const loans = state.loans?.loans ?? []; + if (!loans.length) return ''; + const summary = state.loans?.summary ?? {}; const activeLoans = loans.filter((loan) => loan.status === 'active'); @@ -458,10 +459,6 @@ function renderLoansDashboard() { amount: formatAmount(summary.remaining_amount ?? 0), })}
-
@@ -560,6 +557,7 @@ function formatEntryDate(dateStr) { function openBudgetModal({ mode, entry = null }) { const isEdit = mode === 'edit'; const today = new Date().toISOString().slice(0, 10); + const todayMonth = today.slice(0, 7); const isExpense = isEdit ? entry.amount < 0 : true; const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : ''; @@ -575,27 +573,29 @@ function openBudgetModal({ mode, entry = null }) { ).join(''); const content = ` -
+
+ ${!isEdit ? `` : ''}
-
+
-
+
-
+
@@ -603,7 +603,7 @@ function openBudgetModal({ mode, entry = null }) {
-
+
@@ -611,13 +611,13 @@ function openBudgetModal({ mode, entry = null }) {
-
+
-
+
+ +
@@ -513,17 +522,55 @@ function renderLoansDashboard() { ${formatAmount(summary.paid_amount ?? 0)}
- ${activeLoans.length ? ` + ${visibleLoans.length ? `
- ${activeLoans.map(renderLoanCard).join('')} + ${visibleLoans.map(renderLoanCard).join('')}
` : `
${t('budget.loansEmpty')}
`} + ${renderLoanTransactions(visibleLoans)} `; } +function filteredLoans() { + const loans = state.loans?.loans ?? []; + if (state.loanStatusFilter === 'all') return loans; + return loans.filter((loan) => loan.status === state.loanStatusFilter); +} + +function loanPaymentsFor(loans) { + return loans.flatMap((loan) => (loan.payments ?? []).map((payment) => ({ ...payment, loan }))) + .sort((a, b) => new Date(b.paid_date) - new Date(a.paid_date) || b.installment_number - a.installment_number); +} + +function renderLoanTransactions(loans) { + const payments = loanPaymentsFor(loans); + if (!payments.length) return ''; + + return `
+
${t('budget.loanTransactions')}
+
+ ${payments.map(({ loan, ...payment }) => ` +
+
+ ${esc(loan.title)} + ${esc(loan.borrower)} · ${t('budget.loanInstallmentNumber', { + number: payment.installment_number, + total: loan.installment_count, + })} +
+
+ ${formatAmount(payment.amount)} + ${formatEntryDate(payment.paid_date)} +
+
+ `).join('')} +
+
`; +} + function renderLoansPage() { const loans = state.loans?.loans ?? []; if (!loans.length) { @@ -547,6 +594,19 @@ function renderLoansPage() { function wireLoansPage() { _container.querySelector('#budget-empty-loan')?.addEventListener('click', () => openBudgetModal({ mode: 'create', initialType: 'loan' })); + _container.querySelectorAll('[data-loan-status]').forEach((btn) => { + btn.addEventListener('click', () => { + state.loanStatusFilter = btn.dataset.loanStatus; + renderBody(); + }); + }); + _container.querySelectorAll('.budget-loan-card[data-loan-id]').forEach((card) => { + card.addEventListener('click', (event) => { + if (event.target.closest('button, a')) return; + const loan = state.loans.loans.find((item) => item.id === parseInt(card.dataset.loanId, 10)); + if (loan) openLoanReport(loan); + }); + }); _container.querySelectorAll('[data-action="loan-pay"]').forEach((btn) => { btn.addEventListener('click', async () => { await markLoanPayment(parseInt(btn.dataset.id, 10)); @@ -573,13 +633,65 @@ function wireLoansPage() { }); } +function openLoanReport(loan) { + const payments = (loan.payments ?? []).slice() + .sort((a, b) => new Date(b.paid_date) - new Date(a.paid_date) || b.installment_number - a.installment_number); + const content = ` +
+
+
+
${esc(loan.borrower)}
+
${esc(loan.title)}
+
+ + ${loan.status === 'paid' ? t('budget.loanStatusPaid') : t('budget.loanStatusActive')} + +
+
+
${t('budget.loanAmountLabel')}${formatAmount(loan.total_amount)}
+
${t('budget.loanRemainingAmount')}${formatAmount(loan.remaining_amount)}
+
${t('budget.loanPaidAmount')}${formatAmount(loan.paid_amount)}
+
${t('budget.loanRemainingInstallments')}${loan.remaining_installments}
+
+
${t('budget.loanTransactions')}
+ ${payments.length ? ` +
+ ${payments.map((payment) => ` +
+
+ ${t('budget.loanInstallmentNumber', { number: payment.installment_number, total: loan.installment_count })} + ${formatEntryDate(payment.paid_date)} +
+
+ ${formatAmount(payment.amount)} +
+
+ `).join('')} +
+ ` : `
${t('budget.loanNoTransactions')}
`} +
+ `; + + openSharedModal({ + title: t('budget.loanReportTitle'), + content, + size: 'md', + onSave(panel) { + panel.querySelector('#loan-report-close')?.addEventListener('click', closeModal); + }, + }); +} + function renderLoanCard(loan) { const paidPct = Math.min(100, Math.round((loan.paid_amount / loan.total_amount) * 100)); const nextDue = loan.next_due_month ? formatMonthLabel(loan.next_due_month) : t('budget.loanPaidStatus'); const payDisabled = loan.remaining_installments <= 0 ? 'disabled' : ''; return ` -
+
${esc(loan.title)}
diff --git a/public/styles/budget.css b/public/styles/budget.css index bcf9582..f5ee47c 100644 --- a/public/styles/budget.css +++ b/public/styles/budget.css @@ -86,7 +86,7 @@ .budget-tab { min-height: 34px; padding: 0 var(--space-3); - border: 0; + border: 1px solid transparent; border-radius: var(--radius-xs); background: transparent; color: var(--color-text-secondary); @@ -95,11 +95,34 @@ font-weight: var(--font-weight-medium); } +.budget-tab[data-tab="budget"] { + color: var(--module-budget); +} + +.budget-tab[data-tab="loans"] { + color: var(--color-info); +} + +.budget-tab[data-tab="budget"]:not(.budget-tab--active) { + background: color-mix(in srgb, var(--module-budget) 10%, transparent); +} + +.budget-tab[data-tab="loans"]:not(.budget-tab--active) { + background: color-mix(in srgb, var(--color-info) 10%, transparent); +} + .budget-tab--active { - background: var(--module-accent); color: var(--color-text-on-accent); } +.budget-tab[data-tab="budget"].budget-tab--active { + background: var(--module-budget); +} + +.budget-tab[data-tab="loans"].budget-tab--active { + background: var(--color-info); +} + .budget-tab-panel { flex: 1; min-height: 0; @@ -269,8 +292,30 @@ margin-top: 2px; } -.budget-loans__add { - flex-shrink: 0; +.budget-loans__filters { + display: flex; + gap: var(--space-1); + padding: 2px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-2); +} + +.budget-loans__filter { + min-height: 30px; + padding: 0 var(--space-2); + border: 0; + border-radius: var(--radius-xs); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); +} + +.budget-loans__filter--active { + background: var(--color-info); + color: var(--color-text-on-accent); } .budget-loans__stats { @@ -328,6 +373,13 @@ padding: var(--space-3); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast); +} + +.budget-loan-card:hover { + border-color: var(--color-info); + background: var(--color-surface-2); } .budget-loan-card__main { @@ -402,6 +454,107 @@ gap: var(--space-2); } +.budget-loan-transactions { + margin-top: var(--space-4); + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +.budget-loan-transactions__title, +.loan-report__section-title { + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-2); +} + +.budget-loan-transactions__list, +.loan-report__transactions { + display: grid; + gap: var(--space-2); +} + +.budget-loan-transaction { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + background: var(--color-surface-2); +} + +.budget-loan-transaction span { + display: block; + margin-top: 2px; + color: var(--color-text-secondary); + font-size: var(--text-xs); +} + +.budget-loan-transaction > div:last-child { + text-align: right; +} + +.loan-report__hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-4); + border-radius: var(--radius-md); + background: var(--color-surface-2); +} + +.loan-report__borrower { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.loan-report__title { + font-size: var(--text-xl); + font-weight: var(--font-weight-bold); + margin-top: 2px; +} + +.loan-report__status { + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); +} + +.loan-report__status--active { + background: var(--color-info); + color: var(--color-text-on-accent); +} + +.loan-report__status--paid { + background: var(--color-success); + color: var(--color-text-on-accent); +} + +.loan-report__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + margin: var(--space-3) 0; +} + +.loan-report__grid > div { + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); +} + +.loan-report__grid span { + display: block; + color: var(--color-text-secondary); + font-size: var(--text-xs); + margin-bottom: 2px; +} + .budget-page .form-grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -641,10 +794,15 @@ } .amount-type-btn--loan.amount-type-btn--active { - background-color: var(--module-accent); + background-color: var(--module-budget); color: var(--color-text-on-accent); } +.modal-panel .js-entry-field[hidden], +.modal-panel #bm-loan-fields[hidden] { + display: none !important; +} + .budget-field-header { display: flex; align-items: center; diff --git a/server/routes/budget.js b/server/routes/budget.js index f914e09..ab403b2 100644 --- a/server/routes/budget.js +++ b/server/routes/budget.js @@ -889,6 +889,17 @@ router.put('/:id', (req, res) => { if (linkedPayment && amount !== undefined && Number(amount) <= 0) { return res.status(400).json({ error: 'Loan repayment entries must remain income.', code: 400 }); } + if (linkedPayment && amount !== undefined) { + const loan = db.get().prepare('SELECT total_amount FROM budget_loans WHERE id = ?').get(linkedPayment.loan_id); + const otherPaid = db.get().prepare(` + SELECT COALESCE(SUM(amount), 0) AS total + FROM budget_loan_payments + WHERE loan_id = ? AND id != ? + `).get(linkedPayment.loan_id, linkedPayment.id).total; + if (Number(amount) - (Number(loan?.total_amount || 0) - Number(otherPaid || 0)) > 0.005) { + return res.status(400).json({ error: 'Amount cannot be greater than the remaining loan amount.', code: 400 }); + } + } const nextCategory = category ?? entry.category; const subcategory = requestedSubcategory !== undefined || category !== undefined ? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory) From e34ba33f9b7484d52ff2cd1c2d84e73a3c648e0d Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Fri, 1 May 2026 08:24:39 -0300 Subject: [PATCH 5/9] Refine loan tab filtering and date formats --- public/i18n.js | 25 +++++- public/locales/ar.json | 2 + public/locales/de.json | 2 + public/locales/el.json | 2 + public/locales/en.json | 2 + public/locales/es.json | 2 + public/locales/fr.json | 2 + public/locales/hi.json | 2 + public/locales/it.json | 2 + public/locales/ja.json | 2 + public/locales/pt.json | 2 + public/locales/ru.json | 2 + public/locales/sv.json | 2 + public/locales/tr.json | 2 + public/locales/uk.json | 2 + public/locales/zh.json | 2 + public/pages/budget.js | 144 +++++++++++++++++++++++------------ public/pages/settings.js | 4 + public/styles/budget.css | 46 ++++++----- server/routes/budget.js | 8 +- server/routes/preferences.js | 2 +- 21 files changed, 187 insertions(+), 72 deletions(-) diff --git a/public/i18n.js b/public/i18n.js index b45a0fe..fa1c1d6 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -87,9 +87,11 @@ function isDateOnlyString(value) { return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value); } +const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd', 'mdy_dot', 'dmy_dot', 'ymd_dot', 'ymd_slash']; + function getDateFormatPreference() { const stored = localStorage.getItem(DATE_FORMAT_KEY); - return ['mdy', 'dmy', 'ymd'].includes(stored) ? stored : DEFAULT_DATE_FORMAT; + return VALID_DATE_FORMATS.includes(stored) ? stored : DEFAULT_DATE_FORMAT; } export function getDateFormat() { @@ -112,8 +114,12 @@ function formatDateParts(date, useUtc = false) { const month = String((useUtc ? d.getUTCMonth() : d.getMonth()) + 1).padStart(2, '0'); const day = String(useUtc ? d.getUTCDate() : d.getDate()).padStart(2, '0'); switch (getDateFormatPreference()) { - case 'dmy': return `${day}.${month}.${year}`; + case 'dmy': return `${day}/${month}/${year}`; + case 'mdy_dot': return `${month}.${day}.${year}`; + case 'dmy_dot': return `${day}.${month}.${year}`; case 'ymd': return `${year}-${month}-${day}`; + case 'ymd_dot': return `${year}.${month}.${day}`; + case 'ymd_slash': return `${year}/${month}/${day}`; default: return `${month}/${day}/${year}`; } } @@ -139,8 +145,12 @@ export function formatDate(date) { export function dateInputPlaceholder() { switch (getDateFormatPreference()) { - case 'dmy': return 'DD.MM.YYYY'; + case 'dmy': return 'DD/MM/YYYY'; + case 'mdy_dot': return 'MM.DD.YYYY'; + case 'dmy_dot': return 'DD.MM.YYYY'; case 'ymd': return 'YYYY-MM-DD'; + case 'ymd_dot': return 'YYYY.MM.DD'; + case 'ymd_slash': return 'YYYY/MM/DD'; default: return 'MM/DD/YYYY'; } } @@ -157,11 +167,18 @@ export function parseDateInput(value) { const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (isoMatch) return isValidDateParts(isoMatch[1], isoMatch[2], isoMatch[3]) ? raw : ''; + const ymdSeparatorMatch = raw.match(/^(\d{4})[\/.](\d{1,2})[\/.](\d{1,2})$/); + if (ymdSeparatorMatch && getDateFormatPreference().startsWith('ymd')) { + const [, year, month, day] = ymdSeparatorMatch; + if (!isValidDateParts(year, month, day)) return ''; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + const slashMatch = raw.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})$/); if (!slashMatch) return ''; const [, first, second, year] = slashMatch; - const [month, day] = getDateFormatPreference() === 'dmy' + const [month, day] = getDateFormatPreference().startsWith('dmy') ? [second, first] : [first, second]; diff --git a/public/locales/ar.json b/public/locales/ar.json index c319ce2..755fa39 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -573,6 +573,7 @@ "editLoan": "تعديل القرض", "deleteLoan": "حذف القرض", "deleteLoanConfirm": "هل تريد حذف القرض \"{{title}}\"؟ ستتم إزالة الدفعات المسجلة في الميزانية أيضًا.", + "deleteLoanPaymentConfirm": "هل تريد حذف دفعة القرض هذه؟", "loanRemainingAmount": "المتبقي", "loanRemainingInstallments": "الأقساط المتبقية", "loanPaidAmount": "المدفوع", @@ -597,6 +598,7 @@ "loanSavedToast": "تم حفظ القرض", "loanDeletedToast": "تم حذف القرض", "loanPaymentAddedToast": "تم تسجيل الدفع", + "loanPaymentTitle": "سداد القرض: {{borrower}}", "typeLoan": "قرض", "tabsLabel": "أقسام الميزانية", "budgetTab": "الميزانية", diff --git a/public/locales/de.json b/public/locales/de.json index 11793da..790936b 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -598,6 +598,7 @@ "editLoan": "Darlehen bearbeiten", "deleteLoan": "Darlehen löschen", "deleteLoanConfirm": "Darlehen \"{{title}}\" löschen? Bereits im Budget verbuchte Zahlungen werden ebenfalls entfernt.", + "deleteLoanPaymentConfirm": "Diese Darlehenszahlung löschen?", "loanRemainingAmount": "Offen", "loanRemainingInstallments": "Raten offen", "loanPaidAmount": "Bezahlt", @@ -622,6 +623,7 @@ "loanSavedToast": "Darlehen gespeichert", "loanDeletedToast": "Darlehen gelöscht", "loanPaymentAddedToast": "Zahlung erfasst", + "loanPaymentTitle": "Darlehensrückzahlung: {{borrower}}", "typeLoan": "Darlehen", "tabsLabel": "Budgetbereiche", "budgetTab": "Budget", diff --git a/public/locales/el.json b/public/locales/el.json index f570d37..b2f2ff5 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -573,6 +573,7 @@ "editLoan": "Επεξεργασία δανείου", "deleteLoan": "Διαγραφή δανείου", "deleteLoanConfirm": "Να διαγραφεί το δάνειο «{{title}}»; Οι πληρωμές που έχουν ήδη περαστεί στον προϋπολογισμό θα αφαιρεθούν επίσης.", + "deleteLoanPaymentConfirm": "Διαγραφή αυτής της πληρωμής δανείου;", "loanRemainingAmount": "Υπόλοιπο", "loanRemainingInstallments": "Δόσεις που απομένουν", "loanPaidAmount": "Πληρωμένο", @@ -597,6 +598,7 @@ "loanSavedToast": "Το δάνειο αποθηκεύτηκε", "loanDeletedToast": "Το δάνειο διαγράφηκε", "loanPaymentAddedToast": "Η πληρωμή καταγράφηκε", + "loanPaymentTitle": "Πληρωμή δανείου: {{borrower}}", "typeLoan": "Δάνειο", "tabsLabel": "Ενότητες προϋπολογισμού", "budgetTab": "Προϋπολογισμός", diff --git a/public/locales/en.json b/public/locales/en.json index d11437c..d11161d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -573,6 +573,7 @@ "editLoan": "Edit loan", "deleteLoan": "Delete loan", "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "deleteLoanPaymentConfirm": "Delete this loan payment?", "loanRemainingAmount": "Remaining", "loanRemainingInstallments": "Installments left", "loanPaidAmount": "Paid", @@ -597,6 +598,7 @@ "loanSavedToast": "Loan saved", "loanDeletedToast": "Loan deleted", "loanPaymentAddedToast": "Payment recorded", + "loanPaymentTitle": "Loan repayment: {{borrower}}", "typeLoan": "Loan", "tabsLabel": "Budget sections", "budgetTab": "Budget", diff --git a/public/locales/es.json b/public/locales/es.json index 26e044e..a797df3 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -573,6 +573,7 @@ "editLoan": "Editar préstamo", "deleteLoan": "Eliminar préstamo", "deleteLoanConfirm": "¿Eliminar el préstamo \"{{title}}\"? También se eliminarán los pagos ya registrados en el presupuesto.", + "deleteLoanPaymentConfirm": "¿Eliminar este pago del préstamo?", "loanRemainingAmount": "Restante", "loanRemainingInstallments": "Cuotas restantes", "loanPaidAmount": "Pagado", @@ -597,6 +598,7 @@ "loanSavedToast": "Préstamo guardado", "loanDeletedToast": "Préstamo eliminado", "loanPaymentAddedToast": "Pago registrado", + "loanPaymentTitle": "Pago del préstamo: {{borrower}}", "typeLoan": "Préstamo", "tabsLabel": "Secciones del presupuesto", "budgetTab": "Presupuesto", diff --git a/public/locales/fr.json b/public/locales/fr.json index e1e3438..c29e954 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -573,6 +573,7 @@ "editLoan": "Modifier le prêt", "deleteLoan": "Supprimer le prêt", "deleteLoanConfirm": "Supprimer le prêt \"{{title}}\" ? Les paiements déjà enregistrés dans le budget seront aussi supprimés.", + "deleteLoanPaymentConfirm": "Supprimer ce paiement de prêt ?", "loanRemainingAmount": "Restant", "loanRemainingInstallments": "Échéances restantes", "loanPaidAmount": "Payé", @@ -597,6 +598,7 @@ "loanSavedToast": "Prêt enregistré", "loanDeletedToast": "Prêt supprimé", "loanPaymentAddedToast": "Paiement enregistré", + "loanPaymentTitle": "Remboursement du prêt : {{borrower}}", "typeLoan": "Prêt", "tabsLabel": "Sections du budget", "budgetTab": "Budget", diff --git a/public/locales/hi.json b/public/locales/hi.json index 91df594..81b6856 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -573,6 +573,7 @@ "editLoan": "उधार संपादित करें", "deleteLoan": "उधार हटाएं", "deleteLoanConfirm": "उधार \"{{title}}\" हटाएं? बजट में दर्ज भुगतान भी हटा दिए जाएंगे।", + "deleteLoanPaymentConfirm": "यह ऋण भुगतान हटाएँ?", "loanRemainingAmount": "बाकी", "loanRemainingInstallments": "बाकी किस्तें", "loanPaidAmount": "भुगतान किया", @@ -597,6 +598,7 @@ "loanSavedToast": "उधार सहेजा गया", "loanDeletedToast": "उधार हटाया गया", "loanPaymentAddedToast": "भुगतान दर्ज किया गया", + "loanPaymentTitle": "ऋण भुगतान: {{borrower}}", "typeLoan": "उधार", "tabsLabel": "बजट अनुभाग", "budgetTab": "बजट", diff --git a/public/locales/it.json b/public/locales/it.json index cc01c3a..c21649a 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -573,6 +573,7 @@ "editLoan": "Modifica prestito", "deleteLoan": "Elimina prestito", "deleteLoanConfirm": "Eliminare il prestito \"{{title}}\"? Verranno rimossi anche i pagamenti già registrati nel bilancio.", + "deleteLoanPaymentConfirm": "Eliminare questo pagamento del prestito?", "loanRemainingAmount": "Rimanente", "loanRemainingInstallments": "Rate rimanenti", "loanPaidAmount": "Pagato", @@ -597,6 +598,7 @@ "loanSavedToast": "Prestito salvato", "loanDeletedToast": "Prestito eliminato", "loanPaymentAddedToast": "Pagamento registrato", + "loanPaymentTitle": "Rimborso del prestito: {{borrower}}", "typeLoan": "Prestito", "tabsLabel": "Sezioni del bilancio", "budgetTab": "Bilancio", diff --git a/public/locales/ja.json b/public/locales/ja.json index bcc9bc6..af4b07f 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -573,6 +573,7 @@ "editLoan": "貸付を編集", "deleteLoan": "貸付を削除", "deleteLoanConfirm": "貸付「{{title}}」を削除しますか?予算に記録済みの返済も削除されます。", + "deleteLoanPaymentConfirm": "このローン支払いを削除しますか?", "loanRemainingAmount": "残額", "loanRemainingInstallments": "残り回数", "loanPaidAmount": "返済済み", @@ -597,6 +598,7 @@ "loanSavedToast": "貸付を保存しました", "loanDeletedToast": "貸付を削除しました", "loanPaymentAddedToast": "返済を記録しました", + "loanPaymentTitle": "ローン返済: {{borrower}}", "typeLoan": "貸付", "tabsLabel": "予算セクション", "budgetTab": "予算", diff --git a/public/locales/pt.json b/public/locales/pt.json index 783ca78..454feee 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -573,6 +573,7 @@ "editLoan": "Editar empréstimo", "deleteLoan": "Excluir empréstimo", "deleteLoanConfirm": "Excluir empréstimo \"{{title}}\"? Pagamentos já lançados no orçamento também serão removidos.", + "deleteLoanPaymentConfirm": "Excluir este pagamento do empréstimo?", "loanRemainingAmount": "Restante", "loanRemainingInstallments": "Parcelas restantes", "loanPaidAmount": "Pago", @@ -597,6 +598,7 @@ "loanSavedToast": "Empréstimo salvo", "loanDeletedToast": "Empréstimo excluído", "loanPaymentAddedToast": "Pagamento registrado", + "loanPaymentTitle": "Pagamento do empréstimo: {{borrower}}", "typeLoan": "Empréstimo", "tabsLabel": "Seções do orçamento", "budgetTab": "Orçamento", diff --git a/public/locales/ru.json b/public/locales/ru.json index 7dac1a0..1cab256 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -573,6 +573,7 @@ "editLoan": "Изменить займ", "deleteLoan": "Удалить займ", "deleteLoanConfirm": "Удалить займ «{{title}}»? Платежи, уже добавленные в бюджет, тоже будут удалены.", + "deleteLoanPaymentConfirm": "Удалить этот платеж по займу?", "loanRemainingAmount": "Осталось", "loanRemainingInstallments": "Осталось платежей", "loanPaidAmount": "Оплачено", @@ -597,6 +598,7 @@ "loanSavedToast": "Займ сохранён", "loanDeletedToast": "Займ удалён", "loanPaymentAddedToast": "Платёж записан", + "loanPaymentTitle": "Платеж по займу: {{borrower}}", "typeLoan": "Займ", "tabsLabel": "Разделы бюджета", "budgetTab": "Бюджет", diff --git a/public/locales/sv.json b/public/locales/sv.json index d3c9234..f611d3c 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -573,6 +573,7 @@ "editLoan": "Redigera lån", "deleteLoan": "Ta bort lån", "deleteLoanConfirm": "Ta bort lånet \"{{title}}\"? Betalningar som redan bokförts i budgeten tas också bort.", + "deleteLoanPaymentConfirm": "Ta bort den här lånebetalningen?", "loanRemainingAmount": "Kvar", "loanRemainingInstallments": "Delbetalningar kvar", "loanPaidAmount": "Betalt", @@ -597,6 +598,7 @@ "loanSavedToast": "Lån sparat", "loanDeletedToast": "Lån borttaget", "loanPaymentAddedToast": "Betalning registrerad", + "loanPaymentTitle": "Låneåterbetalning: {{borrower}}", "typeLoan": "Lån", "tabsLabel": "Budgetsektioner", "budgetTab": "Budget", diff --git a/public/locales/tr.json b/public/locales/tr.json index 6eba08c..2e97e6a 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -573,6 +573,7 @@ "editLoan": "Borcu düzenle", "deleteLoan": "Borcu sil", "deleteLoanConfirm": "\"{{title}}\" borcu silinsin mi? Bütçeye işlenmiş ödemeler de kaldırılır.", + "deleteLoanPaymentConfirm": "Bu kredi ödemesi silinsin mi?", "loanRemainingAmount": "Kalan", "loanRemainingInstallments": "Kalan taksit", "loanPaidAmount": "Ödenen", @@ -597,6 +598,7 @@ "loanSavedToast": "Borç kaydedildi", "loanDeletedToast": "Borç silindi", "loanPaymentAddedToast": "Ödeme kaydedildi", + "loanPaymentTitle": "Kredi geri ödemesi: {{borrower}}", "typeLoan": "Borç", "tabsLabel": "Bütçe bölümleri", "budgetTab": "Bütçe", diff --git a/public/locales/uk.json b/public/locales/uk.json index f41aeae..e295193 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -573,6 +573,7 @@ "editLoan": "Редагувати позику", "deleteLoan": "Видалити позику", "deleteLoanConfirm": "Видалити позику «{{title}}»? Платежі, вже додані до бюджету, також буде видалено.", + "deleteLoanPaymentConfirm": "Видалити цей платіж за позикою?", "loanRemainingAmount": "Залишилось", "loanRemainingInstallments": "Залишилось платежів", "loanPaidAmount": "Сплачено", @@ -597,6 +598,7 @@ "loanSavedToast": "Позику збережено", "loanDeletedToast": "Позику видалено", "loanPaymentAddedToast": "Платіж записано", + "loanPaymentTitle": "Платіж за позикою: {{borrower}}", "typeLoan": "Позика", "tabsLabel": "Розділи бюджету", "budgetTab": "Бюджет", diff --git a/public/locales/zh.json b/public/locales/zh.json index 285a9d4..6388d87 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -573,6 +573,7 @@ "editLoan": "编辑借款", "deleteLoan": "删除借款", "deleteLoanConfirm": "删除借款“{{title}}”?已记入预算的还款也会被删除。", + "deleteLoanPaymentConfirm": "删除这笔借款还款?", "loanRemainingAmount": "剩余金额", "loanRemainingInstallments": "剩余期数", "loanPaidAmount": "已还金额", @@ -597,6 +598,7 @@ "loanSavedToast": "借款已保存", "loanDeletedToast": "借款已删除", "loanPaymentAddedToast": "还款已记录", + "loanPaymentTitle": "借款还款:{{borrower}}", "typeLoan": "借款", "tabsLabel": "预算分区", "budgetTab": "预算", diff --git a/public/pages/budget.js b/public/pages/budget.js index 5259966..24a56bf 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -6,7 +6,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate, getLocale, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -164,11 +164,8 @@ function setHtml(element, html) { async function loadMonth(month) { const prevMonth = addMonths(month, -1); try { - const entriesPath = state.loanFilterId - ? `/budget?loan_id=${encodeURIComponent(state.loanFilterId)}` - : `/budget?month=${month}`; const [entriesRes, summaryRes, prevSummaryRes, loansRes] = await Promise.all([ - api.get(entriesPath), + api.get(`/budget?month=${month}`), api.get(`/budget/summary?month=${month}`), api.get(`/budget/summary?month=${prevMonth}`), api.get('/budget/loans'), @@ -357,16 +354,10 @@ function renderBody() {
+ ${state.loanFilterId ? ` + ` : ''} ` : ''} + +
+
+ `; +} + function renderLoansPage() { const loans = state.loans?.loans ?? []; if (!loans.length) { @@ -594,6 +616,10 @@ function renderLoansPage() { function wireLoansPage() { _container.querySelector('#budget-empty-loan')?.addEventListener('click', () => openBudgetModal({ mode: 'create', initialType: 'loan' })); + _container.querySelector('#budget-clear-loan-filter')?.addEventListener('click', () => { + state.loanFilterId = null; + renderBody(); + }); _container.querySelectorAll('[data-loan-status]').forEach((btn) => { btn.addEventListener('click', () => { state.loanStatusFilter = btn.dataset.loanStatus; @@ -624,13 +650,25 @@ function wireLoansPage() { }); }); _container.querySelectorAll('[data-action="loan-filter"]').forEach((btn) => { - btn.addEventListener('click', async () => { - state.loanFilterId = parseInt(btn.dataset.id, 10); - state.activeTab = 'budget'; - await loadMonth(state.month); + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id, 10); + state.loanFilterId = state.loanFilterId === id ? null : id; renderBody(); }); }); + _container.querySelectorAll('[data-action="loan-payment-edit"]').forEach((btn) => { + btn.addEventListener('click', () => { + const loan = state.loans.loans.find((item) => item.id === parseInt(btn.dataset.loanId, 10)); + const payment = loan?.payments?.find((item) => item.id === parseInt(btn.dataset.paymentId, 10)); + const entry = loan && payment ? loanPaymentToEntry(loan, payment) : null; + if (entry) openBudgetModal({ mode: 'edit', entry }); + }); + }); + _container.querySelectorAll('[data-action="loan-payment-delete"]').forEach((btn) => { + btn.addEventListener('click', async () => { + await deleteLoanPayment(parseInt(btn.dataset.loanId, 10), parseInt(btn.dataset.paymentId, 10)); + }); + }); } function openLoanReport(loan) { @@ -695,7 +733,7 @@ function renderLoanCard(loan) {
${esc(loan.title)}
-
@@ -752,7 +790,7 @@ function renderTrend(current, prev, prevLabel) { } function formatEntryDate(dateStr) { - return formatDate(new Date(dateStr + 'T00:00:00')); + return formatDate(dateStr); } // -------------------------------------------------------- @@ -1198,7 +1236,7 @@ async function markLoanPayment(id) { async function deleteLoan(id) { const loan = state.loans.loans.find((item) => item.id === id); if (!loan) return; - if (!window.confirm(t('budget.deleteLoanConfirm', { title: loan.title }))) return; + if (!await confirmModal(t('budget.deleteLoanConfirm', { title: loan.title }), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/budget/loans/${id}`); await loadMonth(state.month); @@ -1209,6 +1247,18 @@ async function deleteLoan(id) { } } +async function deleteLoanPayment(loanId, paymentId) { + if (!await confirmModal(t('budget.deleteLoanPaymentConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; + try { + await api.delete(`/budget/loans/${loanId}/payments/${paymentId}`); + await loadMonth(state.month); + renderBody(); + window.oikos?.showToast(t('budget.deletedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); + } +} + // -------------------------------------------------------- // Eintrag löschen // -------------------------------------------------------- diff --git a/public/pages/settings.js b/public/pages/settings.js index 6f195cd..faaae39 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -340,6 +340,10 @@ export async function render(container, { user }) { + + + + + ${sizeOptions} + + + +
+ `; +} + +function renderDashboardLayout(cfg, data, weather, currency, { editing = false } = {}) { const widgetById = { tasks: () => renderUrgentTasks(data.urgentTasks ?? []), calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []), @@ -580,11 +653,15 @@ function renderDashboardLayout(cfg, data, weather, currency) { .map((w) => { const html = widgetById[w.id](); if (!html) return ''; - return `
${html}
`; + return `
+ ${editing ? renderWidgetCustomizeControls(w) : ''} + ${html} +
`; }) .join(''); - return `
${tiles}
`; + return `
${tiles}
`; } function renderDashboardSkeleton() { @@ -800,6 +877,9 @@ function openCustomizeModal(currentConfig, onSave) { return draft.map((w, i) => { const isFirst = i === 0; const isLast = i === draft.length - 1; + const sizeOptions = WIDGET_SIZE_OPTIONS.map((size) => ` + + `).join(''); return `
${widgetLabel(w.id)} +