diff --git a/Archiwum.zip b/Archiwum.zip new file mode 100644 index 0000000..4b9dcf4 Binary files /dev/null and b/Archiwum.zip differ diff --git a/api/.env.example b/api/.env.example index 9a5f655..f54bd12 100644 --- a/api/.env.example +++ b/api/.env.example @@ -10,7 +10,6 @@ DB_USER=expense_app DB_PASSWORD=expense_app DB_SYNC=true DB_LOGGING=false -APP_NAME=Expense Control DEFAULT_CURRENCY=PLN ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=ChangeMe123! diff --git a/api/package-lock.json b/api/package-lock.json index 1bb2730..7e1d407 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -604,9 +604,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -621,9 +618,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +632,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -655,9 +646,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -672,9 +660,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -689,9 +674,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +688,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +702,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -740,9 +716,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +730,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -774,9 +744,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -791,9 +758,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -808,9 +772,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 029441d..a4b49bd 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -23,7 +23,6 @@ export const env = { DB_PATH: process.env.DB_PATH ?? './data/dev.sqlite', DB_SYNC: toBoolean(process.env.DB_SYNC, true), DB_LOGGING: toBoolean(process.env.DB_LOGGING, false), - APP_NAME: process.env.APP_NAME ?? 'Expense Control', DEFAULT_CURRENCY: process.env.DEFAULT_CURRENCY ?? 'PLN', ADMIN_EMAIL: process.env.ADMIN_EMAIL ?? 'admin@example.com', ADMIN_PASSWORD: process.env.ADMIN_PASSWORD ?? 'Admin123!', diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index cc15eb4..d561c71 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -23,6 +23,7 @@ const loginSchema = z.object({ password: z.string().min(8).max(100) }); +const DEFAULT_APP_NAME = 'Expense Control'; export const publicConfig = async (_req: Request, res: Response) => { const settings = await AppDataSource.getRepository(AppSetting).find({ @@ -31,7 +32,7 @@ export const publicConfig = async (_req: Request, res: Response) => { }); return res.json({ - appName: settings[0]?.appName ?? 'Expense Control', + appName: settings[0]?.appName ?? DEFAULT_APP_NAME, registrationEnabled: settings[0]?.registrationEnabled ?? true }); }; diff --git a/api/src/controllers/merchant.controller.ts b/api/src/controllers/merchant.controller.ts index 7e226fb..927a99f 100644 --- a/api/src/controllers/merchant.controller.ts +++ b/api/src/controllers/merchant.controller.ts @@ -36,7 +36,7 @@ export const listMerchants = async (req: AuthenticatedRequest, res: Response) => export const createMerchant = async (req: AuthenticatedRequest, res: Response) => { const parsed = schema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ message: 'Invalid partner payload', issues: parsed.error.issues }); + return res.status(400).json({ message: 'Invalid merchant payload', issues: parsed.error.issues }); } const user = await userRepo().findOneOrFail({ where: { id: req.user!.id } }); @@ -47,13 +47,13 @@ export const createMerchant = async (req: AuthenticatedRequest, res: Response) = export const updateMerchant = async (req: AuthenticatedRequest, res: Response) => { const parsed = schema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ message: 'Invalid partner payload', issues: parsed.error.issues }); + return res.status(400).json({ message: 'Invalid merchant payload', issues: parsed.error.issues }); } const item = await merchantRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } } }); - if (!item) return res.status(404).json({ message: 'Partner not found' }); + if (!item) return res.status(404).json({ message: 'Merchant not found' }); item.name = parsed.data.name; item.kind = parsed.data.kind; @@ -68,7 +68,7 @@ export const deleteMerchant = async (req: AuthenticatedRequest, res: Response) = const item = await merchantRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } } }); - if (!item) return res.status(404).json({ message: 'Partner not found' }); + if (!item) return res.status(404).json({ message: 'Merchant not found' }); await merchantRepo().remove(item); return res.status(204).send(); diff --git a/api/src/controllers/report.controller.ts b/api/src/controllers/report.controller.ts index b78076a..5c8b543 100644 --- a/api/src/controllers/report.controller.ts +++ b/api/src/controllers/report.controller.ts @@ -29,16 +29,23 @@ const defaultPrefs = (email: string) => ({ categoryIds: [] as string[] }); +const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + const periodRange = (frequency: 'monthly' | 'yearly' | 'threshold') => { const now = new Date(); - const endDate = now.toISOString().slice(0, 10); + const endDate = formatLocalDate(now); if (frequency === 'yearly') { - return { startDate: `${now.getUTCFullYear()}-01-01`, endDate, bucket: 'month' as const, label: 'Year to date' }; + return { startDate: `${now.getFullYear()}-01-01`, endDate, bucket: 'month' as const, label: 'Year to date' }; } - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); - return { startDate: start.toISOString().slice(0, 10), endDate, bucket: 'month' as const, label: 'Current month' }; + const start = new Date(now.getFullYear(), now.getMonth(), 1); + return { startDate: formatLocalDate(start), endDate, bucket: 'month' as const, label: 'Current month' }; }; const buildReportHtml = (title: string, summary: Awaited>) => { diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts index 71f344d..dc62196 100644 --- a/api/src/middleware/auth.ts +++ b/api/src/middleware/auth.ts @@ -4,15 +4,15 @@ import { env } from '../config/env.js'; import type { AuthenticatedRequest } from '../types/express.js'; export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => { const header = req.headers.authorization; - if (!header?.startsWith('Bearer ')) return res.status(401).json({ message: 'Brak tokenu autoryzacji' }); + if (!header?.startsWith('Bearer ')) return res.status(401).json({ message: 'Authorization token is missing' }); try { req.user = jwt.verify(header.replace('Bearer ', ''), env.JWT_SECRET) as { id: string; email: string; role: 'ADMIN' | 'USER' }; return next(); } catch { - return res.status(401).json({ message: 'Nieprawidlowy token' }); + return res.status(401).json({ message: 'Invalid token' }); } }; export const requireAdmin = (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - if (!req.user || req.user.role !== 'ADMIN') return res.status(403).json({ message: 'Wymagane uprawnienia administratora' }); + if (!req.user || req.user.role !== 'ADMIN') return res.status(403).json({ message: 'Administrator access is required' }); return next(); }; diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index e6a3e51..5575873 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -39,7 +39,7 @@ export const createUser = async (input: { defaultCurrency?: string; }) => { const existing = await repo().findOne({ where: { email: input.email.toLowerCase() } }); - if (existing) throw new Error('Email already exists'); + if (existing) throw new Error('Email address is already in use'); const user = repo().create({ fullName: input.fullName, diff --git a/api/src/services/seed.service.ts b/api/src/services/seed.service.ts index 7707575..df76ece 100644 --- a/api/src/services/seed.service.ts +++ b/api/src/services/seed.service.ts @@ -4,6 +4,8 @@ import { AppSetting } from '../entities/AppSetting.js'; import { Category } from '../entities/Category.js'; import { createUser, findUserByEmail } from './auth.service.js'; +const DEFAULT_APP_NAME = 'Expense Control'; + const systemCategories = [ { name: 'Rachunki', color: '#b91c1c' }, { name: 'Zakupy', color: '#2563eb' }, @@ -41,7 +43,7 @@ export const bootstrapData = async () => { if (!settings) { await settingsRepo.save( settingsRepo.create({ - appName: env.APP_NAME, + appName: DEFAULT_APP_NAME, defaultCurrency: env.DEFAULT_CURRENCY, registrationEnabled: true, allowedProofTypes: ['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER'], @@ -52,7 +54,7 @@ export const bootstrapData = async () => { smtpSecure: false, smtpUser: null, smtpPassword: null, - smtpFromName: env.APP_NAME, + smtpFromName: DEFAULT_APP_NAME, smtpFromEmail: env.ADMIN_EMAIL }) ); diff --git a/start_dev.sh b/start_dev.sh index 9b8b74b..b5bc37f 100755 --- a/start_dev.sh +++ b/start_dev.sh @@ -39,7 +39,6 @@ export DB_SYNC=${DB_SYNC:-true} export DB_LOGGING=${DB_LOGGING:-false} export JWT_SECRET=${JWT_SECRET:-dev-secret-key} export JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d} -export APP_NAME=${APP_NAME:-Expense Control Dev} export DEFAULT_CURRENCY=${DEFAULT_CURRENCY:-PLN} export ADMIN_EMAIL=${ADMIN_EMAIL:-admin@local.dev} export ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin123!} diff --git a/web/src/app/core/services/toast.service.ts b/web/src/app/core/services/toast.service.ts index 19d7576..cb8ec9c 100644 --- a/web/src/app/core/services/toast.service.ts +++ b/web/src/app/core/services/toast.service.ts @@ -1,4 +1,5 @@ -import { Injectable, signal } from '@angular/core'; +import { Injectable, inject, signal } from '@angular/core'; +import { UiService } from './ui.service'; export type ToastItem = { id: number; @@ -9,6 +10,7 @@ export type ToastItem = { @Injectable({ providedIn: 'root' }) export class ToastService { + private readonly ui = inject(UiService); readonly items = signal([]); private counter = 0; @@ -22,23 +24,29 @@ export class ToastService { this.items.update((items) => items.filter((item) => item.id !== id)); } - success(message: string, title = 'Gotowe') { + success(message: string, title = this.ui.t('toast.ready')) { this.show(message, 'success', title); } - error(message: string, title = 'Błąd') { + error(message: string, title = this.ui.t('toast.error')) { this.show(message, 'danger', title); } - warning(message: string, title = 'Uwaga') { + warning(message: string, title = this.ui.t('toast.warning')) { this.show(message, 'warning', title); } - info(message: string, title = 'Informacja') { + info(message: string, title = this.ui.t('toast.info')) { this.show(message, 'info', title); } private defaultTitle(tone: ToastItem['tone']) { - return tone === 'success' ? 'Gotowe' : tone === 'danger' ? 'Błąd' : tone === 'warning' ? 'Uwaga' : 'Informacja'; + return tone === 'success' + ? this.ui.t('toast.ready') + : tone === 'danger' + ? this.ui.t('toast.error') + : tone === 'warning' + ? this.ui.t('toast.warning') + : this.ui.t('toast.info'); } } diff --git a/web/src/app/core/services/ui.service.ts b/web/src/app/core/services/ui.service.ts index be7c8a0..de27806 100644 --- a/web/src/app/core/services/ui.service.ts +++ b/web/src/app/core/services/ui.service.ts @@ -14,6 +14,7 @@ const translations: Record> = { 'nav.reports': 'Raporty', 'nav.categories': 'Kategorie', 'nav.admin': 'Administracja', + 'action.logout': 'Wyloguj', 'action.addExpense': 'Dodaj wydatek', 'action.openReports': 'Raporty', @@ -23,38 +24,241 @@ const translations: Record> = { 'action.creatingAccount': 'Tworzenie konta...', 'action.loginMode': 'Logowanie', 'action.registerMode': 'Rejestracja', + 'action.save': 'Zapisz', + 'action.add': 'Dodaj', + 'action.edit': 'Edytuj', + 'action.delete': 'Usuń', + 'action.cancel': 'Anuluj', + 'action.reset': 'Reset', + 'action.show': 'Pokaż', + 'action.filter': 'Filtruj', + 'action.refreshPreview': 'Odśwież podgląd', + 'action.sendNow': 'Wyślij teraz', + 'action.testSmtp': 'Test SMTP', + 'action.saveChanges': 'Zapisz zmiany', + 'action.cancelEdit': 'Anuluj edycję', + 'action.addMerchant': 'Dodaj kontrahenta', + 'action.saveMerchant': 'Zapisz kontrahenta', + 'action.addCategory': 'Dodaj kategorię', + 'action.block': 'Zablokuj', + 'action.unblock': 'Odblokuj', + 'action.setUser': 'Ustaw USER', + 'action.setAdmin': 'Ustaw ADMIN', + 'theme.label': 'Motyw', 'theme.dark': 'Ciemny', 'theme.light': 'Jasny', 'lang.label': 'Język', 'lang.pl': 'Polski', 'lang.en': 'English', + 'login.email': 'E-mail', 'login.password': 'Hasło', 'login.fullName': 'Imię i nazwisko', - 'dashboard.section': 'Panel wydatków', - 'dashboard.subtitle': 'Szybki podgląd wydatków, kontrahentów i raportów SMTP.', + 'login.subtitle': 'Zaloguj się, aby zarządzać wydatkami, kontrahentami i raportami.', + 'register.subtitle': 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.', + 'login.footer': 'Użyj swojego konta, aby zarządzać wydatkami, raportami i uprawnieniami.', + 'register.footer': 'Po utworzeniu konta od razu wrócisz do logowania.', + 'login.needAccount': 'Nie masz konta?', + 'login.haveAccount': 'Masz już konto?', + 'login.error': 'Nie udało się zalogować.', + 'register.success': 'Konto zostało utworzone.', + 'register.error': 'Nie udało się utworzyć konta.', + 'dashboard.total': 'Suma miesiąca', 'dashboard.count': 'Liczba wydatków', 'dashboard.avg': 'Średnia', 'dashboard.top': 'Największa kategoria', 'dashboard.share': 'Udział kategorii', + 'dashboard.shareHint': 'Miesięczny przekrój kosztów według kategorii.', 'dashboard.areas': 'Najmocniejsze obszary kosztów', + 'dashboard.areasHint': 'Najważniejsze obszary kosztowe w aktualnym okresie.', 'dashboard.recent': 'Ostatnie wydatki', + 'dashboard.recentHint': 'Ostatnio dodane pozycje wraz z kontrahentami.', + 'dashboard.noChartData': 'Brak danych do pokazania wykresu kategorii.', + + 'stats.title': 'Statystyki', + 'stats.subtitle': 'Analiza miesięczna, kwartalna i roczna z podziałem na kategorie i zakres dat.', + 'stats.period': 'Okres', + 'stats.period.month': 'Miesięczny', + 'stats.period.quarter': 'Kwartalny', + 'stats.period.year': 'Roczny', + 'stats.from': 'Od', + 'stats.to': 'Do', + 'stats.sum': 'Suma', + 'stats.average': 'Średnia', + 'stats.share': 'Udział kategorii', + 'stats.trend': 'Trend wydatków', + 'stats.breakdown': 'Podział kategorii', + 'stats.noCategoryChart': 'Brak danych do wykresu kategorii.', + 'stats.noTrendChart': 'Brak danych do wykresu trendu.', + 'stats.expensesLabel': 'Wydatki', + + 'expenses.title': 'Wydatki', + 'expenses.subtitle': 'Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.', + 'expenses.new': 'Nowy wydatek', + 'expenses.edit': 'Edytuj wydatek', + 'expenses.requiredHint': 'Uzupełnij wymagane pola oznaczone *.', + 'expenses.field.title': 'Tytuł', + 'expenses.field.amount': 'Kwota', + 'expenses.field.date': 'Data', + 'expenses.field.category': 'Kategoria', + 'expenses.field.payment': 'Płatność', + 'expenses.field.merchantPicker': 'Kontrahent / Sprzedawca', + 'expenses.field.merchantName': 'Nazwa na wydatku', + 'expenses.field.description': 'Opis', + 'expenses.field.proofType': 'Typ potwierdzenia', + 'expenses.field.proofLabel': 'Etykieta', + 'expenses.field.file': 'Plik', + 'expenses.field.proofNote': 'Notatka do potwierdzenia', + 'expenses.field.crop': 'Kadrowanie', + 'expenses.field.cropPreview': 'Podgląd po cropie', + 'expenses.payment.none': 'Brak', + 'expenses.payment.card': 'Karta', + 'expenses.payment.cash': 'Gotówka', + 'expenses.payment.transfer': 'Przelew', + 'expenses.payment.other': 'Inne', + 'expenses.customEntry': 'Własny wpis', + 'expenses.allCategories': 'Wszystkie kategorie', + 'expenses.search': 'Szukaj', + 'expenses.filters': 'Filtry i ostatnie wydatki', + 'expenses.noMerchant': 'Brak kontrahenta', + 'expenses.noItems': 'Brak wydatków do wyświetlenia.', + 'expenses.proof': 'Potwierdzenie', + 'expenses.saving': 'Zapisywanie...', + 'expenses.added': 'Wydatek został dodany.', + 'expenses.saved': 'Wydatek został zapisany.', + 'expenses.deleted': 'Wydatek został usunięty.', + 'expenses.addError': 'Nie udało się dodać wydatku.', + 'expenses.saveError': 'Nie udało się zapisać wydatku.', + 'expenses.deleteError': 'Nie udało się usunąć wydatku.', + 'expenses.validation.title': 'Podaj tytuł wydatku.', + 'expenses.validation.amount': 'Podaj poprawną kwotę większą od 0.', + 'expenses.validation.date': 'Wybierz datę wydatku.', + 'expenses.validation.category': 'Wybierz kategorię.', + + 'proof.receipt': 'Paragon', + 'proof.invoice': 'Faktura', + 'proof.note': 'Notatka', + 'proof.statement': 'Wyciąg', + 'proof.other': 'Inne', + + 'merchant.title': 'Kontrahenci', + 'merchant.subtitle': 'Zapisani sprzedawcy i usługodawcy do szybkiego wyboru przy wydatkach.', + 'merchant.new': 'Nowy kontrahent', + 'merchant.edit': 'Edytuj kontrahenta', + 'merchant.name': 'Nazwa', + 'merchant.type': 'Typ', + 'merchant.notes': 'Notatki', + 'merchant.showOnLists': 'Pokazuj na listach wyboru', + 'merchant.noneSaved': 'Brak zapisanych kontrahentów.', + 'merchant.added': 'Kontrahent został dodany.', + 'merchant.saved': 'Kontrahent został zapisany.', + 'merchant.deleted': 'Kontrahent został usunięty.', + 'merchant.saveError': 'Nie udało się zapisać kontrahenta.', + 'merchant.deleteError': 'Nie udało się usunąć kontrahenta.', + 'merchant.kind.merchant': 'Sprzedawca', + 'merchant.kind.service': 'Usługodawca', + 'merchant.kind.other': 'Inny', + + 'reports.title': 'Raporty', + 'reports.subtitle': 'Skonfiguruj raporty SMTP, podgląd i ręczne wysyłanie podsumowań.', + 'reports.emailTitle': 'Raporty e-mail', + 'reports.enable': 'Włącz raporty', + 'reports.frequency': 'Częstotliwość', + 'reports.frequency.monthly': 'Miesięczna', + 'reports.frequency.yearly': 'Roczna', + 'reports.frequency.threshold': 'Po przekroczeniu progu', + 'reports.targetEmail': 'Adres docelowy', + 'reports.threshold': 'Próg kwotowy', + 'reports.categories': 'Kategorie raportu', + 'reports.preview': 'Podgląd raportu', + 'reports.noData': 'Brak danych raportu.', + 'reports.saved': 'Ustawienia raportów zapisane.', + 'reports.saveError': 'Nie udało się zapisać raportów.', + 'reports.previewError': 'Nie udało się pobrać podglądu.', + 'reports.sendError': 'Nie udało się wysłać raportu.', + 'reports.sentTo': 'Raport wysłano na {email}.', + + 'categories.title': 'Kategorie', + 'categories.subtitle': 'Zarządzaj kategoriami systemowymi i własnymi dla raportów oraz wydatków.', + 'categories.new': 'Nowa kategoria', + 'categories.edit': 'Edytuj kategorię', + 'categories.name': 'Nazwa', + 'categories.color': 'Kolor', + 'categories.type': 'Typ', + 'categories.system': 'Systemowa', + 'categories.custom': 'Własna', + 'categories.saved': 'Kategoria została zapisana.', + 'categories.added': 'Kategoria została dodana.', + 'categories.deleted': 'Kategoria została usunięta.', + 'categories.saveError': 'Nie udało się zapisać kategorii.', + 'categories.deleteError': 'Nie udało się usunąć kategorii.', + + 'admin.title': 'Administracja', + 'admin.subtitle': 'Ustawienia aplikacji, SMTP oraz zarządzanie użytkownikami.', + 'admin.settings': 'Ustawienia aplikacji', + 'admin.appName': 'Nazwa aplikacji', + 'admin.defaultCurrency': 'Domyślna waluta', + 'admin.allowedProofTypes': 'Typy potwierdzeń', + 'admin.registration': 'Włącz rejestrację', + 'admin.smtp': 'SMTP', + 'admin.smtpEnabled': 'Włącz SMTP', + 'admin.host': 'Host', + 'admin.port': 'Port', + 'admin.user': 'Użytkownik', + 'admin.password': 'Hasło', + 'admin.fromName': 'Nazwa nadawcy', + 'admin.fromEmail': 'E-mail nadawcy', + 'admin.secureConnection': 'Bezpieczne połączenie', + 'admin.users': 'Użytkownicy', + 'admin.userLabel': 'Użytkownik', + 'admin.role': 'Rola', + 'admin.status': 'Status', + 'admin.date': 'Data', + 'admin.noUsers': 'Brak użytkowników.', + 'admin.settingsSaved': 'Ustawienia zapisane.', + 'admin.settingsError': 'Nie udało się zapisać ustawień.', + 'admin.missingFromEmail': 'Uzupełnij e-mail nadawcy.', + 'admin.testSent': 'Wiadomość testowa została wysłana.', + 'admin.testError': 'Nie udało się wysłać testu SMTP.', + 'admin.roleUpdated': 'Rola została zaktualizowana.', + 'admin.roleError': 'Nie udało się zmienić roli.', + 'admin.statusUpdated': 'Status konta został zaktualizowany.', + 'admin.statusError': 'Nie udało się zmienić statusu.', + 'common.none': 'Brak', + 'common.select': 'Wybierz', 'common.noData': 'Brak danych.', 'common.noExpenses': 'Brak wydatków.', - 'common.noCategories': 'Brak kategorii.' + 'common.noCategories': 'Brak kategorii.', + 'common.active': 'Aktywny', + 'common.hidden': 'Ukryty', + 'common.blocked': 'Zablokowany', + 'common.selected': 'OK', + + 'table.title': 'Tytuł', + 'table.merchant': 'Kontrahent', + 'table.date': 'Data', + 'table.amount': 'Kwota', + 'table.count': 'Liczba', + 'table.category': 'Kategoria', + + 'toast.ready': 'Gotowe', + 'toast.error': 'Błąd', + 'toast.warning': 'Uwaga', + 'toast.info': 'Informacja' }, en: { 'app.name': 'Expense Control', 'nav.dashboard': 'Dashboard', 'nav.expenses': 'Expenses', 'nav.stats': 'Statistics', - 'nav.merchants': 'Partners', + 'nav.merchants': 'Merchants', 'nav.reports': 'Reports', 'nav.categories': 'Categories', - 'nav.admin': 'Admin', + 'nav.admin': 'Administration', + 'action.logout': 'Sign out', 'action.addExpense': 'Add expense', 'action.openReports': 'Reports', @@ -64,28 +268,230 @@ const translations: Record> = { 'action.creatingAccount': 'Creating account...', 'action.loginMode': 'Sign in', 'action.registerMode': 'Register', + 'action.save': 'Save', + 'action.add': 'Add', + 'action.edit': 'Edit', + 'action.delete': 'Delete', + 'action.cancel': 'Cancel', + 'action.reset': 'Reset', + 'action.show': 'Show', + 'action.filter': 'Filter', + 'action.refreshPreview': 'Refresh preview', + 'action.sendNow': 'Send now', + 'action.testSmtp': 'SMTP test', + 'action.saveChanges': 'Save changes', + 'action.cancelEdit': 'Cancel editing', + 'action.addMerchant': 'Add merchant', + 'action.saveMerchant': 'Save merchant', + 'action.addCategory': 'Add category', + 'action.block': 'Block', + 'action.unblock': 'Unblock', + 'action.setUser': 'Set USER', + 'action.setAdmin': 'Set ADMIN', + 'theme.label': 'Theme', 'theme.dark': 'Dark', 'theme.light': 'Light', 'lang.label': 'Language', 'lang.pl': 'Polish', 'lang.en': 'English', + 'login.email': 'Email', 'login.password': 'Password', 'login.fullName': 'Full name', - 'dashboard.section': 'Expense overview', - 'dashboard.subtitle': 'Fast access to expenses, partners and SMTP reports.', + 'login.subtitle': 'Sign in to manage expenses, merchants and reports.', + 'register.subtitle': 'Create an account and start collecting proofs and analytics.', + 'login.footer': 'Use your account to manage expenses, reports and permissions.', + 'register.footer': 'After creating an account you will be taken back to sign in.', + 'login.needAccount': 'Need an account?', + 'login.haveAccount': 'Already registered?', + 'login.error': 'Sign in failed.', + 'register.success': 'Account created successfully.', + 'register.error': 'Account creation failed.', + 'dashboard.total': 'Month total', 'dashboard.count': 'Expense count', 'dashboard.avg': 'Average', 'dashboard.top': 'Top category', 'dashboard.share': 'Category share', + 'dashboard.shareHint': 'Monthly cost split by category.', 'dashboard.areas': 'Top cost areas', + 'dashboard.areasHint': 'Most important cost areas in the current period.', 'dashboard.recent': 'Recent expenses', + 'dashboard.recentHint': 'Most recently added items with merchants.', + 'dashboard.noChartData': 'No category chart data available.', + + 'stats.title': 'Statistics', + 'stats.subtitle': 'Monthly, quarterly and yearly analysis by category and date range.', + 'stats.period': 'Period', + 'stats.period.month': 'Monthly', + 'stats.period.quarter': 'Quarterly', + 'stats.period.year': 'Yearly', + 'stats.from': 'From', + 'stats.to': 'To', + 'stats.sum': 'Total', + 'stats.average': 'Average', + 'stats.share': 'Category share', + 'stats.trend': 'Expense trend', + 'stats.breakdown': 'Category breakdown', + 'stats.noCategoryChart': 'No category chart data available.', + 'stats.noTrendChart': 'No trend chart data available.', + 'stats.expensesLabel': 'Expenses', + + 'expenses.title': 'Expenses', + 'expenses.subtitle': 'Add expenses, store proofs and pick merchants from the list.', + 'expenses.new': 'New expense', + 'expenses.edit': 'Edit expense', + 'expenses.requiredHint': 'Complete the required fields marked with *.', + 'expenses.field.title': 'Title', + 'expenses.field.amount': 'Amount', + 'expenses.field.date': 'Date', + 'expenses.field.category': 'Category', + 'expenses.field.payment': 'Payment', + 'expenses.field.merchantPicker': 'Merchant / Seller', + 'expenses.field.merchantName': 'Name on expense', + 'expenses.field.description': 'Description', + 'expenses.field.proofType': 'Proof type', + 'expenses.field.proofLabel': 'Label', + 'expenses.field.file': 'File', + 'expenses.field.proofNote': 'Proof note', + 'expenses.field.crop': 'Crop', + 'expenses.field.cropPreview': 'Cropped preview', + 'expenses.payment.none': 'None', + 'expenses.payment.card': 'Card', + 'expenses.payment.cash': 'Cash', + 'expenses.payment.transfer': 'Transfer', + 'expenses.payment.other': 'Other', + 'expenses.customEntry': 'Custom entry', + 'expenses.allCategories': 'All categories', + 'expenses.search': 'Search', + 'expenses.filters': 'Filters and recent expenses', + 'expenses.noMerchant': 'No merchant', + 'expenses.noItems': 'No expenses to display.', + 'expenses.proof': 'Proof', + 'expenses.saving': 'Saving...', + 'expenses.added': 'Expense added successfully.', + 'expenses.saved': 'Expense saved successfully.', + 'expenses.deleted': 'Expense deleted successfully.', + 'expenses.addError': 'Failed to add the expense.', + 'expenses.saveError': 'Failed to save the expense.', + 'expenses.deleteError': 'Failed to delete the expense.', + 'expenses.validation.title': 'Enter an expense title.', + 'expenses.validation.amount': 'Enter a valid amount greater than 0.', + 'expenses.validation.date': 'Select an expense date.', + 'expenses.validation.category': 'Select a category.', + + 'proof.receipt': 'Receipt', + 'proof.invoice': 'Invoice', + 'proof.note': 'Note', + 'proof.statement': 'Statement', + 'proof.other': 'Other', + + 'merchant.title': 'Merchants', + 'merchant.subtitle': 'Saved sellers and service providers for faster expense entry.', + 'merchant.new': 'New merchant', + 'merchant.edit': 'Edit merchant', + 'merchant.name': 'Name', + 'merchant.type': 'Type', + 'merchant.notes': 'Notes', + 'merchant.showOnLists': 'Show in pickers', + 'merchant.noneSaved': 'No merchants saved.', + 'merchant.added': 'Merchant added successfully.', + 'merchant.saved': 'Merchant saved successfully.', + 'merchant.deleted': 'Merchant deleted successfully.', + 'merchant.saveError': 'Failed to save the merchant.', + 'merchant.deleteError': 'Failed to delete the merchant.', + 'merchant.kind.merchant': 'Seller', + 'merchant.kind.service': 'Service provider', + 'merchant.kind.other': 'Other', + + 'reports.title': 'Reports', + 'reports.subtitle': 'Configure SMTP reports, preview them and send summaries manually.', + 'reports.emailTitle': 'Email reports', + 'reports.enable': 'Enable reports', + 'reports.frequency': 'Frequency', + 'reports.frequency.monthly': 'Monthly', + 'reports.frequency.yearly': 'Yearly', + 'reports.frequency.threshold': 'When threshold is exceeded', + 'reports.targetEmail': 'Destination email', + 'reports.threshold': 'Amount threshold', + 'reports.categories': 'Report categories', + 'reports.preview': 'Report preview', + 'reports.noData': 'No report data available.', + 'reports.saved': 'Report settings saved.', + 'reports.saveError': 'Failed to save report settings.', + 'reports.previewError': 'Failed to load the preview.', + 'reports.sendError': 'Failed to send the report.', + 'reports.sentTo': 'Report sent to {email}.', + + 'categories.title': 'Categories', + 'categories.subtitle': 'Manage system and custom categories for reports and expenses.', + 'categories.new': 'New category', + 'categories.edit': 'Edit category', + 'categories.name': 'Name', + 'categories.color': 'Color', + 'categories.type': 'Type', + 'categories.system': 'System', + 'categories.custom': 'Custom', + 'categories.saved': 'Category saved successfully.', + 'categories.added': 'Category added successfully.', + 'categories.deleted': 'Category deleted successfully.', + 'categories.saveError': 'Failed to save the category.', + 'categories.deleteError': 'Failed to delete the category.', + + 'admin.title': 'Administration', + 'admin.subtitle': 'Application settings, SMTP and user management.', + 'admin.settings': 'Application settings', + 'admin.appName': 'Application name', + 'admin.defaultCurrency': 'Default currency', + 'admin.allowedProofTypes': 'Allowed proof types', + 'admin.registration': 'Enable registration', + 'admin.smtp': 'SMTP', + 'admin.smtpEnabled': 'Enable SMTP', + 'admin.host': 'Host', + 'admin.port': 'Port', + 'admin.user': 'User', + 'admin.password': 'Password', + 'admin.fromName': 'Sender name', + 'admin.fromEmail': 'Sender email', + 'admin.secureConnection': 'Secure connection', + 'admin.users': 'Users', + 'admin.userLabel': 'User', + 'admin.role': 'Role', + 'admin.status': 'Status', + 'admin.date': 'Date', + 'admin.noUsers': 'No users found.', + 'admin.settingsSaved': 'Settings saved successfully.', + 'admin.settingsError': 'Failed to save settings.', + 'admin.missingFromEmail': 'Enter the sender email address.', + 'admin.testSent': 'Test message sent successfully.', + 'admin.testError': 'Failed to send the SMTP test.', + 'admin.roleUpdated': 'Role updated successfully.', + 'admin.roleError': 'Failed to change the role.', + 'admin.statusUpdated': 'Account status updated successfully.', + 'admin.statusError': 'Failed to change the account status.', + 'common.none': 'None', + 'common.select': 'Select', 'common.noData': 'No data.', 'common.noExpenses': 'No expenses.', - 'common.noCategories': 'No categories.' + 'common.noCategories': 'No categories.', + 'common.active': 'Active', + 'common.hidden': 'Hidden', + 'common.blocked': 'Blocked', + 'common.selected': 'OK', + + 'table.title': 'Title', + 'table.merchant': 'Merchant', + 'table.date': 'Date', + 'table.amount': 'Amount', + 'table.count': 'Count', + 'table.category': 'Category', + + 'toast.ready': 'Done', + 'toast.error': 'Error', + 'toast.warning': 'Warning', + 'toast.info': 'Information' } }; @@ -123,8 +529,14 @@ export class UiService { this.language.set(language); } - t(key: string) { - return translations[this.language()][key] ?? translations.pl[key] ?? key; + t(key: string, params?: Record) { + let value = translations[this.language()][key] ?? translations.pl[key] ?? key; + if (params) { + Object.entries(params).forEach(([paramKey, paramValue]) => { + value = value.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)); + }); + } + return value; } private readTheme(): UiTheme { diff --git a/web/src/app/features/admin/admin.component.ts b/web/src/app/features/admin/admin.component.ts index d0772c7..ab9cef3 100644 --- a/web/src/app/features/admin/admin.component.ts +++ b/web/src/app/features/admin/admin.component.ts @@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { AdminService } from '../../core/services/admin.service'; import { AppSettingsService } from '../../core/services/app-settings.service'; import { ToastService } from '../../core/services/toast.service'; +import { UiService } from '../../core/services/ui.service'; import type { AppSettings, User } from '../../shared/models'; @Component({ @@ -14,8 +15,8 @@ import type { AppSettings, User } from '../../shared/models'; @@ -23,52 +24,52 @@ import type { AppSettings, User } from '../../shared/models';
-

Ustawienia aplikacji

+

{{ ui.t('admin.settings') }}

- +
-
-
+
+

-
SMTP
+
{{ ui.t('admin.smtp') }}
-
-
-
-
-
-
+
+
+
+
+
+
- +
@@ -78,12 +79,12 @@ import type { AppSettings, User } from '../../shared/models';
-

Użytkownicy

+

{{ ui.t('admin.users') }}

{{ users().length }}
- + @for (user of users(); track user.id) { @@ -94,23 +95,23 @@ import type { AppSettings, User } from '../../shared/models'; } @empty { - + }
UżytkownikRolaStatusData
{{ ui.t('admin.userLabel') }}{{ ui.t('admin.role') }}{{ ui.t('admin.status') }}{{ ui.t('admin.date') }}
{{ user.role }} - {{ user.isActive ? 'Aktywny' : 'Zablokowany' }} + {{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }} {{ user.createdAt | date:'short' }}
Brak użytkowników.
{{ ui.t('admin.noUsers') }}
@@ -121,6 +122,7 @@ import type { AppSettings, User } from '../../shared/models'; ` }) export class AdminComponent implements OnInit { + readonly ui = inject(UiService); private readonly fb = inject(FormBuilder); private readonly admin = inject(AdminService); private readonly appSettings = inject(AppSettingsService); @@ -199,11 +201,11 @@ export class AdminComponent implements OnInit { this.saving.set(false); this.settings.set(response.item); this.appSettings.applySettings(response.item); - this.toast.success('Ustawienia zapisane.'); + this.toast.success(this.ui.t('admin.settingsSaved')); }, error: (error) => { this.saving.set(false); - this.toast.error(error.error?.message ?? 'Nie udało się zapisać ustawień.'); + this.toast.error(error.error?.message ?? this.ui.t('admin.settingsError')); } }); } @@ -211,12 +213,12 @@ export class AdminComponent implements OnInit { sendTest() { const to = this.form.getRawValue().smtpFromEmail; if (!to) { - this.toast.error('Uzupełnij e-mail nadawcy.'); + this.toast.error(this.ui.t('admin.missingFromEmail')); return; } this.admin.testSmtp(to).subscribe({ - next: () => this.toast.success('Wiadomość testowa została wysłana.'), - error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się wysłać testu SMTP.') + next: () => this.toast.success(this.ui.t('admin.testSent')), + error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.testError')) }); } @@ -224,9 +226,9 @@ export class AdminComponent implements OnInit { this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({ next: (response) => { this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item))); - this.toast.success('Rola została zaktualizowana.'); + this.toast.success(this.ui.t('admin.roleUpdated')); }, - error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zmienić roli.') + error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError')) }); } @@ -234,9 +236,9 @@ export class AdminComponent implements OnInit { this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({ next: (response) => { this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item))); - this.toast.success('Status konta został zaktualizowany.'); + this.toast.success(this.ui.t('admin.statusUpdated')); }, - error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zmienić statusu.') + error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError')) }); } } diff --git a/web/src/app/features/auth/login.component.ts b/web/src/app/features/auth/login.component.ts index 05c69fb..932f555 100644 --- a/web/src/app/features/auth/login.component.ts +++ b/web/src/app/features/auth/login.component.ts @@ -76,9 +76,7 @@ import { UiService } from '../../core/services/ui.service'; @if (appSettings.registrationEnabled()) { @@ -133,7 +131,7 @@ export class LoginComponent { this.router.navigate(['/']); }, error: (error) => { - const message = error.error?.message ?? 'Nie udało się zalogować.'; + const message = error.error?.message ?? this.ui.t('login.error'); this.loading.set(false); this.errorMessage.set(message); this.toast.error(message); @@ -146,11 +144,11 @@ export class LoginComponent { next: () => { this.loading.set(false); this.errorMessage.set(null); - this.toast.success('Konto zostało utworzone.'); + this.toast.success(this.ui.t('register.success')); this.mode.set('login'); }, error: (error) => { - const message = error.error?.message ?? 'Nie udało się utworzyć konta.'; + const message = error.error?.message ?? this.ui.t('register.error'); this.loading.set(false); this.errorMessage.set(message); this.toast.error(message); @@ -168,22 +166,18 @@ export class LoginComponent { } loginSubtitle() { - return this.ui.language() === 'pl' - ? 'Zaloguj się, aby zarządzać wydatkami, kontrahentami i raportami.' - : 'Sign in to manage expenses, merchants and reports.'; + return this.ui.t('login.subtitle'); } registerSubtitle() { - return this.ui.language() === 'pl' - ? 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.' - : 'Create an account and start collecting proofs and analytics.'; + return this.ui.t('register.subtitle'); } switchToRegisterLabel() { - return this.ui.language() === 'pl' ? 'Nie masz konta?' : 'Need an account?'; + return this.ui.t('login.needAccount'); } switchToLoginLabel() { - return this.ui.language() === 'pl' ? 'Masz już konto?' : 'Already registered?'; + return this.ui.t('login.haveAccount'); } } diff --git a/web/src/app/features/categories/categories.component.ts b/web/src/app/features/categories/categories.component.ts index 2bdd630..b9ffe54 100644 --- a/web/src/app/features/categories/categories.component.ts +++ b/web/src/app/features/categories/categories.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { CategoriesService } from '../../core/services/categories.service'; import { ToastService } from '../../core/services/toast.service'; +import { UiService } from '../../core/services/ui.service'; import type { Category } from '../../shared/models'; const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b', '#475569']; @@ -15,8 +16,8 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b @@ -24,16 +25,16 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
-

{{ editingId ? 'Edytuj kategorię' : 'Nowa kategoria' }}

+

{{ editingId ? ui.t('categories.edit') : ui.t('categories.new') }}

- +
- +
@@ -51,10 +52,10 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
@if (editingId) { - + }
@@ -64,29 +65,28 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
-

Kategorie

- + + + @for (item of items(); track item.id) { - - + + + } @empty { - + }
NazwaTyp
{{ ui.t('categories.name') }}{{ ui.t('categories.color') }}{{ ui.t('categories.type') }}
-   - {{ item.name }} - {{ item.isSystem ? 'Systemowa' : 'Własna' }}{{ item.name }} {{ item.color }}{{ item.isSystem ? ui.t('categories.system') : ui.t('categories.custom') }}
+ @if (!item.isSystem) { - - + }
Brak kategorii.
{{ ui.t('common.noCategories') }}
@@ -97,17 +97,18 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b ` }) export class CategoriesComponent implements OnInit { + readonly ui = inject(UiService); + readonly presets = presets; private readonly fb = inject(FormBuilder); private readonly categories = inject(CategoriesService); private readonly toast = inject(ToastService); readonly items = this.categories.items; - readonly presets = presets; editingId: string | null = null; readonly form = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], - color: ['#2563eb', Validators.required] + color: [presets[0], Validators.required] }); ngOnInit() { @@ -118,6 +119,16 @@ export class CategoriesComponent implements OnInit { this.form.patchValue({ color }); } + edit(item: Category) { + this.editingId = item.id; + this.form.reset({ name: item.name, color: item.color || presets[0] }); + } + + resetForm() { + this.editingId = null; + this.form.reset({ name: '', color: presets[0] }); + } + submit() { if (this.form.invalid) return; const payload = this.form.getRawValue(); @@ -125,27 +136,17 @@ export class CategoriesComponent implements OnInit { request.subscribe({ next: () => { - this.toast.success(this.editingId ? 'Kategoria została zapisana.' : 'Kategoria została dodana.'); - this.reset(); + this.toast.success(this.editingId ? this.ui.t('categories.saved') : this.ui.t('categories.added')); + this.resetForm(); }, - error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać kategorii.') + error: (error) => this.toast.error(error.error?.message ?? this.ui.t('categories.saveError')) }); } - edit(item: Category) { - this.editingId = item.id; - this.form.patchValue({ name: item.name, color: item.color }); - } - - reset() { - this.editingId = null; - this.form.reset({ name: '', color: '#2563eb' }); - } - remove(item: Category) { this.categories.delete(item.id).subscribe({ - next: () => this.toast.success('Kategoria została usunięta.'), - error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kategorii.') + next: () => this.toast.success(this.ui.t('categories.deleted')), + error: (error) => this.toast.error(error.error?.message ?? this.ui.t('categories.deleteError')) }); } } diff --git a/web/src/app/features/dashboard/dashboard.component.ts b/web/src/app/features/dashboard/dashboard.component.ts index 8bc4785..4560ce8 100644 --- a/web/src/app/features/dashboard/dashboard.component.ts +++ b/web/src/app/features/dashboard/dashboard.component.ts @@ -1,5 +1,5 @@ import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; +import { AfterViewChecked, Component, OnDestroy, OnInit, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { Chart, DoughnutController, ArcElement, Tooltip, Legend } from 'chart.js'; import { AuthService } from '../../core/services/auth.service'; @@ -10,6 +10,24 @@ import type { Expense, StatsResponse } from '../../shared/models'; Chart.register(DoughnutController, ArcElement, Tooltip, Legend); +const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c']; +const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-cache'; + +const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const getMonthRange = () => { + const now = new Date(); + return { + start: formatLocalDate(new Date(now.getFullYear(), now.getMonth(), 1)), + end: formatLocalDate(now) + }; +}; + @Component({ selector: 'app-dashboard', standalone: true, @@ -19,7 +37,6 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);

{{ auth.currentUser()?.fullName }}

-
{{ ui.t('dashboard.subtitle') }}
@@ -38,8 +55,8 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
-
{{ stats?.total || 0 | currency:'PLN':'symbol':'1.2-2' }}
-
{{ ui.t('dashboard.subtitle') }}
+
{{ ui.t('dashboard.total') }}
+
{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}
@@ -52,7 +69,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
{{ ui.t('dashboard.avg') }}
-
{{ stats?.average || 0 | currency:'PLN':'symbol':'1.2-2' }}
+
{{ (stats?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}
@@ -73,7 +90,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);

{{ ui.t('dashboard.share') }}

-
Miesięczny przekrój kosztów według kategorii.
+
{{ ui.t('dashboard.shareHint') }}
@@ -82,7 +99,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
} @else { -
Brak danych do pokazania wykresu kategorii.
+
{{ ui.t('dashboard.noChartData') }}
}
@@ -93,13 +110,13 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);

{{ ui.t('dashboard.areas') }}

-
Najważniejsze obszary kosztowe w aktualnym okresie.
+
{{ ui.t('dashboard.areasHint') }}
- + @for (row of stats?.byCategory || []; track row.categoryId) { @@ -122,14 +139,14 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);

{{ ui.t('dashboard.recent') }}

-
Ostatnio dodane pozycje wraz z kontrahentami.
+
{{ ui.t('dashboard.recentHint') }}
@if (recentExpenses.length) {
KategoriaKwotaLiczba
{{ ui.t('table.category') }}{{ ui.t('table.amount') }}{{ ui.t('table.count') }}
- + @for (item of recentExpenses; track item.id) { @@ -153,7 +170,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend); ` }) -export class DashboardComponent implements OnInit { +export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy { readonly auth = inject(AuthService); readonly ui = inject(UiService); private readonly expensesService = inject(ExpensesService); @@ -162,48 +179,102 @@ export class DashboardComponent implements OnInit { recentExpenses: Expense[] = []; stats: StatsResponse | null = null; private categoryChart?: Chart; + private chartPending = false; ngOnInit() { - const now = new Date(); - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10); - const end = now.toISOString().slice(0, 10); + this.restoreCache(); + this.loadDashboard(); + } - this.expensesService.list({ startDate: start, endDate: end }).subscribe({ - next: (response) => (this.recentExpenses = response.items.slice(0, 8)) - }); + ngAfterViewChecked() { + if (this.chartPending) { + this.chartPending = false; + this.renderChart(); + } + } - this.statsService.overview({ startDate: start, endDate: end, bucket: 'month' }).subscribe({ - next: (response) => { - this.stats = response; - setTimeout(() => this.renderChart(), 0); - } - }); + ngOnDestroy() { + this.categoryChart?.destroy(); } hasCategoryData() { return Boolean(this.stats?.byCategory?.length); } + private loadDashboard() { + const range = getMonthRange(); + + this.expensesService.list().subscribe({ + next: (response) => { + this.recentExpenses = response.items.slice(0, 8); + this.persistCache(); + } + }); + + this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({ + next: (response) => { + this.stats = response; + this.chartPending = true; + this.persistCache(); + } + }); + } + private renderChart() { const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null; - if (!canvas || !this.stats?.byCategory.length) { + if (!canvas || !this.stats?.byCategory?.length) { this.categoryChart?.destroy(); return; } + const colors = this.stats.byCategory.map((_, index) => chartPalette[index % chartPalette.length]); + this.categoryChart?.destroy(); this.categoryChart = new Chart(canvas, { type: 'doughnut', data: { labels: this.stats.byCategory.map((item) => item.categoryName), - datasets: [{ data: this.stats.byCategory.map((item) => item.total) }] + datasets: [ + { + data: this.stats.byCategory.map((item) => item.total), + backgroundColor: colors, + borderColor: '#ffffff', + hoverOffset: 10 + } + ] }, options: { responsive: true, maintainAspectRatio: false, cutout: '66%', - plugins: { legend: { position: 'bottom' } } + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + boxWidth: 10, + color: '#9ca3af' + } + } + } } }); } + + private restoreCache() { + try { + const raw = localStorage.getItem(DASHBOARD_CACHE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null }; + this.recentExpenses = parsed.recentExpenses ?? []; + this.stats = parsed.stats ?? null; + this.chartPending = Boolean(this.stats?.byCategory?.length); + } catch {} + } + + private persistCache() { + try { + localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats })); + } catch {} + } } diff --git a/web/src/app/features/expenses/expenses.component.ts b/web/src/app/features/expenses/expenses.component.ts index a22d185..21d1d59 100644 --- a/web/src/app/features/expenses/expenses.component.ts +++ b/web/src/app/features/expenses/expenses.component.ts @@ -6,9 +6,17 @@ import { CategoriesService } from '../../core/services/categories.service'; import { ExpensesService } from '../../core/services/expenses.service'; import { MerchantsService } from '../../core/services/merchants.service'; import { ToastService } from '../../core/services/toast.service'; +import { UiService } from '../../core/services/ui.service'; import type { Expense, Merchant, Proof } from '../../shared/models'; -const today = new Date().toISOString().slice(0, 10); +const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const today = formatLocalDate(new Date()); @Component({ selector: 'app-expenses', @@ -18,8 +26,8 @@ const today = new Date().toISOString().slice(0, 10); @@ -28,64 +36,80 @@ const today = new Date().toISOString().slice(0, 10);
-

{{ editingExpenseId() ? 'Edytuj wydatek' : 'Nowy wydatek' }}

+

{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}

@if (editingExpenseId()) { - + }
-
+ + @if (submitted() && expenseForm.invalid) { +
{{ ui.t('expenses.requiredHint') }}
+ } +
- - + + + @if (expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())) { +
{{ ui.t('expenses.validation.title') }}
+ }
- - + + + @if (expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())) { +
{{ ui.t('expenses.validation.amount') }}
+ }
- - + + + @if (expenseForm.controls.expenseDate.invalid && (expenseForm.controls.expenseDate.touched || submitted())) { +
{{ ui.t('expenses.validation.date') }}
+ }
- - + @for (category of categories(); track category.id) { } + @if (expenseForm.controls.categoryId.invalid && (expenseForm.controls.categoryId.touched || submitted())) { +
{{ ui.t('expenses.validation.category') }}
+ }
- +
- +
- +
- +
- +
@@ -95,49 +119,49 @@ const today = new Date().toISOString().slice(0, 10);
- +
- +
- +
- +
@if (showCropper()) {
-
Kadrowanie
+
{{ ui.t('expenses.field.crop') }}
} @if (croppedPreview()) {
-
Podgląd po cropie
- Podgląd +
{{ ui.t('expenses.field.cropPreview') }}
+
}
} -
@@ -146,23 +170,23 @@ const today = new Date().toISOString().slice(0, 10);
-

Filtry i ostatnie wydatki

+

{{ ui.t('expenses.filters') }}

-
+
- - + +
@@ -173,14 +197,14 @@ const today = new Date().toISOString().slice(0, 10);
{{ expense.title }}
-
{{ expense.merchant || 'Brak kontrahenta' }} • {{ expense.expenseDate | date:'shortDate' }}
+
{{ expense.merchant || ui.t('expenses.noMerchant') }} • {{ expense.expenseDate | date:'shortDate' }}
{{ expense.category.name }}
{{ expense.amount | currency:expense.currency:'symbol':'1.2-2' }}
- - + +
@@ -188,7 +212,7 @@ const today = new Date().toISOString().slice(0, 10);
@for (proof of expense.proofs; track proof.id) { }
@@ -197,7 +221,7 @@ const today = new Date().toISOString().slice(0, 10); }
} @else { -
Brak wydatków do wyświetlenia.
+
{{ ui.t('expenses.noItems') }}
}
@@ -209,33 +233,33 @@ const today = new Date().toISOString().slice(0, 10);
TytułKontrahentDataKwota
{{ ui.t('table.title') }}{{ ui.t('table.merchant') }}{{ ui.t('table.date') }}{{ ui.t('table.amount') }}
- + @for (item of items(); track item.id) { - - + + } @empty { - + }
NazwaTypStatusNotatki
{{ ui.t('merchant.name') }}{{ ui.t('merchant.type') }}{{ ui.t('admin.status') }}{{ ui.t('merchant.notes') }}
{{ item.name }} {{ labelKind(item.kind) }}{{ item.isActive ? 'Aktywny' : 'Ukryty' }}{{ item.notes || 'Brak' }}{{ item.isActive ? ui.t('common.active') : ui.t('common.hidden') }}{{ item.notes || ui.t('common.none') }}
- - + +
Brak zapisanych kontrahentów.
{{ ui.t('merchant.noneSaved') }}
@@ -60,39 +61,39 @@ import type { Merchant } from '../../shared/models';