Merge pull request #117 from rafaelfoster/improvement-dashboard-customization
Dashboard Customization & Widget Layout Enhancements
This commit is contained in:
+21
-2
@@ -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', 'dmy_slash', '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() {
|
||||
@@ -113,7 +115,12 @@ function formatDateParts(date, useUtc = false) {
|
||||
const day = String(useUtc ? d.getUTCDate() : d.getDate()).padStart(2, '0');
|
||||
switch (getDateFormatPreference()) {
|
||||
case 'dmy': return `${day}.${month}.${year}`;
|
||||
case 'mdy_dot': return `${month}.${day}.${year}`;
|
||||
case 'dmy_dot': return `${day}.${month}.${year}`;
|
||||
case 'dmy_slash': 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}`;
|
||||
}
|
||||
}
|
||||
@@ -140,7 +147,12 @@ export function formatDate(date) {
|
||||
export function dateInputPlaceholder() {
|
||||
switch (getDateFormatPreference()) {
|
||||
case 'dmy': return 'DD.MM.YYYY';
|
||||
case 'mdy_dot': return 'MM.DD.YYYY';
|
||||
case 'dmy_dot': return 'DD.MM.YYYY';
|
||||
case 'dmy_slash': 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 +169,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];
|
||||
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "للأعلى",
|
||||
"customizeMoveDown": "للأسفل",
|
||||
"overdueTasksChip": "{{count}} مهمة متأخرة",
|
||||
"overdueTasksChipPlural": "{{count}} مهام متأخرة"
|
||||
"overdueTasksChipPlural": "{{count}} مهام متأخرة",
|
||||
"customizeManage": "الأدوات",
|
||||
"customizeExit": "إنهاء التخصيص",
|
||||
"customizeDrag": "اسحب الأداة",
|
||||
"customizeSize": "الحجم",
|
||||
"customizeSizeFor": "حجم {{widget}}",
|
||||
"customizeHide": "إخفاء {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "المهام",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "القروض / الفوائد",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "إضافة إدخال"
|
||||
"emptyAction": "إضافة إدخال",
|
||||
"loansTitle": "القروض",
|
||||
"loansSummary": "{{count}} نشط · المتبقي {{amount}}",
|
||||
"newLoan": "قرض جديد",
|
||||
"createLoan": "إنشاء قرض",
|
||||
"editLoan": "تعديل القرض",
|
||||
"deleteLoan": "حذف القرض",
|
||||
"deleteLoanConfirm": "هل تريد حذف القرض \"{{title}}\"؟ ستتم إزالة الدفعات المسجلة في الميزانية أيضًا.",
|
||||
"deleteLoanPaymentConfirm": "هل تريد حذف دفعة القرض هذه؟",
|
||||
"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": "تم تسجيل الدفع",
|
||||
"loanPaymentTitle": "سداد القرض: {{borrower}}",
|
||||
"typeLoan": "قرض",
|
||||
"tabsLabel": "أقسام الميزانية",
|
||||
"budgetTab": "الميزانية",
|
||||
"loansTab": "القروض",
|
||||
"filteredTransactions": "المعاملات المصفاة",
|
||||
"clearLoanFilter": "مسح الفلتر",
|
||||
"loanFilterActive": "القرض: {{title}}",
|
||||
"filterLoanTransactions": "عرض معاملات هذا القرض",
|
||||
"loansEmptyDescription": "أنشئ قرضًا من زر + واختر قرض.",
|
||||
"newCategoryTitle": "فئة جديدة",
|
||||
"newCategoryPlaceholder": "اسم الفئة",
|
||||
"newSubcategoryTitle": "فئة فرعية جديدة",
|
||||
"newSubcategoryPlaceholder": "اسم الفئة الفرعية",
|
||||
"loanStatusFilterLabel": "فلتر حالة القرض",
|
||||
"loanStatusActive": "نشطة",
|
||||
"loanStatusPaid": "مدفوعة",
|
||||
"loanStatusAll": "الكل",
|
||||
"loanTransactions": "معاملات القرض",
|
||||
"loanInstallmentNumber": "القسط {{number}} من {{total}}",
|
||||
"loanReportTitle": "تقرير القرض",
|
||||
"loanNoTransactions": "لم يتم تسجيل أي دفعات بعد."
|
||||
},
|
||||
"settings": {
|
||||
"title": "الإعدادات",
|
||||
|
||||
+62
-2
@@ -118,7 +118,13 @@
|
||||
"customizeMoveUp": "Nach oben",
|
||||
"customizeMoveDown": "Nach unten",
|
||||
"overdueTasksChip": "{{count}} überfällige Aufgabe",
|
||||
"overdueTasksChipPlural": "{{count}} überfällige Aufgaben"
|
||||
"overdueTasksChipPlural": "{{count}} überfällige Aufgaben",
|
||||
"customizeManage": "Widgets",
|
||||
"customizeExit": "Anpassung beenden",
|
||||
"customizeDrag": "Widget ziehen",
|
||||
"customizeSize": "Größe",
|
||||
"customizeSizeFor": "Größe für {{widget}}",
|
||||
"customizeHide": "{{widget}} ausblenden"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Aufgaben",
|
||||
@@ -590,7 +596,61 @@
|
||||
"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.",
|
||||
"deleteLoanPaymentConfirm": "Diese Darlehenszahlung löschen?",
|
||||
"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": "Als bezahlt markieren",
|
||||
"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",
|
||||
"loanPaymentTitle": "Darlehensrückzahlung: {{borrower}}",
|
||||
"typeLoan": "Darlehen",
|
||||
"tabsLabel": "Budgetbereiche",
|
||||
"budgetTab": "Budget",
|
||||
"loansTab": "Darlehen",
|
||||
"filteredTransactions": "Gefilterte Transaktionen",
|
||||
"clearLoanFilter": "Filter löschen",
|
||||
"loanFilterActive": "Darlehen: {{title}}",
|
||||
"filterLoanTransactions": "Transaktionen dieses Darlehens anzeigen",
|
||||
"loansEmptyDescription": "Erstelle ein Darlehen über die +-Schaltfläche und wähle Darlehen.",
|
||||
"newCategoryTitle": "Neue Kategorie",
|
||||
"newCategoryPlaceholder": "Kategoriename",
|
||||
"newSubcategoryTitle": "Neue Unterkategorie",
|
||||
"newSubcategoryPlaceholder": "Name der Unterkategorie",
|
||||
"loanStatusFilterLabel": "Darlehensstatus filtern",
|
||||
"loanStatusActive": "Aktiv",
|
||||
"loanStatusPaid": "Bezahlt",
|
||||
"loanStatusAll": "Alle",
|
||||
"loanTransactions": "Darlehenstransaktionen",
|
||||
"loanInstallmentNumber": "Rate {{number}} von {{total}}",
|
||||
"loanReportTitle": "Darlehensbericht",
|
||||
"loanNoTransactions": "Noch keine Zahlungen erfasst."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Πάνω",
|
||||
"customizeMoveDown": "Κάτω",
|
||||
"overdueTasksChip": "{{count}} εκπρόθεσμη εργασία",
|
||||
"overdueTasksChipPlural": "{{count}} εκπρόθεσμες εργασίες"
|
||||
"overdueTasksChipPlural": "{{count}} εκπρόθεσμες εργασίες",
|
||||
"customizeManage": "Widget",
|
||||
"customizeExit": "Έξοδος από προσαρμογή",
|
||||
"customizeDrag": "Σύρετε widget",
|
||||
"customizeSize": "Μέγεθος",
|
||||
"customizeSizeFor": "Μέγεθος για {{widget}}",
|
||||
"customizeHide": "Απόκρυψη {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Εργασίες",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "Δάνεια / Τόκοι",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "Προσθήκη εγγραφής"
|
||||
"emptyAction": "Προσθήκη εγγραφής",
|
||||
"loansTitle": "Δάνεια",
|
||||
"loansSummary": "{{count}} ενεργά · απομένουν {{amount}}",
|
||||
"newLoan": "Νέο δάνειο",
|
||||
"createLoan": "Δημιουργία δανείου",
|
||||
"editLoan": "Επεξεργασία δανείου",
|
||||
"deleteLoan": "Διαγραφή δανείου",
|
||||
"deleteLoanConfirm": "Να διαγραφεί το δάνειο «{{title}}»; Οι πληρωμές που έχουν ήδη περαστεί στον προϋπολογισμό θα αφαιρεθούν επίσης.",
|
||||
"deleteLoanPaymentConfirm": "Διαγραφή αυτής της πληρωμής δανείου;",
|
||||
"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": "Η πληρωμή καταγράφηκε",
|
||||
"loanPaymentTitle": "Πληρωμή δανείου: {{borrower}}",
|
||||
"typeLoan": "Δάνειο",
|
||||
"tabsLabel": "Ενότητες προϋπολογισμού",
|
||||
"budgetTab": "Προϋπολογισμός",
|
||||
"loansTab": "Δάνεια",
|
||||
"filteredTransactions": "Φιλτραρισμένες συναλλαγές",
|
||||
"clearLoanFilter": "Καθαρισμός φίλτρου",
|
||||
"loanFilterActive": "Δάνειο: {{title}}",
|
||||
"filterLoanTransactions": "Εμφάνιση συναλλαγών αυτού του δανείου",
|
||||
"loansEmptyDescription": "Δημιουργήστε δάνειο από το κουμπί + και επιλέξτε Δάνειο.",
|
||||
"newCategoryTitle": "Νέα κατηγορία",
|
||||
"newCategoryPlaceholder": "Όνομα κατηγορίας",
|
||||
"newSubcategoryTitle": "Νέα υποκατηγορία",
|
||||
"newSubcategoryPlaceholder": "Όνομα υποκατηγορίας",
|
||||
"loanStatusFilterLabel": "Φίλτρο κατάστασης δανείων",
|
||||
"loanStatusActive": "Ενεργά",
|
||||
"loanStatusPaid": "Πληρωμένα",
|
||||
"loanStatusAll": "Όλα",
|
||||
"loanTransactions": "Συναλλαγές δανείου",
|
||||
"loanInstallmentNumber": "Δόση {{number}} από {{total}}",
|
||||
"loanReportTitle": "Αναφορά δανείου",
|
||||
"loanNoTransactions": "Δεν έχουν καταγραφεί πληρωμές ακόμα."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ρυθμίσεις",
|
||||
|
||||
+62
-2
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Move up",
|
||||
"customizeMoveDown": "Move down",
|
||||
"overdueTasksChip": "{{count}} overdue task",
|
||||
"overdueTasksChipPlural": "{{count}} overdue tasks"
|
||||
"overdueTasksChipPlural": "{{count}} overdue tasks",
|
||||
"customizeManage": "Widgets",
|
||||
"customizeExit": "Exit customization",
|
||||
"customizeDrag": "Drag widget",
|
||||
"customizeSize": "Size",
|
||||
"customizeSizeFor": "Size for {{widget}}",
|
||||
"customizeHide": "Hide {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
@@ -584,7 +590,61 @@
|
||||
"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.",
|
||||
"deleteLoanPaymentConfirm": "Delete this loan payment?",
|
||||
"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",
|
||||
"loanPaymentTitle": "Loan repayment: {{borrower}}",
|
||||
"typeLoan": "Loan",
|
||||
"tabsLabel": "Budget sections",
|
||||
"budgetTab": "Budget",
|
||||
"loansTab": "Loans",
|
||||
"filteredTransactions": "Filtered transactions",
|
||||
"clearLoanFilter": "Clear filter",
|
||||
"loanFilterActive": "Loan: {{title}}",
|
||||
"filterLoanTransactions": "Show transactions for this loan",
|
||||
"loansEmptyDescription": "Create a loan from the + button and choose Loan.",
|
||||
"newCategoryTitle": "New category",
|
||||
"newCategoryPlaceholder": "Category name",
|
||||
"newSubcategoryTitle": "New subcategory",
|
||||
"newSubcategoryPlaceholder": "Subcategory name",
|
||||
"loanStatusFilterLabel": "Loan status filter",
|
||||
"loanStatusActive": "Active",
|
||||
"loanStatusPaid": "Paid",
|
||||
"loanStatusAll": "All",
|
||||
"loanTransactions": "Loan transactions",
|
||||
"loanInstallmentNumber": "Installment {{number}} of {{total}}",
|
||||
"loanReportTitle": "Loan report",
|
||||
"loanNoTransactions": "No payments recorded yet."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
+62
-2
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Subir",
|
||||
"customizeMoveDown": "Bajar",
|
||||
"overdueTasksChip": "{{count}} tarea vencida",
|
||||
"overdueTasksChipPlural": "{{count}} tareas vencidas"
|
||||
"overdueTasksChipPlural": "{{count}} tareas vencidas",
|
||||
"customizeManage": "Widgets",
|
||||
"customizeExit": "Salir de personalización",
|
||||
"customizeDrag": "Arrastrar widget",
|
||||
"customizeSize": "Tamaño",
|
||||
"customizeSizeFor": "Tamaño de {{widget}}",
|
||||
"customizeHide": "Ocultar {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tareas",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Nombre de la nueva subcategoría:",
|
||||
"categoryAddedToast": "Categoría añadida.",
|
||||
"subcategoryAddedToast": "Subcategoría añadida.",
|
||||
"emptyAction": "Agregar entrada"
|
||||
"emptyAction": "Agregar entrada",
|
||||
"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.",
|
||||
"deleteLoanPaymentConfirm": "¿Eliminar este pago del préstamo?",
|
||||
"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",
|
||||
"loanPaymentTitle": "Pago del préstamo: {{borrower}}",
|
||||
"typeLoan": "Préstamo",
|
||||
"tabsLabel": "Secciones del presupuesto",
|
||||
"budgetTab": "Presupuesto",
|
||||
"loansTab": "Préstamos",
|
||||
"filteredTransactions": "Transacciones filtradas",
|
||||
"clearLoanFilter": "Limpiar filtro",
|
||||
"loanFilterActive": "Préstamo: {{title}}",
|
||||
"filterLoanTransactions": "Mostrar transacciones de este préstamo",
|
||||
"loansEmptyDescription": "Crea un préstamo con el botón + y elige Préstamo.",
|
||||
"newCategoryTitle": "Nueva categoría",
|
||||
"newCategoryPlaceholder": "Nombre de la categoría",
|
||||
"newSubcategoryTitle": "Nueva subcategoría",
|
||||
"newSubcategoryPlaceholder": "Nombre de la subcategoría",
|
||||
"loanStatusFilterLabel": "Filtro de estado de préstamos",
|
||||
"loanStatusActive": "Activos",
|
||||
"loanStatusPaid": "Pagados",
|
||||
"loanStatusAll": "Todos",
|
||||
"loanTransactions": "Transacciones del préstamo",
|
||||
"loanInstallmentNumber": "Cuota {{number}} de {{total}}",
|
||||
"loanReportTitle": "Informe del préstamo",
|
||||
"loanNoTransactions": "Aún no hay pagos registrados."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
|
||||
+62
-2
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Monter",
|
||||
"customizeMoveDown": "Descendre",
|
||||
"overdueTasksChip": "{{count}} tâche en retard",
|
||||
"overdueTasksChipPlural": "{{count}} tâches en retard"
|
||||
"overdueTasksChipPlural": "{{count}} tâches en retard",
|
||||
"customizeManage": "Widgets",
|
||||
"customizeExit": "Quitter la personnalisation",
|
||||
"customizeDrag": "Faire glisser le widget",
|
||||
"customizeSize": "Taille",
|
||||
"customizeSizeFor": "Taille de {{widget}}",
|
||||
"customizeHide": "Masquer {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tâches",
|
||||
@@ -584,7 +590,61 @@
|
||||
"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": "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.",
|
||||
"deleteLoanPaymentConfirm": "Supprimer ce paiement de prêt ?",
|
||||
"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": "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é",
|
||||
"loanPaymentTitle": "Remboursement du prêt : {{borrower}}",
|
||||
"typeLoan": "Prêt",
|
||||
"tabsLabel": "Sections du budget",
|
||||
"budgetTab": "Budget",
|
||||
"loansTab": "Prêts",
|
||||
"filteredTransactions": "Transactions filtrées",
|
||||
"clearLoanFilter": "Effacer le filtre",
|
||||
"loanFilterActive": "Prêt : {{title}}",
|
||||
"filterLoanTransactions": "Afficher les transactions de ce prêt",
|
||||
"loansEmptyDescription": "Créez un prêt avec le bouton + puis choisissez Prêt.",
|
||||
"newCategoryTitle": "Nouvelle catégorie",
|
||||
"newCategoryPlaceholder": "Nom de la catégorie",
|
||||
"newSubcategoryTitle": "Nouvelle sous-catégorie",
|
||||
"newSubcategoryPlaceholder": "Nom de la sous-catégorie",
|
||||
"loanStatusFilterLabel": "Filtre de statut des prêts",
|
||||
"loanStatusActive": "Actifs",
|
||||
"loanStatusPaid": "Payés",
|
||||
"loanStatusAll": "Tous",
|
||||
"loanTransactions": "Transactions du prêt",
|
||||
"loanInstallmentNumber": "Échéance {{number}} sur {{total}}",
|
||||
"loanReportTitle": "Rapport du prêt",
|
||||
"loanNoTransactions": "Aucun paiement enregistré."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "ऊपर ले जाएं",
|
||||
"customizeMoveDown": "नीचे ले जाएं",
|
||||
"overdueTasksChip": "{{count}} विलंबित कार्य",
|
||||
"overdueTasksChipPlural": "{{count}} विलंबित कार्य"
|
||||
"overdueTasksChipPlural": "{{count}} विलंबित कार्य",
|
||||
"customizeManage": "विजेट",
|
||||
"customizeExit": "अनुकूलन से बाहर निकलें",
|
||||
"customizeDrag": "विजेट खींचें",
|
||||
"customizeSize": "आकार",
|
||||
"customizeSizeFor": "{{widget}} का आकार",
|
||||
"customizeHide": "{{widget}} छिपाएँ"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "कार्य",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "उधार / ब्याज",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "प्रविष्टि जोड़ें"
|
||||
"emptyAction": "प्रविष्टि जोड़ें",
|
||||
"loansTitle": "उधार",
|
||||
"loansSummary": "{{count}} सक्रिय · {{amount}} बाकी",
|
||||
"newLoan": "नया उधार",
|
||||
"createLoan": "उधार बनाएं",
|
||||
"editLoan": "उधार संपादित करें",
|
||||
"deleteLoan": "उधार हटाएं",
|
||||
"deleteLoanConfirm": "उधार \"{{title}}\" हटाएं? बजट में दर्ज भुगतान भी हटा दिए जाएंगे।",
|
||||
"deleteLoanPaymentConfirm": "यह ऋण भुगतान हटाएँ?",
|
||||
"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": "भुगतान दर्ज किया गया",
|
||||
"loanPaymentTitle": "ऋण भुगतान: {{borrower}}",
|
||||
"typeLoan": "उधार",
|
||||
"tabsLabel": "बजट अनुभाग",
|
||||
"budgetTab": "बजट",
|
||||
"loansTab": "उधार",
|
||||
"filteredTransactions": "फ़िल्टर किए गए लेन-देन",
|
||||
"clearLoanFilter": "फ़िल्टर हटाएं",
|
||||
"loanFilterActive": "उधार: {{title}}",
|
||||
"filterLoanTransactions": "इस उधार के लेन-देन दिखाएं",
|
||||
"loansEmptyDescription": "+ बटन से उधार चुनकर नया उधार बनाएं।",
|
||||
"newCategoryTitle": "नई श्रेणी",
|
||||
"newCategoryPlaceholder": "श्रेणी का नाम",
|
||||
"newSubcategoryTitle": "नई उपश्रेणी",
|
||||
"newSubcategoryPlaceholder": "उपश्रेणी का नाम",
|
||||
"loanStatusFilterLabel": "उधार स्थिति फ़िल्टर",
|
||||
"loanStatusActive": "सक्रिय",
|
||||
"loanStatusPaid": "चुकाया गया",
|
||||
"loanStatusAll": "सभी",
|
||||
"loanTransactions": "उधार लेन-देन",
|
||||
"loanInstallmentNumber": "{{total}} में से किस्त {{number}}",
|
||||
"loanReportTitle": "उधार रिपोर्ट",
|
||||
"loanNoTransactions": "अभी कोई भुगतान दर्ज नहीं है।"
|
||||
},
|
||||
"settings": {
|
||||
"title": "सेटिंग्स",
|
||||
|
||||
+62
-2
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Su",
|
||||
"customizeMoveDown": "Giù",
|
||||
"overdueTasksChip": "{{count}} compito scaduto",
|
||||
"overdueTasksChipPlural": "{{count}} compiti scaduti"
|
||||
"overdueTasksChipPlural": "{{count}} compiti scaduti",
|
||||
"customizeManage": "Widget",
|
||||
"customizeExit": "Esci dalla personalizzazione",
|
||||
"customizeDrag": "Trascina widget",
|
||||
"customizeSize": "Dimensione",
|
||||
"customizeSizeFor": "Dimensione di {{widget}}",
|
||||
"customizeHide": "Nascondi {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Compiti",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Nome della nuova sottocategoria:",
|
||||
"categoryAddedToast": "Categoria aggiunta.",
|
||||
"subcategoryAddedToast": "Sottocategoria aggiunta.",
|
||||
"emptyAction": "Aggiungi voce"
|
||||
"emptyAction": "Aggiungi voce",
|
||||
"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.",
|
||||
"deleteLoanPaymentConfirm": "Eliminare questo pagamento del prestito?",
|
||||
"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",
|
||||
"loanPaymentTitle": "Rimborso del prestito: {{borrower}}",
|
||||
"typeLoan": "Prestito",
|
||||
"tabsLabel": "Sezioni del bilancio",
|
||||
"budgetTab": "Bilancio",
|
||||
"loansTab": "Prestiti",
|
||||
"filteredTransactions": "Movimenti filtrati",
|
||||
"clearLoanFilter": "Cancella filtro",
|
||||
"loanFilterActive": "Prestito: {{title}}",
|
||||
"filterLoanTransactions": "Mostra i movimenti di questo prestito",
|
||||
"loansEmptyDescription": "Crea un prestito dal pulsante + e scegli Prestito.",
|
||||
"newCategoryTitle": "Nuova categoria",
|
||||
"newCategoryPlaceholder": "Nome categoria",
|
||||
"newSubcategoryTitle": "Nuova sottocategoria",
|
||||
"newSubcategoryPlaceholder": "Nome sottocategoria",
|
||||
"loanStatusFilterLabel": "Filtro stato prestiti",
|
||||
"loanStatusActive": "Attivi",
|
||||
"loanStatusPaid": "Pagati",
|
||||
"loanStatusAll": "Tutti",
|
||||
"loanTransactions": "Movimenti del prestito",
|
||||
"loanInstallmentNumber": "Rata {{number}} di {{total}}",
|
||||
"loanReportTitle": "Report del prestito",
|
||||
"loanNoTransactions": "Nessun pagamento registrato."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "上へ",
|
||||
"customizeMoveDown": "下へ",
|
||||
"overdueTasksChip": "期限超過のタスク {{count}} 件",
|
||||
"overdueTasksChipPlural": "期限超過のタスク {{count}} 件"
|
||||
"overdueTasksChipPlural": "期限超過のタスク {{count}} 件",
|
||||
"customizeManage": "ウィジェット",
|
||||
"customizeExit": "カスタマイズを終了",
|
||||
"customizeDrag": "ウィジェットをドラッグ",
|
||||
"customizeSize": "サイズ",
|
||||
"customizeSizeFor": "{{widget}} のサイズ",
|
||||
"customizeHide": "{{widget}} を非表示"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "タスク",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "貸付 / 利息",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "エントリを追加"
|
||||
"emptyAction": "エントリを追加",
|
||||
"loansTitle": "貸付",
|
||||
"loansSummary": "{{count}} 件が進行中 · 残り {{amount}}",
|
||||
"newLoan": "新しい貸付",
|
||||
"createLoan": "貸付を作成",
|
||||
"editLoan": "貸付を編集",
|
||||
"deleteLoan": "貸付を削除",
|
||||
"deleteLoanConfirm": "貸付「{{title}}」を削除しますか?予算に記録済みの返済も削除されます。",
|
||||
"deleteLoanPaymentConfirm": "このローン支払いを削除しますか?",
|
||||
"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": "返済を記録しました",
|
||||
"loanPaymentTitle": "ローン返済: {{borrower}}",
|
||||
"typeLoan": "貸付",
|
||||
"tabsLabel": "予算セクション",
|
||||
"budgetTab": "予算",
|
||||
"loansTab": "貸付",
|
||||
"filteredTransactions": "絞り込み済み取引",
|
||||
"clearLoanFilter": "フィルター解除",
|
||||
"loanFilterActive": "貸付:{{title}}",
|
||||
"filterLoanTransactions": "この貸付の取引を表示",
|
||||
"loansEmptyDescription": "+ ボタンから貸付を選んで作成します。",
|
||||
"newCategoryTitle": "新しいカテゴリ",
|
||||
"newCategoryPlaceholder": "カテゴリ名",
|
||||
"newSubcategoryTitle": "新しいサブカテゴリ",
|
||||
"newSubcategoryPlaceholder": "サブカテゴリ名",
|
||||
"loanStatusFilterLabel": "貸付ステータスフィルター",
|
||||
"loanStatusActive": "進行中",
|
||||
"loanStatusPaid": "完済",
|
||||
"loanStatusAll": "すべて",
|
||||
"loanTransactions": "貸付取引",
|
||||
"loanInstallmentNumber": "{{total}} 回中 {{number}} 回目",
|
||||
"loanReportTitle": "貸付レポート",
|
||||
"loanNoTransactions": "返済はまだ記録されていません。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
|
||||
+62
-2
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Mover para cima",
|
||||
"customizeMoveDown": "Mover para baixo",
|
||||
"overdueTasksChip": "{{count}} tarefa vencida",
|
||||
"overdueTasksChipPlural": "{{count}} tarefas vencidas"
|
||||
"overdueTasksChipPlural": "{{count}} tarefas vencidas",
|
||||
"customizeManage": "Widgets",
|
||||
"customizeExit": "Sair da personalização",
|
||||
"customizeDrag": "Arrastar widget",
|
||||
"customizeSize": "Tamanho",
|
||||
"customizeSizeFor": "Tamanho de {{widget}}",
|
||||
"customizeHide": "Ocultar {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tarefas",
|
||||
@@ -584,7 +590,61 @@
|
||||
"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.",
|
||||
"deleteLoanPaymentConfirm": "Excluir este pagamento do empréstimo?",
|
||||
"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",
|
||||
"loanPaymentTitle": "Pagamento do empréstimo: {{borrower}}",
|
||||
"typeLoan": "Empréstimo",
|
||||
"tabsLabel": "Seções do orçamento",
|
||||
"budgetTab": "Orçamento",
|
||||
"loansTab": "Empréstimos",
|
||||
"filteredTransactions": "Transações filtradas",
|
||||
"clearLoanFilter": "Limpar filtro",
|
||||
"loanFilterActive": "Empréstimo: {{title}}",
|
||||
"filterLoanTransactions": "Mostrar transações deste empréstimo",
|
||||
"loansEmptyDescription": "Crie um empréstimo pelo botão + e escolha Empréstimo.",
|
||||
"newCategoryTitle": "Nova categoria",
|
||||
"newCategoryPlaceholder": "Nome da categoria",
|
||||
"newSubcategoryTitle": "Nova subcategoria",
|
||||
"newSubcategoryPlaceholder": "Nome da subcategoria",
|
||||
"loanStatusFilterLabel": "Filtro de status dos empréstimos",
|
||||
"loanStatusActive": "Ativos",
|
||||
"loanStatusPaid": "Pagos",
|
||||
"loanStatusAll": "Todos",
|
||||
"loanTransactions": "Transações do empréstimo",
|
||||
"loanInstallmentNumber": "Parcela {{number}} de {{total}}",
|
||||
"loanReportTitle": "Relatório do empréstimo",
|
||||
"loanNoTransactions": "Nenhum pagamento registrado ainda."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Вверх",
|
||||
"customizeMoveDown": "Вниз",
|
||||
"overdueTasksChip": "{{count}} просроченная задача",
|
||||
"overdueTasksChipPlural": "{{count}} просроченных задач"
|
||||
"overdueTasksChipPlural": "{{count}} просроченных задач",
|
||||
"customizeManage": "Виджеты",
|
||||
"customizeExit": "Выйти из настройки",
|
||||
"customizeDrag": "Перетащить виджет",
|
||||
"customizeSize": "Размер",
|
||||
"customizeSizeFor": "Размер для {{widget}}",
|
||||
"customizeHide": "Скрыть {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Задачи",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "Займы / Проценты",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "Добавить запись"
|
||||
"emptyAction": "Добавить запись",
|
||||
"loansTitle": "Займы",
|
||||
"loansSummary": "{{count}} активных · осталось {{amount}}",
|
||||
"newLoan": "Новый займ",
|
||||
"createLoan": "Создать займ",
|
||||
"editLoan": "Изменить займ",
|
||||
"deleteLoan": "Удалить займ",
|
||||
"deleteLoanConfirm": "Удалить займ «{{title}}»? Платежи, уже добавленные в бюджет, тоже будут удалены.",
|
||||
"deleteLoanPaymentConfirm": "Удалить этот платеж по займу?",
|
||||
"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": "Платёж записан",
|
||||
"loanPaymentTitle": "Платеж по займу: {{borrower}}",
|
||||
"typeLoan": "Займ",
|
||||
"tabsLabel": "Разделы бюджета",
|
||||
"budgetTab": "Бюджет",
|
||||
"loansTab": "Займы",
|
||||
"filteredTransactions": "Отфильтрованные операции",
|
||||
"clearLoanFilter": "Сбросить фильтр",
|
||||
"loanFilterActive": "Займ: {{title}}",
|
||||
"filterLoanTransactions": "Показать операции этого займа",
|
||||
"loansEmptyDescription": "Создайте займ кнопкой + и выберите Займ.",
|
||||
"newCategoryTitle": "Новая категория",
|
||||
"newCategoryPlaceholder": "Название категории",
|
||||
"newSubcategoryTitle": "Новая подкатегория",
|
||||
"newSubcategoryPlaceholder": "Название подкатегории",
|
||||
"loanStatusFilterLabel": "Фильтр статуса займов",
|
||||
"loanStatusActive": "Активные",
|
||||
"loanStatusPaid": "Оплаченные",
|
||||
"loanStatusAll": "Все",
|
||||
"loanTransactions": "Операции займа",
|
||||
"loanInstallmentNumber": "Платёж {{number}} из {{total}}",
|
||||
"loanReportTitle": "Отчёт по займу",
|
||||
"loanNoTransactions": "Платежи ещё не записаны."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
|
||||
+62
-2
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Flytta upp",
|
||||
"customizeMoveDown": "Flytta ner",
|
||||
"overdueTasksChip": "{{count}} förfallen uppgift",
|
||||
"overdueTasksChipPlural": "{{count}} förfallna uppgifter"
|
||||
"overdueTasksChipPlural": "{{count}} förfallna uppgifter",
|
||||
"customizeManage": "Widgetar",
|
||||
"customizeExit": "Avsluta anpassning",
|
||||
"customizeDrag": "Dra widget",
|
||||
"customizeSize": "Storlek",
|
||||
"customizeSizeFor": "Storlek för {{widget}}",
|
||||
"customizeHide": "Dölj {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Uppgifter",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Namn på den nya underkategorin:",
|
||||
"categoryAddedToast": "Kategori tillagd.",
|
||||
"subcategoryAddedToast": "Underkategori har lagts till.",
|
||||
"emptyAction": "Lägg till post"
|
||||
"emptyAction": "Lägg till post",
|
||||
"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.",
|
||||
"deleteLoanPaymentConfirm": "Ta bort den här lånebetalningen?",
|
||||
"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",
|
||||
"loanPaymentTitle": "Låneåterbetalning: {{borrower}}",
|
||||
"typeLoan": "Lån",
|
||||
"tabsLabel": "Budgetsektioner",
|
||||
"budgetTab": "Budget",
|
||||
"loansTab": "Lån",
|
||||
"filteredTransactions": "Filtrerade transaktioner",
|
||||
"clearLoanFilter": "Rensa filter",
|
||||
"loanFilterActive": "Lån: {{title}}",
|
||||
"filterLoanTransactions": "Visa transaktioner för detta lån",
|
||||
"loansEmptyDescription": "Skapa ett lån med +-knappen och välj Lån.",
|
||||
"newCategoryTitle": "Ny kategori",
|
||||
"newCategoryPlaceholder": "Kategorinamn",
|
||||
"newSubcategoryTitle": "Ny underkategori",
|
||||
"newSubcategoryPlaceholder": "Underkategorinamn",
|
||||
"loanStatusFilterLabel": "Filter för lånestatus",
|
||||
"loanStatusActive": "Aktiva",
|
||||
"loanStatusPaid": "Betalda",
|
||||
"loanStatusAll": "Alla",
|
||||
"loanTransactions": "Lånetransaktioner",
|
||||
"loanInstallmentNumber": "Delbetalning {{number}} av {{total}}",
|
||||
"loanReportTitle": "Lånrapport",
|
||||
"loanNoTransactions": "Inga betalningar registrerade ännu."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Inställningar",
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Yukarı",
|
||||
"customizeMoveDown": "Aşağı",
|
||||
"overdueTasksChip": "{{count}} gecikmiş görev",
|
||||
"overdueTasksChipPlural": "{{count}} gecikmiş görev"
|
||||
"overdueTasksChipPlural": "{{count}} gecikmiş görev",
|
||||
"customizeManage": "Widgetlar",
|
||||
"customizeExit": "Özelleştirmeden çık",
|
||||
"customizeDrag": "Widgetı sürükle",
|
||||
"customizeSize": "Boyut",
|
||||
"customizeSizeFor": "{{widget}} boyutu",
|
||||
"customizeHide": "{{widget}} gizle"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Görevler",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "Borçlar / Faiz",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "Giriş ekle"
|
||||
"emptyAction": "Giriş ekle",
|
||||
"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.",
|
||||
"deleteLoanPaymentConfirm": "Bu kredi ödemesi silinsin mi?",
|
||||
"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",
|
||||
"loanPaymentTitle": "Kredi geri ödemesi: {{borrower}}",
|
||||
"typeLoan": "Borç",
|
||||
"tabsLabel": "Bütçe bölümleri",
|
||||
"budgetTab": "Bütçe",
|
||||
"loansTab": "Borçlar",
|
||||
"filteredTransactions": "Filtrelenmiş işlemler",
|
||||
"clearLoanFilter": "Filtreyi temizle",
|
||||
"loanFilterActive": "Borç: {{title}}",
|
||||
"filterLoanTransactions": "Bu borcun işlemlerini göster",
|
||||
"loansEmptyDescription": "+ düğmesinden Borç seçerek yeni bir borç oluşturun.",
|
||||
"newCategoryTitle": "Yeni kategori",
|
||||
"newCategoryPlaceholder": "Kategori adı",
|
||||
"newSubcategoryTitle": "Yeni alt kategori",
|
||||
"newSubcategoryPlaceholder": "Alt kategori adı",
|
||||
"loanStatusFilterLabel": "Borç durumu filtresi",
|
||||
"loanStatusActive": "Aktif",
|
||||
"loanStatusPaid": "Ödendi",
|
||||
"loanStatusAll": "Tümü",
|
||||
"loanTransactions": "Borç işlemleri",
|
||||
"loanInstallmentNumber": "{{total}} taksitten {{number}}.",
|
||||
"loanReportTitle": "Borç raporu",
|
||||
"loanNoTransactions": "Henüz ödeme kaydedilmedi."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Перемістити вгору",
|
||||
"customizeMoveDown": "Перемістити вниз",
|
||||
"overdueTasksChip": "{{count}} прострочене завдання",
|
||||
"overdueTasksChipPlural": "{{count}} прострочених завдань"
|
||||
"overdueTasksChipPlural": "{{count}} прострочених завдань",
|
||||
"customizeManage": "Віджети",
|
||||
"customizeExit": "Вийти з налаштування",
|
||||
"customizeDrag": "Перетягнути віджет",
|
||||
"customizeSize": "Розмір",
|
||||
"customizeSizeFor": "Розмір для {{widget}}",
|
||||
"customizeHide": "Приховати {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Завдання",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "Позики / Відсотки",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "Додати запис"
|
||||
"emptyAction": "Додати запис",
|
||||
"loansTitle": "Позики",
|
||||
"loansSummary": "{{count}} активних · залишилось {{amount}}",
|
||||
"newLoan": "Нова позика",
|
||||
"createLoan": "Створити позику",
|
||||
"editLoan": "Редагувати позику",
|
||||
"deleteLoan": "Видалити позику",
|
||||
"deleteLoanConfirm": "Видалити позику «{{title}}»? Платежі, вже додані до бюджету, також буде видалено.",
|
||||
"deleteLoanPaymentConfirm": "Видалити цей платіж за позикою?",
|
||||
"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": "Платіж записано",
|
||||
"loanPaymentTitle": "Платіж за позикою: {{borrower}}",
|
||||
"typeLoan": "Позика",
|
||||
"tabsLabel": "Розділи бюджету",
|
||||
"budgetTab": "Бюджет",
|
||||
"loansTab": "Позики",
|
||||
"filteredTransactions": "Відфільтровані операції",
|
||||
"clearLoanFilter": "Очистити фільтр",
|
||||
"loanFilterActive": "Позика: {{title}}",
|
||||
"filterLoanTransactions": "Показати операції цієї позики",
|
||||
"loansEmptyDescription": "Створіть позику кнопкою + і виберіть Позика.",
|
||||
"newCategoryTitle": "Нова категорія",
|
||||
"newCategoryPlaceholder": "Назва категорії",
|
||||
"newSubcategoryTitle": "Нова підкатегорія",
|
||||
"newSubcategoryPlaceholder": "Назва підкатегорії",
|
||||
"loanStatusFilterLabel": "Фільтр статусу позик",
|
||||
"loanStatusActive": "Активні",
|
||||
"loanStatusPaid": "Сплачені",
|
||||
"loanStatusAll": "Усі",
|
||||
"loanTransactions": "Операції позики",
|
||||
"loanInstallmentNumber": "Платіж {{number}} з {{total}}",
|
||||
"loanReportTitle": "Звіт по позиці",
|
||||
"loanNoTransactions": "Платежі ще не записано."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Налаштування",
|
||||
|
||||
+63
-3
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "上移",
|
||||
"customizeMoveDown": "下移",
|
||||
"overdueTasksChip": "{{count}} 个逾期任务",
|
||||
"overdueTasksChipPlural": "{{count}} 个逾期任务"
|
||||
"overdueTasksChipPlural": "{{count}} 个逾期任务",
|
||||
"customizeManage": "小组件",
|
||||
"customizeExit": "退出自定义",
|
||||
"customizeDrag": "拖动小组件",
|
||||
"customizeSize": "大小",
|
||||
"customizeSizeFor": "{{widget}} 的大小",
|
||||
"customizeHide": "隐藏 {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "任务",
|
||||
@@ -572,7 +578,7 @@
|
||||
"subcatCoursesCollege": "Courses / College",
|
||||
"subcatSchoolSupplies": "School supplies",
|
||||
"subcatLanguages": "Languages",
|
||||
"subcatLoansInterest": "Loans / Interest",
|
||||
"subcatLoansInterest": "借款 / 利息",
|
||||
"subcatBankFees": "Bank fees",
|
||||
"subcatInsuranceOther": "Insurance",
|
||||
"subcatInvestments": "Investments",
|
||||
@@ -584,7 +590,61 @@
|
||||
"newSubcategoryPrompt": "Name of the new subcategory:",
|
||||
"categoryAddedToast": "Category added.",
|
||||
"subcategoryAddedToast": "Subcategory added.",
|
||||
"emptyAction": "添加记录"
|
||||
"emptyAction": "添加记录",
|
||||
"loansTitle": "借款",
|
||||
"loansSummary": "{{count}} 笔进行中 · 剩余 {{amount}}",
|
||||
"newLoan": "新建借款",
|
||||
"createLoan": "创建借款",
|
||||
"editLoan": "编辑借款",
|
||||
"deleteLoan": "删除借款",
|
||||
"deleteLoanConfirm": "删除借款“{{title}}”?已记入预算的还款也会被删除。",
|
||||
"deleteLoanPaymentConfirm": "删除这笔借款还款?",
|
||||
"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": "还款已记录",
|
||||
"loanPaymentTitle": "借款还款:{{borrower}}",
|
||||
"typeLoan": "借款",
|
||||
"tabsLabel": "预算分区",
|
||||
"budgetTab": "预算",
|
||||
"loansTab": "借款",
|
||||
"filteredTransactions": "已筛选交易",
|
||||
"clearLoanFilter": "清除筛选",
|
||||
"loanFilterActive": "借款:{{title}}",
|
||||
"filterLoanTransactions": "显示此借款的交易",
|
||||
"loansEmptyDescription": "点击 + 按钮并选择借款来创建。",
|
||||
"newCategoryTitle": "新类别",
|
||||
"newCategoryPlaceholder": "类别名称",
|
||||
"newSubcategoryTitle": "新子类别",
|
||||
"newSubcategoryPlaceholder": "子类别名称",
|
||||
"loanStatusFilterLabel": "借款状态筛选",
|
||||
"loanStatusActive": "进行中",
|
||||
"loanStatusPaid": "已还清",
|
||||
"loanStatusAll": "全部",
|
||||
"loanTransactions": "借款交易",
|
||||
"loanInstallmentNumber": "第 {{number}} / {{total}} 期",
|
||||
"loanReportTitle": "借款报告",
|
||||
"loanNoTransactions": "尚未记录还款。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
|
||||
+619
-31
@@ -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 } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
@@ -124,6 +124,10 @@ let state = {
|
||||
entries: [],
|
||||
summary: null,
|
||||
prevSummary: null, // Vormonat für Monatsvergleich
|
||||
loans: { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } },
|
||||
activeTab: 'budget',
|
||||
loanFilterId: null,
|
||||
loanStatusFilter: 'active',
|
||||
currency: 'EUR',
|
||||
meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} },
|
||||
};
|
||||
@@ -148,6 +152,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 +164,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 +218,7 @@ export async function render(container, { user }) {
|
||||
state.currency = prefsRes.data?.currency ?? 'EUR';
|
||||
} catch (_) { /* Fallback auf EUR */ }
|
||||
|
||||
container.innerHTML = `
|
||||
setHtml(container, `
|
||||
<div class="budget-page">
|
||||
<h1 class="sr-only">${t('budget.title')}</h1>
|
||||
<div class="budget-nav">
|
||||
@@ -215,6 +227,14 @@ export async function render(container, { user }) {
|
||||
</button>
|
||||
<button class="budget-nav__today" id="budget-today">${t('budget.currentMonth')}</button>
|
||||
<span class="budget-nav__label" id="budget-label"></span>
|
||||
<div class="budget-tabs" role="tablist" aria-label="${t('budget.tabsLabel')}">
|
||||
<button class="budget-tab" id="budget-tab-budget" type="button" role="tab" aria-selected="true" data-tab="budget">
|
||||
${t('budget.budgetTab')}
|
||||
</button>
|
||||
<button class="budget-tab" id="budget-tab-loans" type="button" role="tab" aria-selected="false" data-tab="loans">
|
||||
${t('budget.loansTab')}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn--primary btn--icon" id="budget-add" aria-label="${t('budget.addEntryLabel')}">
|
||||
<i data-lucide="plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -229,7 +249,7 @@ export async function render(container, { user }) {
|
||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
@@ -264,6 +284,12 @@ function wireNav() {
|
||||
const addHandler = () => openBudgetModal({ mode: 'create' });
|
||||
_container.querySelector('#budget-add').addEventListener('click', addHandler);
|
||||
_container.querySelector('#fab-new-budget').addEventListener('click', addHandler);
|
||||
_container.querySelectorAll('.budget-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
state.activeTab = tab.dataset.tab;
|
||||
renderBody();
|
||||
});
|
||||
});
|
||||
updateLabel();
|
||||
}
|
||||
|
||||
@@ -283,10 +309,19 @@ function renderBody() {
|
||||
|
||||
const s = state.summary;
|
||||
const p = state.prevSummary;
|
||||
updateTabs();
|
||||
if (state.activeTab === 'loans') {
|
||||
setHtml(body, renderLoansPage());
|
||||
wireLoansPage();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
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, `
|
||||
<div class="budget-tab-panel budget-tab-panel--budget">
|
||||
<!-- Zusammenfassung -->
|
||||
<div class="budget-summary">
|
||||
<div class="budget-summary-card budget-summary-card--income">
|
||||
@@ -318,18 +353,23 @@ function renderBody() {
|
||||
<!-- Transaktionsliste -->
|
||||
<div class="budget-list-section">
|
||||
<div class="budget-list-header">
|
||||
<span class="budget-list-header__title">${t('budget.transactions')}</span>
|
||||
<div>
|
||||
<span class="budget-list-header__title">${t('budget.transactions')}</span>
|
||||
</div>
|
||||
<div class="budget-list-header__actions">
|
||||
${state.entries.length ? `
|
||||
<a href="/api/v1/budget/export?month=${state.month}" class="btn btn--secondary"
|
||||
style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3);">
|
||||
<i data-lucide="download" style="width:14px;height:14px;margin-right:4px;" aria-hidden="true"></i>CSV
|
||||
</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-list" id="budget-list">
|
||||
${renderEntries()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>
|
||||
`);
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
_container.querySelector('#empty-cta-budget')?.addEventListener('click', () => {
|
||||
@@ -349,6 +389,14 @@ function renderBody() {
|
||||
});
|
||||
}
|
||||
|
||||
function updateTabs() {
|
||||
_container.querySelectorAll('.budget-tab').forEach((tab) => {
|
||||
const active = tab.dataset.tab === state.activeTab;
|
||||
tab.classList.toggle('budget-tab--active', active);
|
||||
tab.setAttribute('aria-selected', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function renderCategoryBars(byCategory) {
|
||||
const maxAbs = Math.max(...byCategory.map((c) => Math.abs(c.total)), 1);
|
||||
|
||||
@@ -415,6 +463,310 @@ function renderEntries() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderLoansDashboard() {
|
||||
const loans = state.loans?.loans ?? [];
|
||||
if (!loans.length) return '';
|
||||
|
||||
const summary = state.loans?.summary ?? {};
|
||||
const visibleLoans = filteredLoans();
|
||||
|
||||
return `
|
||||
<section class="budget-loans">
|
||||
<div class="budget-loans__header">
|
||||
<div>
|
||||
<div class="budget-loans__eyebrow">${t('budget.loansTitle')}</div>
|
||||
<div class="budget-loans__summary">${t('budget.loansSummary', {
|
||||
count: summary.active_count ?? 0,
|
||||
amount: formatAmount(summary.remaining_amount ?? 0),
|
||||
})}</div>
|
||||
${state.loanFilterId ? `<div class="budget-list-header__filter">${esc(activeLoanLabel())}</div>` : ''}
|
||||
</div>
|
||||
<div class="budget-loans__filters" role="group" aria-label="${t('budget.loanStatusFilterLabel')}">
|
||||
${state.loanFilterId ? `
|
||||
<button class="budget-loans__filter" type="button" id="budget-clear-loan-filter">
|
||||
<i data-lucide="x" aria-hidden="true"></i>${t('budget.clearLoanFilter')}
|
||||
</button>` : ''}
|
||||
<button class="budget-loans__filter ${state.loanStatusFilter === 'active' ? 'budget-loans__filter--active' : ''}"
|
||||
type="button" data-loan-status="active">${t('budget.loanStatusActive')}</button>
|
||||
<button class="budget-loans__filter ${state.loanStatusFilter === 'paid' ? 'budget-loans__filter--active' : ''}"
|
||||
type="button" data-loan-status="paid">${t('budget.loanStatusPaid')}</button>
|
||||
<button class="budget-loans__filter ${state.loanStatusFilter === 'all' ? 'budget-loans__filter--active' : ''}"
|
||||
type="button" data-loan-status="all">${t('budget.loanStatusAll')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-loans__stats">
|
||||
<div>
|
||||
<span>${t('budget.loanRemainingAmount')}</span>
|
||||
<strong>${formatAmount(summary.remaining_amount ?? 0)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>${t('budget.loanRemainingInstallments')}</span>
|
||||
<strong>${summary.remaining_installments ?? 0}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>${t('budget.loanPaidAmount')}</span>
|
||||
<strong>${formatAmount(summary.paid_amount ?? 0)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
${visibleLoans.length ? `
|
||||
<div class="budget-loans__list">
|
||||
${visibleLoans.map(renderLoanCard).join('')}
|
||||
</div>
|
||||
` : `
|
||||
<div class="budget-loans__empty">${t('budget.loansEmpty')}</div>
|
||||
`}
|
||||
${renderLoanTransactions(visibleLoans)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function filteredLoans() {
|
||||
const loans = state.loans?.loans ?? [];
|
||||
return loans.filter((loan) => {
|
||||
const matchesStatus = state.loanStatusFilter === 'all' || loan.status === state.loanStatusFilter;
|
||||
const matchesLoan = !state.loanFilterId || loan.id === state.loanFilterId;
|
||||
return matchesStatus && matchesLoan;
|
||||
});
|
||||
}
|
||||
|
||||
function activeLoanLabel() {
|
||||
const loan = state.loans.loans.find((item) => item.id === state.loanFilterId);
|
||||
return loan ? t('budget.loanFilterActive', { title: loan.title }) : '';
|
||||
}
|
||||
|
||||
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 `<div class="budget-loan-transactions">
|
||||
<div class="budget-loan-transactions__title">${t('budget.loanTransactions')}</div>
|
||||
<div class="budget-loan-transactions__list">
|
||||
${payments.map(({ loan, ...payment }) => renderLoanPaymentEntry(loan, payment)).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function loanPaymentToEntry(loan, payment) {
|
||||
if (!payment.budget_entry_id) return null;
|
||||
return {
|
||||
id: payment.budget_entry_id,
|
||||
title: payment.entry_title || `Loan repayment: ${loan.borrower}`,
|
||||
amount: Number(payment.amount || 0),
|
||||
category: payment.entry_category || 'Geschenke & Transfers',
|
||||
subcategory: payment.entry_subcategory || '',
|
||||
date: payment.paid_date,
|
||||
is_recurring: payment.entry_is_recurring || 0,
|
||||
recurrence_parent_id: payment.entry_recurrence_parent_id || null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderLoanPaymentEntry(loan, payment) {
|
||||
const entry = loanPaymentToEntry(loan, payment);
|
||||
const meta = `${formatEntryDate(payment.paid_date)} · ${esc(loan.title)} · ${t('budget.loanInstallmentNumber', {
|
||||
number: payment.installment_number,
|
||||
total: loan.installment_count,
|
||||
})}`;
|
||||
|
||||
return `
|
||||
<div class="budget-entry budget-entry--loan" data-loan-payment-id="${payment.id}" data-loan-id="${loan.id}" ${entry ? `data-entry-id="${entry.id}"` : ''}>
|
||||
<div class="budget-entry__indicator budget-entry__indicator--income"></div>
|
||||
<div class="budget-entry__body">
|
||||
<div class="budget-entry__title">${esc(payment.entry_title || t('budget.loanPaymentTitle', { borrower: loan.borrower }))}</div>
|
||||
<div class="budget-entry__meta">${meta}</div>
|
||||
</div>
|
||||
<div class="budget-entry__amount budget-entry__amount--income">+${formatAmount(payment.amount)}</div>
|
||||
<div class="budget-entry__actions">
|
||||
${entry ? `
|
||||
<button class="budget-entry__delete" data-action="loan-payment-edit" data-loan-id="${loan.id}" data-payment-id="${payment.id}" data-entry-id="${entry.id}" aria-label="${t('common.edit')}">
|
||||
<i data-lucide="pencil" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||
</button>` : ''}
|
||||
<button class="budget-entry__delete" data-action="loan-payment-delete" data-loan-id="${loan.id}" data-payment-id="${payment.id}" data-entry-id="${entry?.id ?? ''}" aria-label="${t('budget.deleteLabel')}">
|
||||
<i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLoansPage() {
|
||||
const loans = state.loans?.loans ?? [];
|
||||
if (!loans.length) {
|
||||
return `<div class="budget-tab-panel budget-tab-panel--loans">
|
||||
<div class="empty-state">
|
||||
<i data-lucide="hand-coins" class="empty-state__icon" aria-hidden="true"></i>
|
||||
<div class="empty-state__title">${t('budget.loansEmpty')}</div>
|
||||
<div class="empty-state__description">${t('budget.loansEmptyDescription')}</div>
|
||||
<button class="btn btn--primary empty-state__cta" id="budget-empty-loan">
|
||||
<i data-lucide="plus" aria-hidden="true" class="icon-base"></i>
|
||||
${t('budget.newLoan')}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="budget-tab-panel budget-tab-panel--loans">
|
||||
${renderLoansDashboard()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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;
|
||||
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));
|
||||
});
|
||||
});
|
||||
_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));
|
||||
});
|
||||
});
|
||||
_container.querySelectorAll('[data-action="loan-filter"]').forEach((btn) => {
|
||||
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) {
|
||||
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 = `
|
||||
<div class="loan-report">
|
||||
<div class="loan-report__hero">
|
||||
<div>
|
||||
<div class="loan-report__borrower">${esc(loan.borrower)}</div>
|
||||
<div class="loan-report__title">${esc(loan.title)}</div>
|
||||
</div>
|
||||
<span class="loan-report__status loan-report__status--${loan.status}">
|
||||
${loan.status === 'paid' ? t('budget.loanStatusPaid') : t('budget.loanStatusActive')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="loan-report__grid">
|
||||
<div><span>${t('budget.loanAmountLabel')}</span><strong>${formatAmount(loan.total_amount)}</strong></div>
|
||||
<div><span>${t('budget.loanRemainingAmount')}</span><strong>${formatAmount(loan.remaining_amount)}</strong></div>
|
||||
<div><span>${t('budget.loanPaidAmount')}</span><strong>${formatAmount(loan.paid_amount)}</strong></div>
|
||||
<div><span>${t('budget.loanRemainingInstallments')}</span><strong>${loan.remaining_installments}</strong></div>
|
||||
</div>
|
||||
<div class="loan-report__section-title">${t('budget.loanTransactions')}</div>
|
||||
${payments.length ? `
|
||||
<div class="loan-report__transactions">
|
||||
${payments.map((payment) => `
|
||||
<div class="budget-loan-transaction">
|
||||
<div>
|
||||
<strong>${t('budget.loanInstallmentNumber', { number: payment.installment_number, total: loan.installment_count })}</strong>
|
||||
<span>${formatEntryDate(payment.paid_date)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>${formatAmount(payment.amount)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : `<div class="budget-loans__empty">${t('budget.loanNoTransactions')}</div>`}
|
||||
</div>
|
||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||
<div></div>
|
||||
<button class="btn btn--primary" id="loan-report-close">${t('common.close')}</button>
|
||||
</div>`;
|
||||
|
||||
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 `
|
||||
<article class="budget-loan-card" data-loan-id="${loan.id}">
|
||||
<div class="budget-loan-card__main">
|
||||
<div class="budget-loan-card__title-row">
|
||||
<div class="budget-loan-card__title">${esc(loan.title)}</div>
|
||||
<button class="budget-loan-card__filter ${state.loanFilterId === loan.id ? 'budget-loan-card__filter--active' : ''}" data-action="loan-filter" data-id="${loan.id}" aria-label="${t('budget.filterLoanTransactions')}">
|
||||
<i data-lucide="filter" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="budget-loan-card__meta">${esc(loan.borrower)} · ${t('budget.loanInstallmentMeta', {
|
||||
paid: loan.paid_installments,
|
||||
total: loan.installment_count,
|
||||
})}</div>
|
||||
</div>
|
||||
<div class="budget-loan-card__amounts">
|
||||
<strong>${formatAmount(loan.remaining_amount)}</strong>
|
||||
<span>${t('budget.loanRemainingOf', { total: formatAmount(loan.total_amount) })}</span>
|
||||
</div>
|
||||
<div class="budget-loan-card__progress" aria-label="${paidPct}%">
|
||||
<span style="width:${paidPct}%"></span>
|
||||
</div>
|
||||
<div class="budget-loan-card__footer">
|
||||
<span>${t('budget.loanNextDue', { month: nextDue })}</span>
|
||||
<div class="budget-loan-card__actions">
|
||||
<button class="btn btn--secondary btn--icon" data-action="loan-edit" data-id="${loan.id}" aria-label="${t('budget.editLoan')}">
|
||||
<i data-lucide="pencil" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn--secondary btn--icon" data-action="loan-delete" data-id="${loan.id}" aria-label="${t('budget.deleteLoan')}">
|
||||
<i data-lucide="trash-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn--primary" data-action="loan-pay" data-id="${loan.id}" ${payDisabled}>
|
||||
${t('budget.markLoanPaid')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert eine Trend-Zeile im Vergleich zum Vormonat.
|
||||
* Alle drei Metriken (income, expenses, balance) nutzen dieselbe Logik:
|
||||
@@ -438,16 +790,17 @@ function renderTrend(current, prev, prevLabel) {
|
||||
}
|
||||
|
||||
function formatEntryDate(dateStr) {
|
||||
return formatDate(new Date(dateStr + 'T00:00:00'));
|
||||
return formatDate(dateStr);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Modal
|
||||
// --------------------------------------------------------
|
||||
|
||||
function openBudgetModal({ mode, entry = null }) {
|
||||
function openBudgetModal({ mode, entry = null, initialType = '' }) {
|
||||
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) : '';
|
||||
@@ -463,27 +816,29 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
).join('');
|
||||
|
||||
const content = `
|
||||
<div class="amount-type-toggle">
|
||||
<div class="amount-type-toggle ${isEdit ? 'amount-type-toggle--entry-only' : ''}">
|
||||
<button class="amount-type-btn amount-type-btn--expenses ${isExpense ? 'amount-type-btn--active' : ''}"
|
||||
id="type-expense" type="button">${t('budget.typeExpense')}</button>
|
||||
<button class="amount-type-btn amount-type-btn--income ${!isExpense ? 'amount-type-btn--active' : ''}"
|
||||
id="type-income" type="button">${t('budget.typeIncome')}</button>
|
||||
${!isEdit ? `<button class="amount-type-btn amount-type-btn--loan"
|
||||
id="type-loan" type="button">${t('budget.typeLoan')}</button>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group js-entry-field">
|
||||
<label class="form-label" for="bm-title">${t('budget.titleLabel')}</label>
|
||||
<input type="text" class="form-input" id="bm-title"
|
||||
placeholder="${t('budget.titlePlaceholder')}" value="${esc(isEdit ? entry.title : '')}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group js-entry-field">
|
||||
<label class="form-label" for="bm-amount">${t('budget.amountLabel')}</label>
|
||||
<input type="number" class="form-input" id="bm-amount"
|
||||
placeholder="${t('budget.amountPlaceholder')}" step="0.01" min="0"
|
||||
inputmode="decimal" value="${absAmount}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group js-entry-field">
|
||||
<div class="budget-field-header">
|
||||
<label class="form-label" for="bm-category">${t('budget.categoryLabel')}</label>
|
||||
<button class="btn btn--secondary budget-inline-add" type="button" id="bm-add-category">${t('budget.addCategory')}</button>
|
||||
@@ -491,7 +846,7 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
<select class="form-input" id="bm-category">${catOpts}</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="bm-subcategory-group" ${isExpense ? '' : 'hidden'}>
|
||||
<div class="form-group js-entry-field" id="bm-subcategory-group" ${isExpense ? '' : 'hidden'}>
|
||||
<div class="budget-field-header">
|
||||
<label class="form-label" for="bm-subcategory">${t('budget.subcategoryLabel')}</label>
|
||||
<button class="btn btn--secondary budget-inline-add" type="button" id="bm-add-subcategory">${t('budget.addSubcategory')}</button>
|
||||
@@ -499,13 +854,13 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
<select class="form-input" id="bm-subcategory">${subcatOpts}</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group js-entry-field">
|
||||
<label class="form-label" for="bm-date">${t('budget.dateLabel')}</label>
|
||||
<input type="date" class="form-input" id="bm-date"
|
||||
value="${isEdit ? entry.date : today}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group js-entry-field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="bm-recurring" ${isEdit && entry.is_recurring ? 'checked' : ''}>
|
||||
<span class="toggle__track"></span>
|
||||
@@ -513,6 +868,37 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="bm-loan-fields" hidden>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-borrower">${t('budget.loanBorrowerLabel')}</label>
|
||||
<input type="text" class="form-input" id="lm-borrower"
|
||||
placeholder="${t('budget.loanBorrowerPlaceholder')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-title">${t('budget.loanTitleLabel')}</label>
|
||||
<input type="text" class="form-input" id="lm-title"
|
||||
placeholder="${t('budget.loanTitlePlaceholder')}">
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-amount">${t('budget.loanAmountLabel')}</label>
|
||||
<input type="number" class="form-input" id="lm-amount" step="0.01" min="0.01" inputmode="decimal">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-installments">${t('budget.loanInstallmentsLabel')}</label>
|
||||
<input type="number" class="form-input" id="lm-installments" step="1" min="1" max="240" inputmode="numeric">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-start">${t('budget.loanStartMonthLabel')}</label>
|
||||
<input type="month" class="form-input" id="lm-start" value="${todayMonth}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-notes">${t('budget.loanNotesLabel')}</label>
|
||||
<textarea class="form-input" id="lm-notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||
${isEdit ? `<button class="btn btn--danger btn--icon" id="bm-delete" aria-label="${t('budget.deleteLabel')}">
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
@@ -528,7 +914,20 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
content,
|
||||
size: 'sm',
|
||||
onSave(panel) {
|
||||
let currentType = isExpense ? 'expense' : 'income';
|
||||
let currentType = !isEdit && initialType === 'loan' ? 'loan' : (isExpense ? 'expense' : 'income');
|
||||
|
||||
const setType = (type) => {
|
||||
currentType = type;
|
||||
panel.querySelector('#type-expense').classList.toggle('amount-type-btn--active', type === 'expense');
|
||||
panel.querySelector('#type-income').classList.toggle('amount-type-btn--active', type === 'income');
|
||||
panel.querySelector('#type-loan')?.classList.toggle('amount-type-btn--active', type === 'loan');
|
||||
panel.querySelectorAll('.js-entry-field').forEach((el) => { el.hidden = type === 'loan'; });
|
||||
panel.querySelector('#bm-loan-fields').hidden = type !== 'loan';
|
||||
panel.querySelector('#bm-save').textContent = type === 'loan'
|
||||
? t('budget.createLoan')
|
||||
: (isEdit ? t('common.save') : t('common.add'));
|
||||
if (type !== 'loan') updateCategoryOptions();
|
||||
};
|
||||
|
||||
const updateCategoryOptions = (preferredCategory = '') => {
|
||||
const cats = currentType === 'income' ? incomeCategories() : expenseCategories();
|
||||
@@ -568,7 +967,11 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
};
|
||||
|
||||
const addCategory = async () => {
|
||||
const name = window.prompt(t('budget.newCategoryPrompt'));
|
||||
const name = await requestNameInPanel(panel, {
|
||||
title: t('budget.newCategoryTitle'),
|
||||
label: t('budget.newCategoryPrompt'),
|
||||
placeholder: t('budget.newCategoryPlaceholder'),
|
||||
});
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const res = await api.post('/budget/categories', { name: name.trim(), type: currentType });
|
||||
@@ -584,7 +987,11 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
if (currentType !== 'expense') return;
|
||||
const category = panel.querySelector('#bm-category').value;
|
||||
if (!category) return;
|
||||
const name = window.prompt(t('budget.newSubcategoryPrompt'));
|
||||
const name = await requestNameInPanel(panel, {
|
||||
title: t('budget.newSubcategoryTitle'),
|
||||
label: t('budget.newSubcategoryPrompt'),
|
||||
placeholder: t('budget.newSubcategoryPlaceholder'),
|
||||
});
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const res = await api.post(`/budget/categories/${encodeURIComponent(category)}/subcategories`, { name: name.trim() });
|
||||
@@ -597,16 +1004,13 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
};
|
||||
|
||||
panel.querySelector('#type-expense').addEventListener('click', () => {
|
||||
currentType = 'expense';
|
||||
panel.querySelector('#type-expense').classList.add('amount-type-btn--active');
|
||||
panel.querySelector('#type-income').classList.remove('amount-type-btn--active');
|
||||
updateCategoryOptions();
|
||||
setType('expense');
|
||||
});
|
||||
panel.querySelector('#type-income').addEventListener('click', () => {
|
||||
currentType = 'income';
|
||||
panel.querySelector('#type-income').classList.add('amount-type-btn--active');
|
||||
panel.querySelector('#type-expense').classList.remove('amount-type-btn--active');
|
||||
updateCategoryOptions();
|
||||
setType('income');
|
||||
});
|
||||
panel.querySelector('#type-loan')?.addEventListener('click', () => {
|
||||
setType('loan');
|
||||
});
|
||||
panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions());
|
||||
panel.querySelector('#bm-add-category').addEventListener('click', addCategory);
|
||||
@@ -620,6 +1024,11 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
|
||||
panel.querySelector('#bm-save').addEventListener('click', async () => {
|
||||
const saveBtn = panel.querySelector('#bm-save');
|
||||
if (currentType === 'loan') {
|
||||
await saveLoanFromPanel(panel, saveBtn, { closeAfterSave: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = panel.querySelector('#bm-title').value.trim();
|
||||
const absVal = parseFloat(panel.querySelector('#bm-amount').value);
|
||||
const category = panel.querySelector('#bm-category').value;
|
||||
@@ -646,8 +1055,7 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
const idx = state.entries.findIndex((e) => e.id === entry.id);
|
||||
if (idx !== -1) state.entries[idx] = res.data;
|
||||
}
|
||||
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
|
||||
state.summary = sumRes.data;
|
||||
await loadMonth(state.month);
|
||||
|
||||
closeModal({ force: true });
|
||||
renderBody();
|
||||
@@ -658,10 +1066,191 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
saveBtn.textContent = isEdit ? t('common.save') : t('common.add');
|
||||
}
|
||||
});
|
||||
setType(currentType);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function requestNameInPanel(panel, { title, label, placeholder }) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'budget-inline-modal';
|
||||
setHtml(overlay, `
|
||||
<div class="budget-inline-modal__panel" role="dialog" aria-modal="true" aria-label="${esc(title)}">
|
||||
<div class="budget-inline-modal__header">
|
||||
<strong>${esc(title)}</strong>
|
||||
<button class="btn btn--icon" type="button" data-action="inline-cancel" aria-label="${t('common.cancel')}">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="budget-inline-name">${esc(label)}</label>
|
||||
<input class="form-input" id="budget-inline-name" type="text" placeholder="${esc(placeholder)}">
|
||||
</div>
|
||||
<div class="budget-inline-modal__footer">
|
||||
<button class="btn btn--secondary" type="button" data-action="inline-cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn--primary" type="button" data-action="inline-save">${t('common.add')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
panel.append(overlay);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
const input = overlay.querySelector('#budget-inline-name');
|
||||
const cleanup = (value = '') => {
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
};
|
||||
overlay.querySelectorAll('[data-action="inline-cancel"]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => cleanup(''));
|
||||
});
|
||||
overlay.querySelector('[data-action="inline-save"]').addEventListener('click', () => {
|
||||
cleanup(input.value.trim());
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') cleanup(input.value.trim());
|
||||
if (e.key === 'Escape') cleanup('');
|
||||
});
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLoanFromPanel(panel, saveBtn, { loan = null, closeAfterSave = false } = {}) {
|
||||
const isEdit = Boolean(loan);
|
||||
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);
|
||||
if (closeAfterSave) 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');
|
||||
}
|
||||
}
|
||||
|
||||
function openLoanModal(loan = null) {
|
||||
const isEdit = Boolean(loan);
|
||||
const todayMonth = new Date().toISOString().slice(0, 7);
|
||||
const content = `
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-borrower">${t('budget.loanBorrowerLabel')}</label>
|
||||
<input type="text" class="form-input" id="lm-borrower"
|
||||
placeholder="${t('budget.loanBorrowerPlaceholder')}" value="${esc(loan?.borrower ?? '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-title">${t('budget.loanTitleLabel')}</label>
|
||||
<input type="text" class="form-input" id="lm-title"
|
||||
placeholder="${t('budget.loanTitlePlaceholder')}" value="${esc(loan?.title ?? '')}">
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-amount">${t('budget.loanAmountLabel')}</label>
|
||||
<input type="number" class="form-input" id="lm-amount" step="0.01" min="0.01"
|
||||
inputmode="decimal" value="${loan ? loan.total_amount.toFixed(2) : ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-installments">${t('budget.loanInstallmentsLabel')}</label>
|
||||
<input type="number" class="form-input" id="lm-installments" step="1" min="1" max="240"
|
||||
inputmode="numeric" value="${loan?.installment_count ?? ''}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-start">${t('budget.loanStartMonthLabel')}</label>
|
||||
<input type="month" class="form-input" id="lm-start" value="${esc(loan?.start_month ?? todayMonth)}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="lm-notes">${t('budget.loanNotesLabel')}</label>
|
||||
<textarea class="form-input" id="lm-notes" rows="3">${esc(loan?.notes ?? '')}</textarea>
|
||||
</div>
|
||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||
<div></div>
|
||||
<div style="display:flex;gap:var(--space-3)">
|
||||
<button class="btn btn--secondary" id="lm-cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn--primary" id="lm-save">${isEdit ? t('common.save') : t('budget.createLoan')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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');
|
||||
await saveLoanFromPanel(panel, saveBtn, { loan, closeAfterSave: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 (!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);
|
||||
renderBody();
|
||||
window.oikos?.showToast(t('budget.loanDeletedToast'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// --------------------------------------------------------
|
||||
@@ -685,8 +1274,7 @@ async function deleteEntry(id) {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/budget/${id}`);
|
||||
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
|
||||
state.summary = sumRes.data;
|
||||
await loadMonth(state.month);
|
||||
renderBody();
|
||||
} catch (err) {
|
||||
if (entry) {
|
||||
|
||||
+304
-28
@@ -113,7 +113,47 @@ function showOnboarding(appContainer) {
|
||||
// NEU — primäre Inhalte (tasks, calendar) ganz oben
|
||||
const WIDGET_IDS = ['tasks', 'calendar', 'weather', 'meals', 'shopping', 'birthdays', 'budget', 'family', 'notes'];
|
||||
|
||||
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id, i) => ({ id, visible: true, order: i }));
|
||||
const WIDGET_SIZE_OPTIONS = ['1x1', '1x2', '1x3', '1x4', '2x1', '2x2', '2x3', '2x4', '3x1', '3x2', '3x3', '3x4', '4x1', '4x2', '4x3', '4x4'];
|
||||
|
||||
function widgetSizeLabel(size) {
|
||||
return size;
|
||||
}
|
||||
|
||||
function defaultWidgetSize(id) {
|
||||
if (['tasks', 'calendar'].includes(id)) return '2x2';
|
||||
if (['weather', 'shopping'].includes(id)) return '2x1';
|
||||
if (id === 'notes') return '2x1';
|
||||
return '1x1';
|
||||
}
|
||||
|
||||
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id, i) => ({ id, visible: true, order: i, size: defaultWidgetSize(id) }));
|
||||
|
||||
function normalizeDashboardConfig(input) {
|
||||
const valid = Array.isArray(input)
|
||||
? input
|
||||
.filter((w) => w && typeof w === 'object' && WIDGET_IDS.includes(w.id))
|
||||
.map((w, i) => ({
|
||||
id: w.id,
|
||||
visible: w.visible !== false,
|
||||
order: Number.isFinite(Number(w.order)) ? Number(w.order) : i,
|
||||
size: WIDGET_SIZE_OPTIONS.includes(w.size) ? w.size : defaultWidgetSize(w.id),
|
||||
}))
|
||||
: [];
|
||||
const presentIds = new Set(valid.map((w) => w.id));
|
||||
for (const id of WIDGET_IDS) {
|
||||
if (!presentIds.has(id)) {
|
||||
valid.push({ id, visible: true, order: valid.length, size: defaultWidgetSize(id) });
|
||||
}
|
||||
}
|
||||
return valid
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
|
||||
function setHtml(element, html) {
|
||||
element.replaceChildren();
|
||||
element.insertAdjacentHTML('afterbegin', html);
|
||||
}
|
||||
|
||||
function widgetLabel(id) {
|
||||
const map = {
|
||||
@@ -523,7 +563,7 @@ function renderQuickAction({ route, label, icon, tone = '' }) {
|
||||
}
|
||||
|
||||
|
||||
function renderDashboardOverview(user) {
|
||||
function renderDashboardOverview(user, editing = false) {
|
||||
const dateLabel = formatDate(new Date());
|
||||
|
||||
const actions = [
|
||||
@@ -541,10 +581,21 @@ function renderDashboardOverview(user) {
|
||||
<h1 class="dashboard-overview__title">${greeting(user.display_name)}</h1>
|
||||
</div>
|
||||
<div class="dashboard-overview__tools">
|
||||
<div class="dashboard-overview__actions">${actions}</div>
|
||||
${editing ? `
|
||||
<div class="dashboard-customize-toolbar" role="toolbar" aria-label="${t('dashboard.customizeTitle')}">
|
||||
<button class="btn btn--secondary" id="dashboard-manage-widgets">
|
||||
<i data-lucide="sliders-horizontal" aria-hidden="true"></i>
|
||||
${t('dashboard.customizeManage')}
|
||||
</button>
|
||||
<button class="btn btn--ghost" id="dashboard-customize-reset">${t('dashboard.customizeReset')}</button>
|
||||
<button class="btn btn--secondary" id="dashboard-customize-cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn--primary" id="dashboard-customize-save">${t('common.save')}</button>
|
||||
</div>` : `<div class="dashboard-overview__actions">${actions}</div>`}
|
||||
<button class="dashboard-icon-btn" id="dashboard-customize-btn"
|
||||
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
|
||||
<i data-lucide="settings-2" aria-hidden="true"></i>
|
||||
aria-label="${editing ? t('dashboard.customizeExit') : t('dashboard.customize')}"
|
||||
title="${editing ? t('dashboard.customizeExit') : t('dashboard.customize')}"
|
||||
aria-pressed="${editing ? 'true' : 'false'}">
|
||||
<i data-lucide="${editing ? 'x' : 'settings-2'}" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,17 +603,48 @@ function renderDashboardOverview(user) {
|
||||
`;
|
||||
}
|
||||
|
||||
function widgetTileClass(id) {
|
||||
// Primär: immer 2 Spalten breit (die wichtigsten Inhalte)
|
||||
const primaryIds = ['tasks', 'calendar'];
|
||||
// Sekundär: 2 Spalten ab 3-Spalten-Breakpoint (1024px)
|
||||
const secondaryIds = ['weather', 'shopping'];
|
||||
if (primaryIds.includes(id)) return 'widget--wide';
|
||||
if (secondaryIds.includes(id)) return 'widget--secondary';
|
||||
return '';
|
||||
function widgetSizeClass(size) {
|
||||
return WIDGET_SIZE_OPTIONS.includes(size) ? `widget-size--${size}` : 'widget-size--1x1';
|
||||
}
|
||||
|
||||
function renderDashboardLayout(cfg, data, weather, currency) {
|
||||
function renderSizeMiniGrid(size) {
|
||||
return `<span class="widget-size-mini" aria-hidden="true">${renderSizeMiniGridCells(size)}</span>`;
|
||||
}
|
||||
|
||||
function renderSizeMiniGridCells(size) {
|
||||
const [cols, rows] = size.split('x').map(Number);
|
||||
return Array.from({ length: 16 }, (_, i) => {
|
||||
const col = (i % 4) + 1;
|
||||
const row = Math.floor(i / 4) + 1;
|
||||
return `<span class="${col <= cols && row <= rows ? 'is-active' : ''}"></span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderWidgetCustomizeControls(w) {
|
||||
const sizeOptions = WIDGET_SIZE_OPTIONS.map((size) => `
|
||||
<option value="${size}" ${w.size === size ? 'selected' : ''}>${widgetSizeLabel(size)}</option>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="widget-edit-controls" data-widget-controls>
|
||||
<button type="button" class="widget-edit-controls__handle" data-widget-drag-handle aria-label="${t('dashboard.customizeDrag')}">
|
||||
<i data-lucide="grip-vertical" aria-hidden="true"></i>
|
||||
</button>
|
||||
<label class="widget-edit-controls__size">
|
||||
<span>${t('dashboard.customizeSize')}</span>
|
||||
${renderSizeMiniGrid(w.size)}
|
||||
<select class="widget-edit-controls__select" data-widget-size="${esc(w.id)}" aria-label="${t('dashboard.customizeSizeFor', { widget: widgetLabel(w.id) })}">
|
||||
${sizeOptions}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="widget-edit-controls__hide" data-widget-hide="${esc(w.id)}" aria-label="${t('dashboard.customizeHide', { widget: widgetLabel(w.id) })}">
|
||||
<i data-lucide="eye-off" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboardLayout(cfg, data, weather, currency, { editing = false } = {}) {
|
||||
const widgetById = {
|
||||
tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
|
||||
calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
|
||||
@@ -580,11 +662,15 @@ function renderDashboardLayout(cfg, data, weather, currency) {
|
||||
.map((w) => {
|
||||
const html = widgetById[w.id]();
|
||||
if (!html) return '';
|
||||
return `<div class="widget-wrapper ${widgetTileClass(w.id)}">${html}</div>`;
|
||||
return `<div class="widget-wrapper ${widgetSizeClass(w.size)} ${editing ? 'widget-wrapper--editing' : ''}"
|
||||
data-widget-id="${esc(w.id)}" ${editing ? 'draggable="true"' : ''}>
|
||||
${editing ? renderWidgetCustomizeControls(w) : ''}
|
||||
${html}
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<div class="dashboard__grid">${tiles}</div>`;
|
||||
return `<div class="dashboard__grid ${editing ? 'dashboard__grid--editing' : ''}" id="dashboard-widget-grid">${tiles}</div>`;
|
||||
}
|
||||
|
||||
function renderDashboardSkeleton() {
|
||||
@@ -800,6 +886,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) => `
|
||||
<option value="${size}" ${w.size === size ? 'selected' : ''}>${widgetSizeLabel(size)}</option>
|
||||
`).join('');
|
||||
return `
|
||||
<div class="customize-row" data-id="${esc(w.id)}" style="view-transition-name: widget-row-${esc(w.id)}">
|
||||
<label class="customize-row__toggle">
|
||||
@@ -809,6 +898,13 @@ function openCustomizeModal(currentConfig, onSave) {
|
||||
</label>
|
||||
<i data-lucide="${widgetIcon(w.id)}" class="customize-row__icon" aria-hidden="true"></i>
|
||||
<span class="customize-row__name">${widgetLabel(w.id)}</span>
|
||||
<label class="customize-row__size">
|
||||
<span>${t('dashboard.customizeSize')}</span>
|
||||
${renderSizeMiniGrid(w.size)}
|
||||
<select class="form-input customize-row__select" data-size-id="${esc(w.id)}" aria-label="${t('dashboard.customizeSizeFor', { widget: widgetLabel(w.id) })}">
|
||||
${sizeOptions}
|
||||
</select>
|
||||
</label>
|
||||
<div class="customize-row__actions">
|
||||
<button class="customize-row__btn" data-move="up" data-id="${w.id}"
|
||||
${isFirst ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveUp')}">
|
||||
@@ -880,6 +976,19 @@ function openCustomizeModal(currentConfig, onSave) {
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('[data-size-id]').forEach((select) => {
|
||||
select.addEventListener('change', () => {
|
||||
const entry = draft.find((w) => w.id === select.dataset.sizeId);
|
||||
if (!entry || !WIDGET_SIZE_OPTIONS.includes(select.value)) return;
|
||||
entry.size = select.value;
|
||||
const mini = select.closest('.customize-row__size')?.querySelector('.widget-size-mini');
|
||||
if (mini) {
|
||||
mini.replaceChildren();
|
||||
mini.insertAdjacentHTML('afterbegin', renderSizeMiniGridCells(select.value));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.customize-row').forEach((row, idx) => {
|
||||
row.setAttribute('draggable', 'true');
|
||||
row.addEventListener('dragstart', (e) => {
|
||||
@@ -975,9 +1084,10 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
|
||||
// Navigations-Links verdrahten
|
||||
// --------------------------------------------------------
|
||||
|
||||
function wireLinks(container, rerender) {
|
||||
function wireLinks(container, rerender, { editing = false } = {}) {
|
||||
container.querySelectorAll('[data-route]').forEach((el) => {
|
||||
if (el.id === 'fab-main' || el.closest('#fab-actions')) return;
|
||||
if (editing && el.closest('.widget-wrapper--editing')) return;
|
||||
const go = () => window.oikos.navigate(el.dataset.route);
|
||||
if (el.tagName === 'A') {
|
||||
el.addEventListener('click', (e) => { e.preventDefault(); go(); });
|
||||
@@ -990,6 +1100,7 @@ function wireLinks(container, rerender) {
|
||||
});
|
||||
|
||||
// Task-Items öffnen Quick-Action-Modal statt direkt zu navigieren
|
||||
if (editing) return;
|
||||
container.querySelectorAll('.task-item[data-task-id]').forEach((el) => {
|
||||
const show = () => openTaskQuickAction(el.dataset.taskId, el.dataset.taskTitle, rerender);
|
||||
el.addEventListener('click', show);
|
||||
@@ -999,6 +1110,52 @@ function wireLinks(container, rerender) {
|
||||
});
|
||||
}
|
||||
|
||||
function reorderWidgetConfig(config, fromId, toId, placement = 'before') {
|
||||
const fromIdx = config.findIndex((w) => w.id === fromId);
|
||||
let toIdx = config.findIndex((w) => w.id === toId);
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return config;
|
||||
const next = config.map((w) => ({ ...w }));
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
if (fromIdx < toIdx) toIdx -= 1;
|
||||
if (placement === 'after') toIdx += 1;
|
||||
next.splice(toIdx, 0, moved);
|
||||
return next.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
|
||||
function closestWidgetDrop(grid, event, draggedId) {
|
||||
const candidates = [...grid.querySelectorAll('.widget-wrapper[data-widget-id]')]
|
||||
.filter((item) => item.dataset.widgetId !== draggedId);
|
||||
if (!candidates.length) return null;
|
||||
|
||||
let nearest = null;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const item of candidates) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const dx = event.clientX - centerX;
|
||||
const dy = event.clientY - centerY;
|
||||
const distance = (dy * dy * 1.7) + (dx * dx);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearest = { item, rect };
|
||||
}
|
||||
}
|
||||
if (!nearest) return null;
|
||||
|
||||
const sameRow = event.clientY >= nearest.rect.top && event.clientY <= nearest.rect.bottom;
|
||||
const placement = sameRow
|
||||
? (event.clientX > nearest.rect.left + nearest.rect.width / 2 ? 'after' : 'before')
|
||||
: (event.clientY > nearest.rect.top + nearest.rect.height / 2 ? 'after' : 'before');
|
||||
|
||||
return { id: nearest.item.dataset.widgetId, placement, item: nearest.item };
|
||||
}
|
||||
|
||||
function updateWidgetConfig(config, id, patch) {
|
||||
return config.map((w) => w.id === id ? { ...w, ...patch } : w)
|
||||
.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Haupt-Render
|
||||
// --------------------------------------------------------
|
||||
@@ -1007,7 +1164,7 @@ export async function render(container, { user }) {
|
||||
_fabController?.abort();
|
||||
_fabController = new AbortController();
|
||||
|
||||
container.innerHTML = `
|
||||
setHtml(container, `
|
||||
<div class="dashboard">
|
||||
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
||||
<div class="dashboard-shell" id="dashboard-shell">
|
||||
@@ -1015,11 +1172,13 @@ export async function render(container, { user }) {
|
||||
</div>
|
||||
</div>
|
||||
${renderFab()}
|
||||
`;
|
||||
`);
|
||||
|
||||
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [], birthdays: [], users: [], budget: {} };
|
||||
let weather = null;
|
||||
let widgetConfig = DEFAULT_WIDGET_CONFIG;
|
||||
let savedWidgetConfig = DEFAULT_WIDGET_CONFIG;
|
||||
let isCustomizing = false;
|
||||
let currency = 'EUR';
|
||||
try {
|
||||
const [dashRes, weatherRes, prefsRes] = await Promise.all([
|
||||
@@ -1029,8 +1188,8 @@ export async function render(container, { user }) {
|
||||
]);
|
||||
data = dashRes;
|
||||
weather = weatherRes.data ?? null;
|
||||
const raw = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG;
|
||||
widgetConfig = raw.map((w, i) => ({ order: i, ...w })).sort((a, b) => a.order - b.order);
|
||||
widgetConfig = normalizeDashboardConfig(prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG);
|
||||
savedWidgetConfig = widgetConfig.map((w) => ({ ...w }));
|
||||
currency = prefsRes.data?.currency ?? 'EUR';
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network');
|
||||
@@ -1039,26 +1198,139 @@ export async function render(container, { user }) {
|
||||
|
||||
const rerender = () => render(container, { user });
|
||||
|
||||
async function saveDashboardConfig() {
|
||||
try {
|
||||
await api.put('/preferences', { dashboard_widgets: widgetConfig });
|
||||
savedWidgetConfig = widgetConfig.map((w) => ({ ...w }));
|
||||
isCustomizing = false;
|
||||
rebuildDashboard(widgetConfig);
|
||||
window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500);
|
||||
} catch {
|
||||
window.oikos?.showToast(t('common.errorGeneric'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDashboardConfig() {
|
||||
widgetConfig = savedWidgetConfig.map((w) => ({ ...w }));
|
||||
isCustomizing = false;
|
||||
rebuildDashboard(widgetConfig);
|
||||
}
|
||||
|
||||
function resetDashboardConfig() {
|
||||
widgetConfig = DEFAULT_WIDGET_CONFIG.map((w) => ({ ...w }));
|
||||
rebuildDashboard(widgetConfig);
|
||||
}
|
||||
|
||||
function wireDashboardEditMode() {
|
||||
if (!isCustomizing) return;
|
||||
const grid = container.querySelector('#dashboard-widget-grid');
|
||||
if (!grid) return;
|
||||
let draggedId = '';
|
||||
let currentDrop = null;
|
||||
|
||||
const clearDropHint = () => {
|
||||
grid.querySelectorAll('.widget-wrapper--drop-before, .widget-wrapper--drop-after').forEach((el) => {
|
||||
el.classList.remove('widget-wrapper--drop-before', 'widget-wrapper--drop-after');
|
||||
});
|
||||
};
|
||||
|
||||
const updateDropHint = (event) => {
|
||||
if (!draggedId) return null;
|
||||
clearDropHint();
|
||||
currentDrop = closestWidgetDrop(grid, event, draggedId);
|
||||
if (currentDrop) {
|
||||
currentDrop.item.classList.add(currentDrop.placement === 'after' ? 'widget-wrapper--drop-after' : 'widget-wrapper--drop-before');
|
||||
}
|
||||
return currentDrop;
|
||||
};
|
||||
|
||||
grid.querySelectorAll('.widget-wrapper[data-widget-id]').forEach((wrapper) => {
|
||||
wrapper.addEventListener('dragstart', (event) => {
|
||||
draggedId = wrapper.dataset.widgetId;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', draggedId);
|
||||
wrapper.classList.add('widget-wrapper--dragging');
|
||||
});
|
||||
wrapper.addEventListener('dragend', () => {
|
||||
draggedId = '';
|
||||
wrapper.classList.remove('widget-wrapper--dragging');
|
||||
currentDrop = null;
|
||||
clearDropHint();
|
||||
});
|
||||
});
|
||||
|
||||
grid.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
updateDropHint(event);
|
||||
});
|
||||
|
||||
grid.addEventListener('dragleave', (event) => {
|
||||
if (!grid.contains(event.relatedTarget)) {
|
||||
currentDrop = null;
|
||||
clearDropHint();
|
||||
}
|
||||
});
|
||||
|
||||
grid.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
const fromId = event.dataTransfer.getData('text/plain') || draggedId;
|
||||
const drop = currentDrop || updateDropHint(event);
|
||||
if (fromId && drop) {
|
||||
widgetConfig = reorderWidgetConfig(widgetConfig, fromId, drop.id, drop.placement);
|
||||
rebuildDashboard(widgetConfig);
|
||||
}
|
||||
});
|
||||
|
||||
grid.querySelectorAll('[data-widget-size]').forEach((select) => {
|
||||
select.addEventListener('change', () => {
|
||||
if (!WIDGET_SIZE_OPTIONS.includes(select.value)) return;
|
||||
widgetConfig = updateWidgetConfig(widgetConfig, select.dataset.widgetSize, { size: select.value });
|
||||
rebuildDashboard(widgetConfig);
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll('[data-widget-hide]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
widgetConfig = updateWidgetConfig(widgetConfig, btn.dataset.widgetHide, { visible: false });
|
||||
rebuildDashboard(widgetConfig);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildDashboard(cfg) {
|
||||
const shell = container.querySelector('#dashboard-shell');
|
||||
if (!shell) return;
|
||||
shell.replaceChildren();
|
||||
shell.insertAdjacentHTML('beforeend', `
|
||||
${renderDashboardOverview(user)}
|
||||
${renderDashboardLayout(cfg, data, weather, currency)}
|
||||
setHtml(shell, `
|
||||
${renderDashboardOverview(user, isCustomizing)}
|
||||
${renderDashboardLayout(cfg, data, weather, currency, { editing: isCustomizing })}
|
||||
`);
|
||||
wireLinks(container, rerender);
|
||||
wireLinks(container, rerender, { editing: isCustomizing });
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
wireWeatherRefresh(container, (updatedWeather) => {
|
||||
weather = updatedWeather;
|
||||
rebuildDashboard(cfg);
|
||||
});
|
||||
container.querySelector('#dashboard-customize-btn')?.addEventListener('click', () => {
|
||||
isCustomizing = !isCustomizing;
|
||||
if (!isCustomizing) {
|
||||
cancelDashboardConfig();
|
||||
return;
|
||||
}
|
||||
rebuildDashboard(widgetConfig);
|
||||
}, { signal: _fabController.signal });
|
||||
container.querySelector('#dashboard-manage-widgets')?.addEventListener('click', () => {
|
||||
openCustomizeModal(widgetConfig, (newConfig) => {
|
||||
widgetConfig = newConfig;
|
||||
widgetConfig = normalizeDashboardConfig(newConfig);
|
||||
savedWidgetConfig = widgetConfig.map((w) => ({ ...w }));
|
||||
isCustomizing = false;
|
||||
rebuildDashboard(widgetConfig);
|
||||
});
|
||||
}, { signal: _fabController.signal });
|
||||
container.querySelector('#dashboard-customize-save')?.addEventListener('click', saveDashboardConfig, { signal: _fabController.signal });
|
||||
container.querySelector('#dashboard-customize-cancel')?.addEventListener('click', cancelDashboardConfig, { signal: _fabController.signal });
|
||||
container.querySelector('#dashboard-customize-reset')?.addEventListener('click', resetDashboardConfig, { signal: _fabController.signal });
|
||||
wireDashboardEditMode();
|
||||
}
|
||||
|
||||
rebuildDashboard(widgetConfig);
|
||||
@@ -1094,7 +1366,11 @@ function wireWeatherRefresh(container, onUpdated = null) {
|
||||
const res = await api.get('/weather').catch(() => ({ data: null }));
|
||||
const wWidget = container.querySelector('#weather-widget');
|
||||
if (wWidget) {
|
||||
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
|
||||
const wrapper = wWidget.closest('.widget-wrapper');
|
||||
if (wrapper) {
|
||||
wrapper.querySelector('.widget')?.remove();
|
||||
wrapper.insertAdjacentHTML('beforeend', renderWeatherWidget(res.data ?? null));
|
||||
}
|
||||
const newWidget = container.querySelector('#weather-widget');
|
||||
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
||||
onUpdated?.(res.data ?? null);
|
||||
|
||||
@@ -338,8 +338,12 @@ export async function render(container, { user }) {
|
||||
<label class="form-label" for="date-format-select">${t('settings.dateFormatLabel')}</label>
|
||||
<select class="form-input" id="date-format-select">
|
||||
<option value="mdy"${prefs.date_format === 'mdy' ? ' selected' : ''}>MM/DD/YYYY</option>
|
||||
<option value="dmy"${prefs.date_format === 'dmy' ? ' selected' : ''}>DD/MM/YYYY</option>
|
||||
<option value="dmy"${prefs.date_format === 'dmy' ? ' selected' : ''}>DD.MM.YYYY</option>
|
||||
<option value="dmy_slash"${prefs.date_format === 'dmy_slash' ? ' selected' : ''}>DD/MM/YYYY</option>
|
||||
<option value="ymd"${prefs.date_format === 'ymd' ? ' selected' : ''}>YYYY-MM-DD</option>
|
||||
<option value="mdy_dot"${prefs.date_format === 'mdy_dot' ? ' selected' : ''}>MM.DD.YYYY</option>
|
||||
<option value="ymd_dot"${prefs.date_format === 'ymd_dot' ? ' selected' : ''}>YYYY.MM.DD</option>
|
||||
<option value="ymd_slash"${prefs.date_format === 'ymd_slash' ? ' selected' : ''}>YYYY/MM/DD</option>
|
||||
</select>
|
||||
<label class="form-label" for="time-format-select" style="margin-top:var(--space-3)">${t('settings.timeFormatLabel')}</label>
|
||||
<select class="form-input" id="time-format-select">
|
||||
|
||||
+517
-1
@@ -25,6 +25,21 @@
|
||||
.budget-page { height: 100dvh; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.budget-nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.budget-nav__label {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.budget-tabs {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Monat-Navigation
|
||||
* -------------------------------------------------------- */
|
||||
@@ -58,6 +73,56 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.budget-tab {
|
||||
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-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.budget-tab--active {
|
||||
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;
|
||||
}
|
||||
|
||||
.budget-tab-panel--budget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-tab-panel--loans {
|
||||
overflow-y: auto;
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Zusammenfassungs-Karten
|
||||
* -------------------------------------------------------- */
|
||||
@@ -176,6 +241,352 @@
|
||||
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__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 i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 4px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.budget-loans__filter--active {
|
||||
background: var(--color-info);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.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: min(58dvh, 640px);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.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);
|
||||
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 {
|
||||
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__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.budget-loan-card__filter {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.budget-loan-card__filter:hover {
|
||||
color: var(--module-accent);
|
||||
border-color: var(--module-accent);
|
||||
}
|
||||
|
||||
.budget-loan-card__filter--active {
|
||||
color: var(--color-text-on-accent);
|
||||
border-color: var(--color-info);
|
||||
background: var(--color-info);
|
||||
}
|
||||
|
||||
.budget-loan-card__filter i {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.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-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));
|
||||
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
|
||||
* -------------------------------------------------------- */
|
||||
@@ -185,6 +596,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--color-border);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.budget-list-header {
|
||||
@@ -196,6 +608,20 @@
|
||||
border-top: 3px solid var(--module-accent);
|
||||
}
|
||||
|
||||
.budget-list-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.budget-list-header__filter {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.budget-list-header__title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
@@ -206,10 +632,39 @@
|
||||
|
||||
.budget-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.budget-list,
|
||||
.budget-loans__list,
|
||||
.budget-tab-panel--loans {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--module-accent) transparent;
|
||||
}
|
||||
|
||||
.budget-list::-webkit-scrollbar,
|
||||
.budget-loans__list::-webkit-scrollbar,
|
||||
.budget-tab-panel--loans::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.budget-list::-webkit-scrollbar-track,
|
||||
.budget-loans__list::-webkit-scrollbar-track,
|
||||
.budget-tab-panel--loans::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.budget-list::-webkit-scrollbar-thumb,
|
||||
.budget-loans__list::-webkit-scrollbar-thumb,
|
||||
.budget-tab-panel--loans::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--module-accent) 55%, transparent);
|
||||
border: 3px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.budget-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -279,6 +734,13 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.budget-entry__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.budget-entry__delete::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -289,6 +751,10 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.budget-entry--loan .budget-entry__delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.budget-entry__delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
@@ -301,13 +767,17 @@
|
||||
/* Einnahme/Ausgabe-Toggle */
|
||||
.amount-type-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
background-color: var(--color-surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-0h);
|
||||
}
|
||||
|
||||
.amount-type-toggle--entry-only {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.amount-type-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -331,6 +801,16 @@
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.amount-type-btn--loan.amount-type-btn--active {
|
||||
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;
|
||||
@@ -348,3 +828,39 @@
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.budget-inline-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.budget-inline-modal__panel {
|
||||
width: min(100%, 380px);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.budget-inline-modal__header,
|
||||
.budget-inline-modal__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.budget-inline-modal__header {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.budget-inline-modal__footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
+263
-22
@@ -172,10 +172,13 @@
|
||||
.widget-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widget-wrapper > .widget {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -183,29 +186,80 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.widget--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Sekundäre Widgets bei 2 Spalten: 1 Spalte */
|
||||
.widget--secondary {
|
||||
grid-column: span 1;
|
||||
.widget-size--2x1,
|
||||
.widget-size--2x2,
|
||||
.widget-size--2x3,
|
||||
.widget-size--2x4,
|
||||
.widget-size--3x1,
|
||||
.widget-size--3x2,
|
||||
.widget-size--3x3,
|
||||
.widget-size--3x4,
|
||||
.widget-size--4x1,
|
||||
.widget-size--4x2,
|
||||
.widget-size--4x3,
|
||||
.widget-size--4x4 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.dashboard__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: minmax(132px, auto);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.widget--wide {
|
||||
.widget-size--2x1,
|
||||
.widget-size--2x2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* Sekundäre Widgets: 2 Spalten ab 3-Spalten-Grid */
|
||||
.widget--secondary {
|
||||
grid-column: span 2;
|
||||
.widget-size--1x2,
|
||||
.widget-size--1x3,
|
||||
.widget-size--1x4 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.widget-size--3x1,
|
||||
.widget-size--3x2,
|
||||
.widget-size--3x3,
|
||||
.widget-size--3x4,
|
||||
.widget-size--4x1,
|
||||
.widget-size--4x2,
|
||||
.widget-size--4x3,
|
||||
.widget-size--4x4 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.widget-size--1x1,
|
||||
.widget-size--2x1,
|
||||
.widget-size--3x1,
|
||||
.widget-size--4x1 {
|
||||
grid-row: span 1;
|
||||
}
|
||||
|
||||
.widget-size--1x2,
|
||||
.widget-size--2x2,
|
||||
.widget-size--3x2,
|
||||
.widget-size--4x2 {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.widget-size--1x3,
|
||||
.widget-size--2x3,
|
||||
.widget-size--3x3 {
|
||||
grid-row: span 3;
|
||||
}
|
||||
|
||||
.widget-size--1x4,
|
||||
.widget-size--2x4,
|
||||
.widget-size--3x4,
|
||||
.widget-size--4x4 {
|
||||
grid-row: span 4;
|
||||
}
|
||||
|
||||
.widget-size--4x3 {
|
||||
grid-row: span 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,24 +268,43 @@
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
/* Wide-Widgets nehmen 2 von 4 Spalten ein */
|
||||
.widget--wide {
|
||||
.widget-size--1x1,
|
||||
.widget-size--1x2,
|
||||
.widget-size--1x3,
|
||||
.widget-size--1x4 { grid-column: span 1; }
|
||||
.widget-size--2x1,
|
||||
.widget-size--2x2,
|
||||
.widget-size--2x3,
|
||||
.widget-size--2x4 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* Im 4-Spalten-Grid: sekundäre Widgets nur 1 Spalte */
|
||||
.widget--secondary {
|
||||
grid-column: span 1;
|
||||
.widget-size--3x1,
|
||||
.widget-size--3x2,
|
||||
.widget-size--3x3,
|
||||
.widget-size--3x4 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
/* Greeting-Widget über alle 4 Spalten */
|
||||
.widget-greeting {
|
||||
grid-column: 1 / -1;
|
||||
.widget-size--4x1,
|
||||
.widget-size--4x2,
|
||||
.widget-size--4x3,
|
||||
.widget-size--4x4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Primäre Widgets: subtile Akzentlinie oben */
|
||||
.widget-wrapper.widget--wide > .widget {
|
||||
.widget-size--1x2 > .widget,
|
||||
.widget-size--1x3 > .widget,
|
||||
.widget-size--1x4 > .widget,
|
||||
.widget-size--2x2 > .widget,
|
||||
.widget-size--2x3 > .widget,
|
||||
.widget-size--2x4 > .widget,
|
||||
.widget-size--3x2 > .widget,
|
||||
.widget-size--3x3 > .widget,
|
||||
.widget-size--3x4 > .widget,
|
||||
.widget-size--4x2 > .widget,
|
||||
.widget-size--4x3 > .widget,
|
||||
.widget-size--4x4 > .widget {
|
||||
border-top: 2px solid var(--active-module-accent, var(--color-accent));
|
||||
}
|
||||
|
||||
@@ -425,6 +498,8 @@
|
||||
.widget__body {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.widget__empty {
|
||||
@@ -1367,6 +1442,22 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.customize-row__size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.customize-row__select {
|
||||
min-height: 30px;
|
||||
width: 132px;
|
||||
padding: 0 var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.customize-row__actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
@@ -2090,6 +2181,156 @@
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.dashboard-customize-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-customize-toolbar .btn {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.dashboard-customize-toolbar .btn i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dashboard__grid--editing {
|
||||
padding: var(--space-2);
|
||||
border: 1px dashed color-mix(in srgb, var(--module-accent) 48%, var(--color-border));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--module-accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.widget-wrapper--editing {
|
||||
padding-top: 42px;
|
||||
outline: 1px solid color-mix(in srgb, var(--module-accent) 36%, var(--color-border));
|
||||
outline-offset: -1px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.widget-wrapper--editing[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.widget-wrapper--dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.widget-edit-controls {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-surface) 96%, transparent);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.widget-edit-controls__handle,
|
||||
.widget-edit-controls__hide {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.widget-edit-controls__handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.widget-edit-controls__hide {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.widget-edit-controls__handle svg,
|
||||
.widget-edit-controls__hide svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.widget-edit-controls__size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.widget-edit-controls__select {
|
||||
min-width: 132px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.widget-wrapper--drop-before::after,
|
||||
.widget-wrapper--drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--module-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--module-accent) 18%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-wrapper--drop-before::after {
|
||||
left: 4px;
|
||||
top: -7px;
|
||||
right: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.widget-wrapper--drop-after::after {
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
bottom: -7px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.widget-size-mini {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 5px);
|
||||
grid-template-rows: repeat(4, 5px);
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.widget-size-mini span {
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.widget-size-mini .is-active {
|
||||
background: var(--module-accent);
|
||||
}
|
||||
|
||||
.dashboard-kpi-grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+404
-36
@@ -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,87 @@ 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,
|
||||
b.title AS entry_title,
|
||||
b.category AS entry_category,
|
||||
b.subcategory AS entry_subcategory,
|
||||
b.is_recurring AS entry_is_recurring,
|
||||
b.recurrence_parent_id AS entry_recurrence_parent_id
|
||||
FROM budget_loan_payments p
|
||||
LEFT JOIN users u ON u.id = p.created_by
|
||||
LEFT JOIN budget_entries b ON b.id = p.budget_entry_id
|
||||
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;
|
||||
}
|
||||
|
||||
function entryWithLoanMeta(id) {
|
||||
return db.get().prepare(`
|
||||
SELECT b.*, u.display_name AS creator_name,
|
||||
p.id AS loan_payment_id,
|
||||
p.loan_id AS loan_id,
|
||||
p.installment_number AS loan_installment_number,
|
||||
l.title AS loan_title,
|
||||
l.borrower AS loan_borrower
|
||||
FROM budget_entries b
|
||||
LEFT JOIN users u ON u.id = b.created_by
|
||||
LEFT JOIN budget_loan_payments p ON p.budget_entry_id = b.id
|
||||
LEFT JOIN budget_loans l ON l.id = p.loan_id
|
||||
WHERE b.id = ?
|
||||
`).get(id);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Statische Routen vor /:id
|
||||
// --------------------------------------------------------
|
||||
@@ -391,6 +472,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 });
|
||||
@@ -468,21 +783,36 @@ router.get('/', (req, res) => {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 7);
|
||||
const month = req.query.month || today;
|
||||
const loanId = req.query.loan_id ? parseInt(req.query.loan_id, 10) : null;
|
||||
|
||||
if (!MONTH_RE.test(month))
|
||||
if (!loanId && !MONTH_RE.test(month))
|
||||
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
|
||||
|
||||
generateRecurringInstances(db.get(), month);
|
||||
if (!loanId) generateRecurringInstances(db.get(), month);
|
||||
|
||||
const from = `${month}-01`;
|
||||
const to = `${month}-31`;
|
||||
let sql = `
|
||||
SELECT b.*, u.display_name AS creator_name
|
||||
SELECT b.*, u.display_name AS creator_name,
|
||||
p.id AS loan_payment_id,
|
||||
p.loan_id AS loan_id,
|
||||
p.installment_number AS loan_installment_number,
|
||||
l.title AS loan_title,
|
||||
l.borrower AS loan_borrower
|
||||
FROM budget_entries b
|
||||
LEFT JOIN users u ON u.id = b.created_by
|
||||
WHERE b.date BETWEEN ? AND ?
|
||||
LEFT JOIN budget_loan_payments p ON p.budget_entry_id = b.id
|
||||
LEFT JOIN budget_loans l ON l.id = p.loan_id
|
||||
`;
|
||||
const params = [from, to];
|
||||
const params = [];
|
||||
|
||||
if (loanId) {
|
||||
sql += ' WHERE p.loan_id = ?';
|
||||
params.push(loanId);
|
||||
} else {
|
||||
sql += ' WHERE b.date BETWEEN ? AND ?';
|
||||
params.push(from, to);
|
||||
}
|
||||
|
||||
if (req.query.category && validCategoryKeys().includes(req.query.category)) {
|
||||
sql += ' AND b.category = ?';
|
||||
@@ -529,11 +859,7 @@ router.post('/', (req, res) => {
|
||||
req.session.userId
|
||||
);
|
||||
|
||||
const entry = db.get().prepare(`
|
||||
SELECT b.*, u.display_name AS creator_name
|
||||
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by
|
||||
WHERE b.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
const entry = entryWithLoanMeta(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ data: entry });
|
||||
} catch (err) {
|
||||
@@ -563,6 +889,23 @@ router.put('/:id', (req, res) => {
|
||||
const errors = collectErrors(checks);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
const { title, amount, category, subcategory: requestedSubcategory, date, is_recurring, recurrence_rule } = req.body;
|
||||
const linkedPayment = db.get().prepare(`
|
||||
SELECT * FROM budget_loan_payments WHERE budget_entry_id = ?
|
||||
`).get(id);
|
||||
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)
|
||||
@@ -571,31 +914,45 @@ router.put('/:id', (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid subcategory.', code: 400 });
|
||||
}
|
||||
|
||||
db.get().prepare(`
|
||||
UPDATE budget_entries
|
||||
SET title = COALESCE(?, title),
|
||||
amount = COALESCE(?, amount),
|
||||
category = COALESCE(?, category),
|
||||
subcategory = COALESCE(?, subcategory),
|
||||
date = COALESCE(?, date),
|
||||
is_recurring = COALESCE(?, is_recurring),
|
||||
recurrence_rule = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title?.trim() ?? null,
|
||||
amount !== undefined ? Number(amount) : null,
|
||||
category ?? null,
|
||||
subcategory !== undefined ? subcategory : null,
|
||||
date ?? null,
|
||||
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
|
||||
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
|
||||
id
|
||||
);
|
||||
const tx = db.get().transaction(() => {
|
||||
db.get().prepare(`
|
||||
UPDATE budget_entries
|
||||
SET title = COALESCE(?, title),
|
||||
amount = COALESCE(?, amount),
|
||||
category = COALESCE(?, category),
|
||||
subcategory = COALESCE(?, subcategory),
|
||||
date = COALESCE(?, date),
|
||||
is_recurring = COALESCE(?, is_recurring),
|
||||
recurrence_rule = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title?.trim() ?? null,
|
||||
amount !== undefined ? Number(amount) : null,
|
||||
category ?? null,
|
||||
subcategory !== undefined ? subcategory : null,
|
||||
date ?? null,
|
||||
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
|
||||
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.get().prepare(`
|
||||
SELECT b.*, u.display_name AS creator_name
|
||||
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by WHERE b.id = ?
|
||||
`).get(id);
|
||||
if (linkedPayment) {
|
||||
db.get().prepare(`
|
||||
UPDATE budget_loan_payments
|
||||
SET amount = COALESCE(?, amount),
|
||||
paid_date = COALESCE(?, paid_date)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
amount !== undefined ? cents(amount) : null,
|
||||
date ?? null,
|
||||
linkedPayment.id
|
||||
);
|
||||
refreshLoanStatus(linkedPayment.loan_id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
const updated = entryWithLoanMeta(id);
|
||||
|
||||
res.json({ data: updated });
|
||||
} catch (err) {
|
||||
@@ -615,7 +972,18 @@ router.delete('/:id', (req, res) => {
|
||||
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
|
||||
if (!entry) return res.status(404).json({ error: 'Entry not found', code: 404 });
|
||||
|
||||
db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
|
||||
const linkedPayment = db.get().prepare(`
|
||||
SELECT * FROM budget_loan_payments WHERE budget_entry_id = ?
|
||||
`).get(id);
|
||||
|
||||
const tx = db.get().transaction(() => {
|
||||
if (linkedPayment) {
|
||||
db.get().prepare('DELETE FROM budget_loan_payments WHERE id = ?').run(linkedPayment.id);
|
||||
}
|
||||
db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
|
||||
if (linkedPayment) refreshLoanStatus(linkedPayment.loan_id);
|
||||
});
|
||||
tx();
|
||||
|
||||
// Wenn eine Instanz gelöscht wird: Monat als übersprungen markieren
|
||||
if (entry.recurrence_parent_id) {
|
||||
|
||||
@@ -20,13 +20,26 @@ const VALID_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK'
|
||||
const DEFAULT_CURRENCY = 'EUR';
|
||||
const DEFAULT_APP_NAME = 'Oikos';
|
||||
|
||||
const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd'];
|
||||
const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd', 'mdy_dot', 'dmy_dot', 'dmy_slash', 'ymd_dot', 'ymd_slash'];
|
||||
const DEFAULT_DATE_FORMAT = 'mdy';
|
||||
const VALID_TIME_FORMATS = ['24h', '12h'];
|
||||
const DEFAULT_TIME_FORMAT = '24h';
|
||||
|
||||
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
|
||||
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true })));
|
||||
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'weather', 'meals', 'shopping', 'birthdays', 'budget', 'family', 'notes'];
|
||||
const VALID_WIDGET_SIZES = ['1x1', '1x2', '1x3', '1x4', '2x1', '2x2', '2x3', '2x4', '3x1', '3x2', '3x3', '3x4', '4x1', '4x2', '4x3', '4x4'];
|
||||
|
||||
function defaultWidgetSize(id) {
|
||||
if (['tasks', 'calendar'].includes(id)) return '2x2';
|
||||
if (['weather', 'shopping', 'notes'].includes(id)) return '2x1';
|
||||
return '1x1';
|
||||
}
|
||||
|
||||
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id, order) => ({
|
||||
id,
|
||||
visible: true,
|
||||
order,
|
||||
size: defaultWidgetSize(id),
|
||||
})));
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
@@ -67,15 +80,24 @@ function normalizeWidgetConfig(input) {
|
||||
const valid = Array.isArray(input)
|
||||
? input
|
||||
.filter((w) => w && typeof w === 'object' && VALID_WIDGET_IDS.includes(w.id))
|
||||
.map((w) => ({ id: w.id, visible: Boolean(w.visible) }))
|
||||
.map((w, order) => ({
|
||||
id: w.id,
|
||||
visible: w.visible !== false,
|
||||
order: Number.isFinite(Number(w.order)) ? Number(w.order) : order,
|
||||
size: VALID_WIDGET_SIZES.includes(w.size) ? w.size : defaultWidgetSize(w.id),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Fehlende Widget-IDs am Ende ergänzen
|
||||
const presentIds = new Set(valid.map((w) => w.id));
|
||||
for (const id of VALID_WIDGET_IDS) {
|
||||
if (!presentIds.has(id)) valid.push({ id, visible: true });
|
||||
if (!presentIds.has(id)) {
|
||||
valid.push({ id, visible: true, order: valid.length, size: defaultWidgetSize(id) });
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
return valid
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((w, order) => ({ ...w, order }));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user