poprawki
This commit is contained in:
BIN
Archiwum.zip
Normal file
BIN
Archiwum.zip
Normal file
Binary file not shown.
@@ -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!
|
||||
|
||||
39
api/package-lock.json
generated
39
api/package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ReturnType<typeof getStatistics>>) => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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!}
|
||||
|
||||
@@ -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<ToastItem[]>([]);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'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<UiLanguage, Record<string, string>> = {
|
||||
'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<UiLanguage, Record<string, string>> = {
|
||||
'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<string, string | number>) {
|
||||
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 {
|
||||
|
||||
@@ -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';
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">Administracja</h2>
|
||||
<div class="text-secondary">Ustawienia aplikacji, SMTP oraz zarządzanie użytkownikami.</div>
|
||||
<h2 class="page-title mb-1">{{ ui.t('admin.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('admin.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,52 +24,52 @@ import type { AppSettings, User } from '../../shared/models';
|
||||
<div class="row row-cards align-items-start">
|
||||
<div class="col-xl-5">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">Ustawienia aplikacji</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('admin.settings') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||
<div>
|
||||
<label class="form-label">Nazwa aplikacji</label>
|
||||
<label class="form-label">{{ ui.t('admin.appName') }}</label>
|
||||
<input class="form-control" formControlName="appName" />
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Domyślna waluta</label><input class="form-control" formControlName="defaultCurrency" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Typy potwierdzeń</label><input class="form-control" formControlName="allowedProofTypes" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.allowedProofTypes') }}</label><input class="form-control" formControlName="allowedProofTypes" /></div>
|
||||
</div>
|
||||
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" formControlName="registrationEnabled" />
|
||||
<span class="form-check-label">Włącz rejestrację</span>
|
||||
<span class="form-check-label">{{ ui.t('admin.registration') }}</span>
|
||||
</label>
|
||||
|
||||
<hr class="my-2" />
|
||||
<div class="fw-semibold">SMTP</div>
|
||||
<div class="fw-semibold">{{ ui.t('admin.smtp') }}</div>
|
||||
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" formControlName="smtpEnabled" />
|
||||
<span class="form-check-label">Włącz SMTP</span>
|
||||
<span class="form-check-label">{{ ui.t('admin.smtpEnabled') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7"><label class="form-label">Host</label><input class="form-control" formControlName="smtpHost" /></div>
|
||||
<div class="col-md-5"><label class="form-label">Port</label><input class="form-control" type="number" formControlName="smtpPort" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Użytkownik</label><input class="form-control" formControlName="smtpUser" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Hasło</label><input class="form-control" type="password" formControlName="smtpPassword" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Nazwa nadawcy</label><input class="form-control" formControlName="smtpFromName" /></div>
|
||||
<div class="col-md-6"><label class="form-label">E-mail nadawcy</label><input class="form-control" formControlName="smtpFromEmail" /></div>
|
||||
<div class="col-md-7"><label class="form-label">{{ ui.t('admin.host') }}</label><input class="form-control" formControlName="smtpHost" /></div>
|
||||
<div class="col-md-5"><label class="form-label">{{ ui.t('admin.port') }}</label><input class="form-control" type="number" formControlName="smtpPort" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.user') }}</label><input class="form-control" formControlName="smtpUser" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="smtpPassword" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.fromName') }}</label><input class="form-control" formControlName="smtpFromName" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.fromEmail') }}</label><input class="form-control" formControlName="smtpFromEmail" /></div>
|
||||
</div>
|
||||
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" formControlName="smtpSecure" />
|
||||
<span class="form-check-label">Bezpieczne połączenie</span>
|
||||
<span class="form-check-label">{{ ui.t('admin.secureConnection') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="btn-list flex-wrap">
|
||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid || saving()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
|
||||
<span>Zapisz</span>
|
||||
<span>{{ ui.t('action.save') }}</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-info" type="button" (click)="sendTest()">Test SMTP</button>
|
||||
<button class="btn btn-outline-info" type="button" (click)="sendTest()">{{ ui.t('action.testSmtp') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -78,12 +79,12 @@ import type { AppSettings, User } from '../../shared/models';
|
||||
<div class="col-xl-7">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">Użytkownicy</h3>
|
||||
<h3 class="card-title">{{ ui.t('admin.users') }}</h3>
|
||||
<span class="badge bg-dark-lt">{{ users().length }}</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>Użytkownik</th><th>Rola</th><th>Status</th><th>Data</th><th class="w-1"></th></tr></thead>
|
||||
<thead><tr><th>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.date') }}</th><th class="w-1"></th></tr></thead>
|
||||
<tbody>
|
||||
@for (user of users(); track user.id) {
|
||||
<tr>
|
||||
@@ -94,23 +95,23 @@ import type { AppSettings, User } from '../../shared/models';
|
||||
<td>{{ user.role }}</td>
|
||||
<td>
|
||||
<span class="badge" [class.bg-success]="user.isActive" [class.bg-secondary]="!user.isActive">
|
||||
{{ user.isActive ? 'Aktywny' : 'Zablokowany' }}
|
||||
{{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.createdAt | date:'short' }}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
|
||||
{{ user.role === 'ADMIN' ? 'Ustaw USER' : 'Ustaw ADMIN' }}
|
||||
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
|
||||
</button>
|
||||
<button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)">
|
||||
{{ user.isActive ? 'Zablokuj' : 'Odblokuj' }}
|
||||
{{ user.isActive ? ui.t('action.block') : ui.t('action.unblock') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="5" class="text-secondary">Brak użytkowników.</td></tr>
|
||||
<tr><td colspan="5" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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'))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,7 @@ import { UiService } from '../../core/services/ui.service';
|
||||
</form>
|
||||
|
||||
<div class="login-footer-note">
|
||||
{{ mode() === 'login'
|
||||
? (ui.language() === 'pl' ? 'Użyj swojego konta, aby zarządzać wydatkami, raportami i uprawnieniami.' : 'Use your account to manage expenses, reports and user permissions.')
|
||||
: (ui.language() === 'pl' ? 'Po utworzeniu konta od razu wrócisz do logowania.' : 'After creating an account you will be taken back to sign in.') }}
|
||||
{{ mode() === 'login' ? ui.t('login.footer') : ui.t('register.footer') }}
|
||||
</div>
|
||||
|
||||
@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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">Kategorie</h2>
|
||||
<div class="text-secondary">Zarządzaj kategoriami systemowymi i własnymi dla raportów oraz wydatków.</div>
|
||||
<h2 class="page-title mb-1">{{ ui.t('categories.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('categories.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,16 +25,16 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
|
||||
<div class="row row-cards align-items-start">
|
||||
<div class="col-lg-4">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ editingId ? 'Edytuj kategorię' : 'Nowa kategoria' }}</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">{{ editingId ? ui.t('categories.edit') : ui.t('categories.new') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="d-grid gap-3">
|
||||
<div>
|
||||
<label class="form-label">Nazwa</label>
|
||||
<label class="form-label">{{ ui.t('categories.name') }}</label>
|
||||
<input class="form-control" formControlName="name" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Kolor</label>
|
||||
<label class="form-label">{{ ui.t('categories.color') }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text p-1"><span class="ec-color-swatch" [style.background]="form.getRawValue().color"></span></span>
|
||||
<input class="form-control" formControlName="color" />
|
||||
@@ -51,10 +52,10 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
|
||||
<div class="btn-list">
|
||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
|
||||
<span>{{ editingId ? 'Zapisz' : 'Dodaj' }}</span>
|
||||
<span>{{ editingId ? ui.t('action.save') : ui.t('action.addCategory') }}</span>
|
||||
</button>
|
||||
@if (editingId) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="reset()">Anuluj</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="resetForm()">{{ ui.t('action.cancel') }}</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
@@ -64,29 +65,28 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">Kategorie</h3></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>Nazwa</th><th>Typ</th><th class="w-1"></th></tr></thead>
|
||||
<thead>
|
||||
<tr><th>{{ ui.t('categories.name') }}</th><th>{{ ui.t('categories.color') }}</th><th>{{ ui.t('categories.type') }}</th><th class="w-1"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of items(); track item.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge me-2" [style.background]="item.color"> </span>
|
||||
{{ item.name }}
|
||||
</td>
|
||||
<td>{{ item.isSystem ? 'Systemowa' : 'Własna' }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td><span class="ec-color-swatch" [style.background]="item.color"></span> <span class="ms-2">{{ item.color }}</span></td>
|
||||
<td>{{ item.isSystem ? ui.t('categories.system') : ui.t('categories.custom') }}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button>
|
||||
@if (!item.isSystem) {
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="edit(item)">Edytuj</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">Usuń</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="text-secondary">Brak kategorii.</td></tr>
|
||||
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noCategories') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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'))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">{{ auth.currentUser()?.fullName }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('dashboard.subtitle') }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl d-flex justify-content-xl-end">
|
||||
<div class="ec-page-header-actions">
|
||||
@@ -38,8 +55,8 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col-lg-7">
|
||||
<div class="display-6 fw-bold mb-2">{{ stats?.total || 0 | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
<div class="text-secondary">{{ ui.t('dashboard.subtitle') }}</div>
|
||||
<div class="text-secondary text-uppercase small fw-semibold mb-2">{{ ui.t('dashboard.total') }}</div>
|
||||
<div class="display-6 fw-bold mb-0">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="row g-3">
|
||||
@@ -52,7 +69,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
<div class="col-6">
|
||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
||||
<div class="text-secondary small">{{ ui.t('dashboard.avg') }}</div>
|
||||
<div class="h2 mb-0">{{ stats?.average || 0 | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
<div class="h2 mb-0">{{ (stats?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@@ -73,7 +90,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title">{{ ui.t('dashboard.share') }}</h3>
|
||||
<div class="ec-card-header-muted">Miesięczny przekrój kosztów według kategorii.</div>
|
||||
<div class="ec-card-header-muted">{{ ui.t('dashboard.shareHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -82,7 +99,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
<canvas id="dashboardCategoryChart"></canvas>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">Brak danych do pokazania wykresu kategorii.</div>
|
||||
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noChartData') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,13 +110,13 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title">{{ ui.t('dashboard.areas') }}</h3>
|
||||
<div class="ec-card-header-muted">Najważniejsze obszary kosztowe w aktualnym okresie.</div>
|
||||
<div class="ec-card-header-muted">{{ ui.t('dashboard.areasHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table table-striped mb-0">
|
||||
<thead>
|
||||
<tr><th>Kategoria</th><th class="text-end">Kwota</th><th class="text-end">Liczba</th></tr>
|
||||
<tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of stats?.byCategory || []; track row.categoryId) {
|
||||
@@ -122,14 +139,14 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3>
|
||||
<div class="ec-card-header-muted">Ostatnio dodane pozycje wraz z kontrahentami.</div>
|
||||
<div class="ec-card-header-muted">{{ ui.t('dashboard.recentHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (recentExpenses.length) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table table-striped mb-0">
|
||||
<thead>
|
||||
<tr><th>Tytuł</th><th>Kontrahent</th><th>Data</th><th class="text-end">Kwota</th></tr>
|
||||
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.merchant') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of recentExpenses; track item.id) {
|
||||
@@ -153,7 +170,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
</div>
|
||||
`
|
||||
})
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">Wydatki</h2>
|
||||
<div class="text-secondary">Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.</div>
|
||||
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,64 +36,80 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
<div class="col-xl-7">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">{{ editingExpenseId() ? 'Edytuj wydatek' : 'Nowy wydatek' }}</h3>
|
||||
<h3 class="card-title">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
|
||||
@if (editingExpenseId()) {
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">Anuluj edycję</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3">
|
||||
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
|
||||
@if (submitted() && expenseForm.invalid) {
|
||||
<div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<label class="form-label">Tytuł</label>
|
||||
<input class="form-control" formControlName="title" />
|
||||
<label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" />
|
||||
@if (expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.title') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Kwota</label>
|
||||
<input class="form-control" type="number" step="0.01" formControlName="amount" />
|
||||
<label class="form-label">{{ ui.t('expenses.field.amount') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="number" step="0.01" formControlName="amount" [class.is-invalid]="expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())" />
|
||||
@if (expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.amount') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Data</label>
|
||||
<input class="form-control" type="date" formControlName="expenseDate" />
|
||||
<label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="date" formControlName="expenseDate" [class.is-invalid]="expenseForm.controls.expenseDate.invalid && (expenseForm.controls.expenseDate.touched || submitted())" />
|
||||
@if (expenseForm.controls.expenseDate.invalid && (expenseForm.controls.expenseDate.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.date') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Kategoria</label>
|
||||
<select class="form-select" formControlName="categoryId">
|
||||
<option value="">Wybierz</option>
|
||||
<label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></label>
|
||||
<select class="form-select" formControlName="categoryId" [class.is-invalid]="expenseForm.controls.categoryId.invalid && (expenseForm.controls.categoryId.touched || submitted())">
|
||||
<option value="">{{ ui.t('common.select') }}</option>
|
||||
@for (category of categories(); track category.id) {
|
||||
<option [value]="category.id">{{ category.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (expenseForm.controls.categoryId.invalid && (expenseForm.controls.categoryId.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.category') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Płatność</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.payment') }}</label>
|
||||
<select class="form-select" formControlName="paymentMethod">
|
||||
<option value="">Brak</option>
|
||||
<option value="CARD">Karta</option>
|
||||
<option value="CASH">Gotówka</option>
|
||||
<option value="TRANSFER">Przelew</option>
|
||||
<option value="">{{ ui.t('expenses.payment.none') }}</option>
|
||||
<option value="CARD">{{ ui.t('expenses.payment.card') }}</option>
|
||||
<option value="CASH">{{ ui.t('expenses.payment.cash') }}</option>
|
||||
<option value="TRANSFER">{{ ui.t('expenses.payment.transfer') }}</option>
|
||||
<option value="BLIK">BLIK</option>
|
||||
<option value="OTHER">Inne</option>
|
||||
<option value="OTHER">{{ ui.t('expenses.payment.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<label class="form-label">Kontrahent</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.merchantPicker') }}</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)">
|
||||
<option value="">Własny wpis</option>
|
||||
<option value="">{{ ui.t('expenses.customEntry') }}</option>
|
||||
@for (item of activeMerchants(); track item.id) {
|
||||
<option [value]="item.id">{{ item.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary" type="button" (click)="openMerchantModal()">Dodaj</button>
|
||||
<button class="btn btn-outline-primary" type="button" (click)="openMerchantModal()">{{ ui.t('action.add') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Nazwa w wydatku</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label>
|
||||
<input class="form-control" formControlName="merchant" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Opis</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.description') }}</label>
|
||||
<textarea class="form-control" rows="3" formControlName="description"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,49 +119,49 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
<div class="card-body d-grid gap-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Typ potwierdzenia</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofType') }}</label>
|
||||
<select class="form-select" formControlName="proofType">
|
||||
<option value="RECEIPT">Paragon</option>
|
||||
<option value="INVOICE">Faktura</option>
|
||||
<option value="NOTE">Notatka</option>
|
||||
<option value="BANK_STATEMENT">Wyciąg</option>
|
||||
<option value="OTHER">Inne</option>
|
||||
<option value="RECEIPT">{{ ui.t('proof.receipt') }}</option>
|
||||
<option value="INVOICE">{{ ui.t('proof.invoice') }}</option>
|
||||
<option value="NOTE">{{ ui.t('proof.note') }}</option>
|
||||
<option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option>
|
||||
<option value="OTHER">{{ ui.t('proof.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Etykieta</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label>
|
||||
<input class="form-control" formControlName="proofLabel" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Plik</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.file') }}</label>
|
||||
<input class="form-control" type="file" accept="image/*,.pdf" (change)="onProofSelected($event)" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notatka do potwierdzenia</label>
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label>
|
||||
<textarea class="form-control" rows="2" formControlName="proofNote"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showCropper()) {
|
||||
<div>
|
||||
<div class="form-label">Kadrowanie</div>
|
||||
<div class="form-label">{{ ui.t('expenses.field.crop') }}</div>
|
||||
<image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (croppedPreview()) {
|
||||
<div>
|
||||
<div class="form-label">Podgląd po cropie</div>
|
||||
<img class="img-fluid rounded" [src]="croppedPreview()" alt="Podgląd" />
|
||||
<div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div>
|
||||
<img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-success d-inline-flex align-items-center justify-content-center gap-2" [disabled]="expenseForm.invalid || saving()">
|
||||
<button class="btn btn-success d-inline-flex align-items-center justify-content-center gap-2" [disabled]="saving()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
|
||||
<span>{{ saving() ? 'Zapisywanie...' : (editingExpenseId() ? 'Zapisz zmiany' : 'Dodaj wydatek') }}</span>
|
||||
<span>{{ saving() ? ui.t('expenses.saving') : (editingExpenseId() ? ui.t('action.saveChanges') : ui.t('action.addExpense')) }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -146,23 +170,23 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
<div class="col-xl-5">
|
||||
<div class="card sticky-top overflow-hidden" style="top: 1rem;">
|
||||
<div class="card-header"><h3 class="card-title">Filtry i ostatnie wydatki</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('expenses.filters') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="filterForm" (ngSubmit)="loadExpenses()" class="row g-2 mb-4">
|
||||
<div class="col-6"><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-6"><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-12">
|
||||
<select class="form-select" formControlName="categoryId">
|
||||
<option value="">Wszystkie kategorie</option>
|
||||
<option value="">{{ ui.t('expenses.allCategories') }}</option>
|
||||
@for (category of categories(); track category.id) {
|
||||
<option [value]="category.id">{{ category.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12"><input class="form-control" formControlName="search" placeholder="Szukaj" /></div>
|
||||
<div class="col-12"><input class="form-control" formControlName="search" [placeholder]="ui.t('expenses.search')" /></div>
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-primary flex-fill">Filtruj</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">Reset</button>
|
||||
<button class="btn btn-primary flex-fill">{{ ui.t('action.filter') }}</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -173,14 +197,14 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ expense.title }}</div>
|
||||
<div class="small text-secondary">{{ expense.merchant || 'Brak kontrahenta' }} • {{ expense.expenseDate | date:'shortDate' }}</div>
|
||||
<div class="small text-secondary">{{ expense.merchant || ui.t('expenses.noMerchant') }} • {{ expense.expenseDate | date:'shortDate' }}</div>
|
||||
<div class="small text-secondary">{{ expense.category.name }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="fw-bold">{{ expense.amount | currency:expense.currency:'symbol':'1.2-2' }}</div>
|
||||
<div class="btn-list justify-content-end mt-2">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="startEdit(expense)">Edytuj</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="removeExpense(expense)">Usuń</button>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="startEdit(expense)">{{ ui.t('action.edit') }}</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="removeExpense(expense)">{{ ui.t('action.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +212,7 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
<div class="btn-list mt-3">
|
||||
@for (proof of expense.proofs; track proof.id) {
|
||||
<button class="btn btn-outline-info btn-sm" type="button" (click)="openProof(proof)">
|
||||
{{ proof.label || proof.originalName || 'Potwierdzenie' }}
|
||||
{{ proof.label || proof.originalName || ui.t('expenses.proof') }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -197,7 +221,7 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-warning mb-0">Brak wydatków do wyświetlenia.</div>
|
||||
<div class="alert alert-warning mb-0">{{ ui.t('expenses.noItems') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,33 +233,33 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Nowy kontrahent</h5>
|
||||
<h5 class="modal-title">{{ ui.t('merchant.new') }}</h5>
|
||||
<button class="btn-close" type="button" (click)="closeMerchantModal()"></button>
|
||||
</div>
|
||||
<form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()">
|
||||
<div class="modal-body">
|
||||
<div class="d-grid gap-3">
|
||||
<div>
|
||||
<label class="form-label">Nazwa</label>
|
||||
<label class="form-label">{{ ui.t('merchant.name') }}</label>
|
||||
<input class="form-control" formControlName="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Typ</label>
|
||||
<label class="form-label">{{ ui.t('merchant.type') }}</label>
|
||||
<select class="form-select" formControlName="kind">
|
||||
<option value="MERCHANT">Sprzedawca</option>
|
||||
<option value="SERVICE_PROVIDER">Usługodawca</option>
|
||||
<option value="OTHER">Inny</option>
|
||||
<option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option>
|
||||
<option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option>
|
||||
<option value="OTHER">{{ ui.t('merchant.kind.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Notatki</label>
|
||||
<label class="form-label">{{ ui.t('merchant.notes') }}</label>
|
||||
<textarea class="form-control" rows="3" formControlName="notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">Anuluj</button>
|
||||
<button class="btn btn-success" [disabled]="merchantForm.invalid">Zapisz kontrahenta</button>
|
||||
<button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button>
|
||||
<button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -249,14 +273,14 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || 'Potwierdzenie' }}</h5>
|
||||
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
|
||||
<button class="btn-close" type="button" (click)="closeProofPreview()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if ((proofPreview()?.mimeType || '').includes('pdf')) {
|
||||
<embed [attr.src]="proofPreview()?.fileUrl" type="application/pdf" style="width:100%;height:75vh;" />
|
||||
} @else {
|
||||
<img class="img-fluid" [src]="proofPreview()?.fileUrl" alt="Potwierdzenie" />
|
||||
<img class="img-fluid" [src]="proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +291,7 @@ const today = new Date().toISOString().slice(0, 10);
|
||||
`
|
||||
})
|
||||
export class ExpensesComponent implements OnInit {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly merchantsService = inject(MerchantsService);
|
||||
@@ -279,6 +304,7 @@ export class ExpensesComponent implements OnInit {
|
||||
readonly selectedMerchantId = signal('');
|
||||
readonly editingExpenseId = signal<string | null>(null);
|
||||
readonly saving = signal(false);
|
||||
readonly submitted = signal(false);
|
||||
readonly merchantModalOpen = signal(false);
|
||||
readonly proofPreview = signal<Proof | null>(null);
|
||||
|
||||
@@ -289,7 +315,7 @@ export class ExpensesComponent implements OnInit {
|
||||
|
||||
readonly expenseForm = this.fb.nonNullable.group({
|
||||
title: ['', [Validators.required, Validators.minLength(2)]],
|
||||
amount: [0],
|
||||
amount: [0, [Validators.required, Validators.min(0.01)]],
|
||||
expenseDate: [today, Validators.required],
|
||||
categoryId: ['', Validators.required],
|
||||
merchant: [''],
|
||||
@@ -349,12 +375,12 @@ export class ExpensesComponent implements OnInit {
|
||||
if (this.merchantForm.invalid) return;
|
||||
this.merchantsService.create({ ...this.merchantForm.getRawValue(), isActive: true }).subscribe({
|
||||
next: (response) => {
|
||||
this.toast.success('Kontrahent został dodany.');
|
||||
this.toast.success(this.ui.t('merchant.added'));
|
||||
this.merchantModalOpen.set(false);
|
||||
this.selectedMerchantId.set(response.item.id);
|
||||
this.expenseForm.patchValue({ merchant: response.item.name });
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się dodać kontrahenta.')
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('merchant.saveError'))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -380,7 +406,12 @@ export class ExpensesComponent implements OnInit {
|
||||
}
|
||||
|
||||
submitExpense() {
|
||||
this.submitted.set(true);
|
||||
this.expenseForm.markAllAsTouched();
|
||||
this.expenseForm.updateValueAndValidity();
|
||||
|
||||
if (this.expenseForm.invalid) return;
|
||||
|
||||
const raw = this.expenseForm.getRawValue();
|
||||
this.saving.set(true);
|
||||
|
||||
@@ -399,13 +430,14 @@ export class ExpensesComponent implements OnInit {
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toast.success('Wydatek został zapisany.');
|
||||
this.submitted.set(false);
|
||||
this.toast.success(this.ui.t('expenses.saved'));
|
||||
this.cancelEdit();
|
||||
this.loadExpenses();
|
||||
},
|
||||
error: (error) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(error.error?.message ?? 'Nie udało się zapisać wydatku.');
|
||||
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -428,7 +460,8 @@ export class ExpensesComponent implements OnInit {
|
||||
this.expensesService.create(formData).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toast.success('Wydatek został dodany.');
|
||||
this.submitted.set(false);
|
||||
this.toast.success(this.ui.t('expenses.added'));
|
||||
this.expenseForm.reset({
|
||||
title: '',
|
||||
amount: 0,
|
||||
@@ -449,13 +482,14 @@ export class ExpensesComponent implements OnInit {
|
||||
},
|
||||
error: (error) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(error.error?.message ?? 'Nie udało się dodać wydatku.');
|
||||
this.toast.error(error.error?.message ?? this.ui.t('expenses.addError'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startEdit(item: Expense) {
|
||||
this.editingExpenseId.set(item.id);
|
||||
this.submitted.set(false);
|
||||
this.expenseForm.patchValue({
|
||||
title: item.title,
|
||||
amount: item.amount,
|
||||
@@ -469,6 +503,7 @@ export class ExpensesComponent implements OnInit {
|
||||
|
||||
cancelEdit() {
|
||||
this.editingExpenseId.set(null);
|
||||
this.submitted.set(false);
|
||||
this.expenseForm.reset({
|
||||
title: '',
|
||||
amount: 0,
|
||||
@@ -486,10 +521,10 @@ export class ExpensesComponent implements OnInit {
|
||||
removeExpense(item: Expense) {
|
||||
this.expensesService.delete(item.id).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Wydatek został usunięty.');
|
||||
this.toast.success(this.ui.t('expenses.deleted'));
|
||||
this.loadExpenses();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć wydatku.')
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MerchantsService } from '../../core/services/merchants.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { Merchant } from '../../shared/models';
|
||||
|
||||
@Component({
|
||||
@@ -13,14 +14,14 @@ import type { Merchant } from '../../shared/models';
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">Kontrahenci</h2>
|
||||
<div class="text-secondary">Zapisani sprzedawcy i usługodawcy do szybkiego wyboru przy wydatkach.</div>
|
||||
<h2 class="page-title mb-1">{{ ui.t('merchant.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('merchant.subtitle') }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl d-flex justify-content-xl-end">
|
||||
<div class="ec-page-header-actions">
|
||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" type="button" (click)="openCreate()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14"/><path d="M5 12l14 0"/></svg>
|
||||
<span>Dodaj kontrahenta</span>
|
||||
<span>{{ ui.t('action.addMerchant') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,24 +32,24 @@ import type { Merchant } from '../../shared/models';
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead>
|
||||
<tr><th>Nazwa</th><th>Typ</th><th>Status</th><th>Notatki</th><th class="w-1"></th></tr>
|
||||
<tr><th>{{ ui.t('merchant.name') }}</th><th>{{ ui.t('merchant.type') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('merchant.notes') }}</th><th class="w-1"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of items(); track item.id) {
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ labelKind(item.kind) }}</td>
|
||||
<td><span class="badge" [class.bg-success]="item.isActive" [class.bg-secondary]="!item.isActive">{{ item.isActive ? 'Aktywny' : 'Ukryty' }}</span></td>
|
||||
<td>{{ item.notes || 'Brak' }}</td>
|
||||
<td><span class="badge" [class.bg-success]="item.isActive" [class.bg-secondary]="!item.isActive">{{ item.isActive ? ui.t('common.active') : ui.t('common.hidden') }}</span></td>
|
||||
<td>{{ item.notes || ui.t('common.none') }}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="openEdit(item)">Edytuj</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">Usuń</button>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="openEdit(item)">{{ ui.t('action.edit') }}</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="5" class="text-secondary">Brak zapisanych kontrahentów.</td></tr>
|
||||
<tr><td colspan="5" class="text-secondary">{{ ui.t('merchant.noneSaved') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -60,39 +61,39 @@ import type { Merchant } from '../../shared/models';
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ editingId() ? 'Edytuj kontrahenta' : 'Nowy kontrahent' }}</h5>
|
||||
<h5 class="modal-title">{{ editingId() ? ui.t('merchant.edit') : ui.t('merchant.new') }}</h5>
|
||||
<button type="button" class="btn-close" (click)="closeModal()"></button>
|
||||
</div>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Nazwa</label>
|
||||
<label class="form-label">{{ ui.t('merchant.name') }}</label>
|
||||
<input class="form-control" formControlName="name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Typ</label>
|
||||
<label class="form-label">{{ ui.t('merchant.type') }}</label>
|
||||
<select class="form-select" formControlName="kind">
|
||||
<option value="MERCHANT">Sprzedawca</option>
|
||||
<option value="SERVICE_PROVIDER">Usługodawca</option>
|
||||
<option value="OTHER">Inny</option>
|
||||
<option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option>
|
||||
<option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option>
|
||||
<option value="OTHER">{{ ui.t('merchant.kind.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notatki</label>
|
||||
<label class="form-label">{{ ui.t('merchant.notes') }}</label>
|
||||
<textarea class="form-control" rows="4" formControlName="notes"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" formControlName="isActive" />
|
||||
<span class="form-check-label">Pokazuj na listach wyboru</span>
|
||||
<span class="form-check-label">{{ ui.t('merchant.showOnLists') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost-secondary" type="button" (click)="closeModal()">Anuluj</button>
|
||||
<button class="btn btn-success" [disabled]="form.invalid">{{ editingId() ? 'Zapisz' : 'Dodaj' }}</button>
|
||||
<button class="btn btn-ghost-secondary" type="button" (click)="closeModal()">{{ ui.t('action.cancel') }}</button>
|
||||
<button class="btn btn-success" [disabled]="form.invalid">{{ editingId() ? ui.t('action.save') : ui.t('action.add') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -103,6 +104,7 @@ import type { Merchant } from '../../shared/models';
|
||||
`
|
||||
})
|
||||
export class MerchantsComponent implements OnInit {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly merchants = inject(MerchantsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
@@ -123,7 +125,11 @@ export class MerchantsComponent implements OnInit {
|
||||
}
|
||||
|
||||
labelKind(kind: Merchant['kind']) {
|
||||
return kind === 'SERVICE_PROVIDER' ? 'Usługodawca' : kind === 'MERCHANT' ? 'Sprzedawca' : 'Inny';
|
||||
return kind === 'SERVICE_PROVIDER'
|
||||
? this.ui.t('merchant.kind.service')
|
||||
: kind === 'MERCHANT'
|
||||
? this.ui.t('merchant.kind.merchant')
|
||||
: this.ui.t('merchant.kind.other');
|
||||
}
|
||||
|
||||
openCreate() {
|
||||
@@ -134,12 +140,7 @@ export class MerchantsComponent implements OnInit {
|
||||
|
||||
openEdit(item: Merchant) {
|
||||
this.editingId.set(item.id);
|
||||
this.form.reset({
|
||||
name: item.name,
|
||||
kind: item.kind,
|
||||
notes: item.notes ?? '',
|
||||
isActive: item.isActive
|
||||
});
|
||||
this.form.reset({ name: item.name, kind: item.kind, notes: item.notes ?? '', isActive: item.isActive });
|
||||
this.modalOpen.set(true);
|
||||
}
|
||||
|
||||
@@ -154,17 +155,17 @@ export class MerchantsComponent implements OnInit {
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.editingId() ? 'Kontrahent został zapisany.' : 'Kontrahent został dodany.');
|
||||
this.toast.success(this.editingId() ? this.ui.t('merchant.saved') : this.ui.t('merchant.added'));
|
||||
this.closeModal();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać kontrahenta.')
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('merchant.saveError'))
|
||||
});
|
||||
}
|
||||
|
||||
remove(item: Merchant) {
|
||||
this.merchants.delete(item.id).subscribe({
|
||||
next: () => this.toast.success('Kontrahent został usunięty.'),
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kontrahenta.')
|
||||
next: () => this.toast.success(this.ui.t('merchant.deleted')),
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('merchant.deleteError'))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { ReportsService } from '../../core/services/reports.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
||||
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
||||
|
||||
@@ -15,53 +16,53 @@ import { CategoryPickerComponent } from '../../shared/ui/category-picker.compone
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">Raporty</h2>
|
||||
<div class="text-secondary">Skonfiguruj raporty SMTP, podgląd i ręczne wysyłanie podsumowań.</div>
|
||||
<h2 class="page-title mb-1">{{ ui.t('reports.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('reports.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-5">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">Raporty e-mail</h3></div>
|
||||
<div class="card pv-card overflow-visible">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.emailTitle') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" formControlName="enabled" />
|
||||
<span class="form-check-label">Włącz raporty</span>
|
||||
<span class="form-check-label">{{ ui.t('reports.enable') }}</span>
|
||||
</label>
|
||||
<div>
|
||||
<label class="form-label">Częstotliwość</label>
|
||||
<label class="form-label">{{ ui.t('reports.frequency') }}</label>
|
||||
<select class="form-select" formControlName="frequency">
|
||||
<option value="monthly">Miesięczna</option>
|
||||
<option value="yearly">Roczna</option>
|
||||
<option value="threshold">Po przekroczeniu progu</option>
|
||||
<option value="monthly">{{ ui.t('reports.frequency.monthly') }}</option>
|
||||
<option value="yearly">{{ ui.t('reports.frequency.yearly') }}</option>
|
||||
<option value="threshold">{{ ui.t('reports.frequency.threshold') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Adres docelowy</label>
|
||||
<label class="form-label">{{ ui.t('reports.targetEmail') }}</label>
|
||||
<input class="form-control" formControlName="sendToEmail" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Próg kwotowy</label>
|
||||
<label class="form-label">{{ ui.t('reports.threshold') }}</label>
|
||||
<input class="form-control" type="number" step="0.01" formControlName="thresholdAmount" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Kategorie raportu</label>
|
||||
<label class="form-label">{{ ui.t('reports.categories') }}</label>
|
||||
<app-category-picker
|
||||
[items]="categories()"
|
||||
[selectedIds]="form.getRawValue().categoryIds"
|
||||
placeholder="Wszystkie kategorie"
|
||||
[placeholder]="ui.t('expenses.allCategories')"
|
||||
(changed)="setCategoryIds($event)"></app-category-picker>
|
||||
</div>
|
||||
<div class="btn-list flex-wrap">
|
||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
|
||||
<span>Zapisz</span>
|
||||
<span>{{ ui.t('action.save') }}</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-info" type="button" (click)="preview()">Odśwież podgląd</button>
|
||||
<button class="btn btn-warning" type="button" (click)="send()">Wyślij teraz</button>
|
||||
<button class="btn btn-outline-info" type="button" (click)="preview()">{{ ui.t('action.refreshPreview') }}</button>
|
||||
<button class="btn btn-warning" type="button" (click)="send()">{{ ui.t('action.sendNow') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -70,13 +71,13 @@ import { CategoryPickerComponent } from '../../shared/ui/category-picker.compone
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">Podgląd raportu</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.preview') }}</h3></div>
|
||||
<div class="card-body">
|
||||
@if (summary()) {
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Suma</div><div class="h1">{{ summary()!.total.toFixed(2) }}</div></div></div></div>
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Liczba</div><div class="h1">{{ summary()!.count }}</div></div></div></div>
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Średnia</div><div class="h1">{{ summary()!.average.toFixed(2) }}</div></div></div></div>
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.sum') }}</div><div class="h1">{{ summary()!.total.toFixed(2) }}</div></div></div></div>
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="h1">{{ summary()!.count }}</div></div></div></div>
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.average') }}</div><div class="h1">{{ summary()!.average.toFixed(2) }}</div></div></div></div>
|
||||
</div>
|
||||
}
|
||||
<div class="card bg-body-tertiary overflow-hidden">
|
||||
@@ -91,13 +92,14 @@ import { CategoryPickerComponent } from '../../shared/ui/category-picker.compone
|
||||
`
|
||||
})
|
||||
export class ReportsComponent implements OnInit {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly reports = inject(ReportsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly html = signal('<div class="text-secondary">Brak danych raportu.</div>');
|
||||
readonly html = signal(`<div class="text-secondary">${this.ui.t('reports.noData')}</div>`);
|
||||
readonly summary = signal<StatsResponse | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
@@ -130,31 +132,29 @@ export class ReportsComponent implements OnInit {
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
const payload: ReportPreferences = this.form.getRawValue();
|
||||
this.reports.updatePreferences(payload).subscribe({
|
||||
this.reports.updatePreferences(this.form.getRawValue()).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Ustawienia raportów zapisane.');
|
||||
this.toast.success(this.ui.t('reports.saved'));
|
||||
this.preview();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać raportów.')
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.saveError'))
|
||||
});
|
||||
}
|
||||
|
||||
preview() {
|
||||
const payload: ReportPreferences = this.form.getRawValue();
|
||||
this.reports.preview(payload).subscribe({
|
||||
this.reports.preview(this.form.getRawValue()).subscribe({
|
||||
next: (response) => {
|
||||
this.html.set(response.html);
|
||||
this.summary.set(response.summary);
|
||||
this.html.set(response.html || `<div class="text-secondary">${this.ui.t('reports.noData')}</div>`);
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się pobrać podglądu.')
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.previewError'))
|
||||
});
|
||||
}
|
||||
|
||||
send() {
|
||||
this.reports.send().subscribe({
|
||||
next: (response) => this.toast.success(`Raport wysłano na ${response.sentTo}.`),
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się wysłać raportu.')
|
||||
next: (response) => this.toast.success(this.ui.t('reports.sentTo', { email: response.sentTo })),
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.sendError'))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { AfterViewChecked, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import {
|
||||
Chart,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'chart.js';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { StatsResponse } from '../../shared/models';
|
||||
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
||||
|
||||
@@ -30,6 +31,8 @@ Chart.register(
|
||||
LinearScale
|
||||
);
|
||||
|
||||
const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-stats',
|
||||
standalone: true,
|
||||
@@ -38,61 +41,61 @@ Chart.register(
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col">
|
||||
<h2 class="page-title mb-1">Statystyki</h2>
|
||||
<div class="text-secondary">Analiza miesięczna, kwartalna i roczna z podziałem na kategorie i zakres dat.</div>
|
||||
<h2 class="page-title mb-1">{{ ui.t('stats.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('stats.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card overflow-visible">
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="load()" class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Okres</label>
|
||||
<label class="form-label">{{ ui.t('stats.period') }}</label>
|
||||
<select class="form-select" formControlName="bucket">
|
||||
<option value="month">Miesięczny</option>
|
||||
<option value="quarter">Kwartalny</option>
|
||||
<option value="year">Roczny</option>
|
||||
<option value="month">{{ ui.t('stats.period.month') }}</option>
|
||||
<option value="quarter">{{ ui.t('stats.period.quarter') }}</option>
|
||||
<option value="year">{{ ui.t('stats.period.year') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3"><label class="form-label">Od</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Do</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-md-3"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-md-3"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Kategorie</label>
|
||||
<label class="form-label">{{ ui.t('reports.categories') }}</label>
|
||||
<app-category-picker
|
||||
[items]="categories()"
|
||||
[selectedIds]="form.getRawValue().categoryIds"
|
||||
placeholder="Wszystkie kategorie"
|
||||
[placeholder]="ui.t('expenses.allCategories')"
|
||||
(changed)="setCategoryIds($event)"></app-category-picker>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
|
||||
<span>Pokaż</span>
|
||||
<span>{{ ui.t('action.show') }}</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="reset()">Reset</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="reset()">{{ ui.t('action.reset') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Suma</div><div class="display-6">{{ stats()?.total || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Liczba</div><div class="display-6">{{ stats()?.count || 0 }}</div></div></div></div>
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Średnia</div><div class="display-6">{{ stats()?.average || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.sum') }}</div><div class="display-6">{{ (stats()?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats()?.count || 0 }}</div></div></div></div>
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.average') }}</div><div class="display-6">{{ (stats()?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
|
||||
<div class="col-lg-6 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">Udział kategorii</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('stats.share') }}</h3></div>
|
||||
<div class="card-body">
|
||||
@if (hasCategoryData()) {
|
||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
||||
<canvas id="statsCategoryChart"></canvas>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">Brak danych do wykresu kategorii.</div>
|
||||
<div class="alert alert-info mb-0">{{ ui.t('stats.noCategoryChart') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,14 +103,14 @@ Chart.register(
|
||||
|
||||
<div class="col-lg-6 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">Trend wydatków</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('stats.trend') }}</h3></div>
|
||||
<div class="card-body">
|
||||
@if (hasTimelineData()) {
|
||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
||||
<canvas id="statsLineChart"></canvas>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">Brak danych do wykresu trendu.</div>
|
||||
<div class="alert alert-info mb-0">{{ ui.t('stats.noTrendChart') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,10 +118,10 @@ Chart.register(
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">Podział kategorii</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('stats.breakdown') }}</h3></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>Kategoria</th><th class="text-end">Kwota</th><th class="text-end">Liczba</th></tr></thead>
|
||||
<thead><tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@for (row of stats()?.byCategory || []; track row.categoryId) {
|
||||
<tr>
|
||||
@@ -127,7 +130,7 @@ Chart.register(
|
||||
<td class="text-end">{{ row.count }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="text-secondary">Brak danych.</td></tr>
|
||||
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -137,7 +140,8 @@ Chart.register(
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class StatsComponent implements OnInit {
|
||||
export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly statsService = inject(StatsService);
|
||||
@@ -146,6 +150,7 @@ export class StatsComponent implements OnInit {
|
||||
readonly stats = signal<StatsResponse | null>(null);
|
||||
private categoryChart?: Chart;
|
||||
private lineChart?: Chart;
|
||||
private chartsPending = false;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
bucket: ['month' as 'month' | 'quarter' | 'year'],
|
||||
@@ -159,6 +164,18 @@ export class StatsComponent implements OnInit {
|
||||
this.load();
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
if (this.chartsPending) {
|
||||
this.chartsPending = false;
|
||||
this.renderCharts();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.categoryChart?.destroy();
|
||||
this.lineChart?.destroy();
|
||||
}
|
||||
|
||||
setCategoryIds(categoryIds: string[]) {
|
||||
this.form.patchValue({ categoryIds });
|
||||
}
|
||||
@@ -175,7 +192,7 @@ export class StatsComponent implements OnInit {
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.stats.set(response);
|
||||
setTimeout(() => this.renderCharts(), 0);
|
||||
this.chartsPending = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -199,17 +216,34 @@ export class StatsComponent implements OnInit {
|
||||
const lineCanvas = document.getElementById('statsLineChart') as HTMLCanvasElement | null;
|
||||
|
||||
if (categoryCanvas && current?.byCategory?.length) {
|
||||
const colors = current.byCategory.map((_, index) => chartPalette[index % chartPalette.length]);
|
||||
this.categoryChart?.destroy();
|
||||
this.categoryChart = new Chart(categoryCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: current.byCategory.map((item) => item.categoryName),
|
||||
datasets: [{ data: current.byCategory.map((item) => item.total) }]
|
||||
datasets: [
|
||||
{
|
||||
data: current.byCategory.map((item) => item.total),
|
||||
backgroundColor: colors,
|
||||
borderColor: '#ffffff',
|
||||
hoverOffset: 10
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
cutout: '64%',
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
color: '#9ca3af'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -222,9 +256,41 @@ export class StatsComponent implements OnInit {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: current.timeline.map((item) => item.label),
|
||||
datasets: [{ label: 'Wydatki', data: current.timeline.map((item) => item.total), tension: 0.35 }]
|
||||
datasets: [
|
||||
{
|
||||
label: this.ui.t('stats.expensesLabel'),
|
||||
data: current.timeline.map((item) => item.total),
|
||||
tension: 0.35,
|
||||
borderColor: '#206bc4',
|
||||
backgroundColor: 'rgba(32,107,196,0.18)',
|
||||
pointBackgroundColor: '#2fb344',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: { maintainAspectRatio: false }
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#9ca3af'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: 'rgba(148,163,184,0.16)' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: 'rgba(148,163,184,0.16)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.lineChart?.destroy();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, HostListener, computed, input, output, signal } from '@angular/core';
|
||||
import { Component, HostListener, computed, inject, input, output, signal } from '@angular/core';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { Category } from '../models';
|
||||
|
||||
@Component({
|
||||
@@ -7,13 +8,13 @@ import type { Category } from '../models';
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="dropdown w-100">
|
||||
<div class="dropdown w-100 position-relative ec-category-picker">
|
||||
<button class="form-select text-start d-flex align-items-center justify-content-between gap-2" type="button" (click)="toggle($event)">
|
||||
<span class="d-flex flex-wrap gap-2 align-items-center">
|
||||
@if (selectedItems().length) {
|
||||
@for (item of selectedItems(); track item.id) {
|
||||
<span class="badge text-bg-dark d-inline-flex align-items-center gap-1">
|
||||
<span class="badge rounded-pill" [style.background]="item.color"> </span>
|
||||
<span class="badge ec-picker-badge d-inline-flex align-items-center gap-1">
|
||||
<span class="badge rounded-pill ec-picker-dot" [style.background]="item.color"> </span>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
}
|
||||
@@ -25,21 +26,21 @@ import type { Category } from '../models';
|
||||
</button>
|
||||
|
||||
@if (open()) {
|
||||
<div class="dropdown-menu show w-100 p-2 shadow-sm">
|
||||
<div class="dropdown-menu show w-100 p-2 shadow-sm ec-category-picker-menu">
|
||||
<div class="d-grid gap-1" style="max-height: 18rem; overflow: auto;">
|
||||
@for (item of items(); track item.id) {
|
||||
<label class="dropdown-item rounded-2 d-flex align-items-center justify-content-between gap-3" (click)="$event.stopPropagation()">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<input class="form-check-input m-0" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleItem(item.id)" />
|
||||
<span class="badge rounded-pill" [style.background]="item.color"> </span>
|
||||
<span class="badge rounded-pill ec-picker-dot" [style.background]="item.color"> </span>
|
||||
<span>{{ item.name }}</span>
|
||||
</span>
|
||||
@if (isSelected(item.id)) {
|
||||
<span class="badge text-bg-success">OK</span>
|
||||
<span class="badge text-bg-success">{{ ui.t('common.selected') }}</span>
|
||||
}
|
||||
</label>
|
||||
} @empty {
|
||||
<div class="dropdown-item text-secondary">Brak kategorii.</div>
|
||||
<div class="dropdown-item text-secondary">{{ ui.t('common.noCategories') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,9 +49,10 @@ import type { Category } from '../models';
|
||||
`
|
||||
})
|
||||
export class CategoryPickerComponent {
|
||||
readonly ui = inject(UiService);
|
||||
readonly items = input<Category[]>([]);
|
||||
readonly selectedIds = input<string[]>([]);
|
||||
readonly placeholder = input('Wybierz kategorie');
|
||||
readonly placeholder = input('');
|
||||
readonly changed = output<string[]>();
|
||||
|
||||
readonly open = signal(false);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
@import "@tabler/core/dist/css/tabler.min.css";
|
||||
|
||||
:root {
|
||||
--tblr-primary: #111827;
|
||||
--tblr-primary-rgb: 17, 24, 39;
|
||||
--tblr-border-radius: 1rem;
|
||||
--tblr-border-radius-lg: 1rem;
|
||||
--tblr-border-radius-sm: 0.75rem;
|
||||
@@ -62,8 +60,6 @@ body {
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--tblr-primary: #0f172a;
|
||||
--tblr-primary-rgb: 15, 23, 42;
|
||||
--tblr-body-bg: #06080d;
|
||||
--ec-shell-bg: #06080d;
|
||||
--ec-card-shadow: 0 2px 10px rgba(0, 0, 0, 0.22);
|
||||
@@ -326,6 +322,28 @@ body {
|
||||
border: 1px solid var(--ec-card-border);
|
||||
}
|
||||
|
||||
.ec-category-picker {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.ec-category-picker .dropdown-menu,
|
||||
.ec-category-picker-menu {
|
||||
z-index: 1085;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-primary,
|
||||
[data-bs-theme="dark"] .btn-success,
|
||||
[data-bs-theme="dark"] .btn-danger,
|
||||
[data-bs-theme="dark"] .btn-warning,
|
||||
[data-bs-theme="dark"] .btn-info,
|
||||
[data-bs-theme="dark"] .btn-outline-primary,
|
||||
[data-bs-theme="dark"] .btn-outline-secondary,
|
||||
[data-bs-theme="dark"] .btn-outline-danger,
|
||||
[data-bs-theme="dark"] .btn-outline-info,
|
||||
[data-bs-theme="dark"] .btn-outline-warning {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.pv-subnav-shell,
|
||||
@@ -375,3 +393,32 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.ec-picker-badge {
|
||||
color: var(--tblr-body-color);
|
||||
background: rgba(var(--tblr-secondary-rgb), 0.16);
|
||||
border: 1px solid rgba(var(--tblr-secondary-rgb), 0.18);
|
||||
}
|
||||
|
||||
.ec-picker-dot {
|
||||
min-width: 0.75rem;
|
||||
min-height: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .ec-picker-badge {
|
||||
color: #f8fafc;
|
||||
background: rgba(248, 250, 252, 0.12);
|
||||
border-color: rgba(248, 250, 252, 0.18);
|
||||
}
|
||||
|
||||
|
||||
[data-bs-theme="dark"] .badge.bg-dark-lt {
|
||||
color: #f8fafc;
|
||||
background: rgba(248, 250, 252, 0.14) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .badge.bg-secondary {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user