Compare commits
30
Commits
1ba1a26291
..
master
@@ -17,3 +17,6 @@ ADMIN_PASSWORD=ChangeMe123!
|
|||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
MAX_UPLOAD_SIZE_MB=10
|
MAX_UPLOAD_SIZE_MB=10
|
||||||
API_CORS_ORIGIN=http://localhost:4200
|
API_CORS_ORIGIN=http://localhost:4200
|
||||||
|
PUBLIC_HTTP_PORT=8978
|
||||||
|
ADMIN_EMAIL=admin@local.dev
|
||||||
|
ADMIN_PASSWORD=Admin123!
|
||||||
@@ -12,3 +12,5 @@ web/.angular/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
docker-data/postgres/*
|
||||||
|
docker-data/uploads/*
|
||||||
@@ -10,7 +10,6 @@ DB_USER=expense_app
|
|||||||
DB_PASSWORD=expense_app
|
DB_PASSWORD=expense_app
|
||||||
DB_SYNC=true
|
DB_SYNC=true
|
||||||
DB_LOGGING=false
|
DB_LOGGING=false
|
||||||
APP_NAME=Expense Control
|
|
||||||
DEFAULT_CURRENCY=PLN
|
DEFAULT_CURRENCY=PLN
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=ChangeMe123!
|
ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|||||||
Generated
+8
-47
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "expense-control-api",
|
"name": "expense-control-api",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "expense-control-api",
|
"name": "expense-control-api",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -604,9 +604,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -621,9 +618,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -638,9 +632,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -655,9 +646,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -672,9 +660,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -689,9 +674,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -706,9 +688,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -723,9 +702,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -740,9 +716,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -757,9 +730,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -774,9 +744,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -791,9 +758,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -808,9 +772,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1770,9 +1731,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.0",
|
"version": "17.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
||||||
"integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==",
|
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3944,9 +3905,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "expense-control-api",
|
"name": "expense-control-api",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import path from 'node:path';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { AppSetting } from '../entities/AppSetting.js';
|
import { AppSetting } from '../entities/AppSetting.js';
|
||||||
|
import { Budget } from '../entities/Budget.js';
|
||||||
import { Category } from '../entities/Category.js';
|
import { Category } from '../entities/Category.js';
|
||||||
import { Expense } from '../entities/Expense.js';
|
import { Expense } from '../entities/Expense.js';
|
||||||
import { Merchant } from '../entities/Merchant.js';
|
import { Merchant } from '../entities/Merchant.js';
|
||||||
import { Proof } from '../entities/Proof.js';
|
import { Proof } from '../entities/Proof.js';
|
||||||
|
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||||
import { User } from '../entities/User.js';
|
import { User } from '../entities/User.js';
|
||||||
|
|
||||||
const entities = [User, Category, Expense, Proof, AppSetting, Merchant];
|
const entities = [User, Category, Expense, Proof, AppSetting, Merchant, Budget, RecurringExpense];
|
||||||
const baseOptions = { entities, synchronize: env.DB_SYNC, logging: env.DB_LOGGING };
|
const baseOptions = { entities, synchronize: env.DB_SYNC, logging: env.DB_LOGGING };
|
||||||
|
|
||||||
if (env.DB_TYPE === 'sqlite') {
|
if (env.DB_TYPE === 'sqlite') {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const env = {
|
|||||||
DB_PATH: process.env.DB_PATH ?? './data/dev.sqlite',
|
DB_PATH: process.env.DB_PATH ?? './data/dev.sqlite',
|
||||||
DB_SYNC: toBoolean(process.env.DB_SYNC, true),
|
DB_SYNC: toBoolean(process.env.DB_SYNC, true),
|
||||||
DB_LOGGING: toBoolean(process.env.DB_LOGGING, false),
|
DB_LOGGING: toBoolean(process.env.DB_LOGGING, false),
|
||||||
APP_NAME: process.env.APP_NAME ?? 'Expense Control',
|
|
||||||
DEFAULT_CURRENCY: process.env.DEFAULT_CURRENCY ?? 'PLN',
|
DEFAULT_CURRENCY: process.env.DEFAULT_CURRENCY ?? 'PLN',
|
||||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
ADMIN_EMAIL: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
||||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD ?? 'Admin123!',
|
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD ?? 'Admin123!',
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { createRequire } from 'node:module';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
import { AppSetting } from '../entities/AppSetting.js';
|
import { AppSetting } from '../entities/AppSetting.js';
|
||||||
|
import { Budget } from '../entities/Budget.js';
|
||||||
|
import { Category } from '../entities/Category.js';
|
||||||
|
import { Expense } from '../entities/Expense.js';
|
||||||
|
import { Merchant } from '../entities/Merchant.js';
|
||||||
|
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||||
import { User } from '../entities/User.js';
|
import { User } from '../entities/User.js';
|
||||||
import { sanitizeUser } from '../services/auth.service.js';
|
import { createUser, hashPassword, sanitizeUser } from '../services/auth.service.js';
|
||||||
|
import { createSmtpTransport, describeSmtpMode, formatFromAddress } from '../services/mail.service.js';
|
||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -23,15 +33,80 @@ const settingsSchema = z.object({
|
|||||||
smtpFromEmail: z.string().max(160).nullable().optional()
|
smtpFromEmail: z.string().max(160).nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const userUpdateSchema = z.object({
|
const userCreateSchema = z.object({
|
||||||
|
fullName: z.string().min(2).max(120),
|
||||||
|
email: z.email(),
|
||||||
|
password: z.string().min(8).max(100),
|
||||||
role: z.enum(['ADMIN', 'USER']).optional(),
|
role: z.enum(['ADMIN', 'USER']).optional(),
|
||||||
|
defaultCurrency: z.string().min(3).max(8).optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
defaultCurrency: z.string().min(3).max(8).optional()
|
integrationsEnabled: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const userUpdateSchema = z.object({
|
||||||
|
fullName: z.string().min(2).max(120).optional(),
|
||||||
|
email: z.email().optional(),
|
||||||
|
password: z.string().min(8).max(100).optional(),
|
||||||
|
role: z.enum(['ADMIN', 'USER']).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
defaultCurrency: z.string().min(3).max(8).optional(),
|
||||||
|
integrationsEnabled: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
type PackageLike = { version?: string };
|
||||||
|
|
||||||
|
function readPackageJsonSafe(filePath: string): PackageLike | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackageLike;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findApiPackage(): PackageLike | null {
|
||||||
|
return (
|
||||||
|
readPackageJsonSafe(path.resolve(__dirname, '../../package.json')) ??
|
||||||
|
readPackageJsonSafe(path.resolve(__dirname, '../package.json'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRootPackage(): PackageLike | null {
|
||||||
|
const candidates = [
|
||||||
|
path.resolve(__dirname, '../../../package.json'),
|
||||||
|
path.resolve(__dirname, '../../package.json'),
|
||||||
|
path.resolve(__dirname, '../package.json')
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const pkg = readPackageJsonSafe(candidate);
|
||||||
|
if (!pkg) continue;
|
||||||
|
if (candidate.endsWith('/api/package.json') || candidate.endsWith('\\api\\package.json')) continue;
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findWebPackage(): PackageLike | null {
|
||||||
|
return (
|
||||||
|
readPackageJsonSafe(path.resolve(__dirname, '../../../web/package.json')) ??
|
||||||
|
readPackageJsonSafe(path.resolve(__dirname, '../../web/package.json'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPackage = findRootPackage();
|
||||||
|
const apiPackage = findApiPackage();
|
||||||
|
const webPackage = findWebPackage();
|
||||||
|
|
||||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||||
const userRepo = () => AppDataSource.getRepository(User);
|
const userRepo = () => AppDataSource.getRepository(User);
|
||||||
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||||
|
const merchantRepo = () => AppDataSource.getRepository(Merchant);
|
||||||
|
const budgetRepo = () => AppDataSource.getRepository(Budget);
|
||||||
|
const recurringRepo = () => AppDataSource.getRepository(RecurringExpense);
|
||||||
|
|
||||||
const sanitizeSettings = (item: AppSetting) => ({
|
const sanitizeSettings = (item: AppSetting) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -97,6 +172,33 @@ export const listUsers = async (_req: AuthenticatedRequest, res: Response) => {
|
|||||||
return res.json({ items: items.map(sanitizeUser) });
|
return res.json({ items: items.map(sanitizeUser) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createAdminUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = userCreateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ message: 'Invalid user payload', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await createUser({
|
||||||
|
fullName: parsed.data.fullName,
|
||||||
|
email: parsed.data.email,
|
||||||
|
password: parsed.data.password,
|
||||||
|
role: parsed.data.role,
|
||||||
|
defaultCurrency: parsed.data.defaultCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof parsed.data.isActive === 'boolean') user.isActive = parsed.data.isActive;
|
||||||
|
if (typeof parsed.data.integrationsEnabled === 'boolean') user.integrationsEnabled = parsed.data.integrationsEnabled;
|
||||||
|
|
||||||
|
await userRepo().save(user);
|
||||||
|
return res.status(201).json({ item: sanitizeUser(user) });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create user';
|
||||||
|
const status = message === 'Email address is already in use' ? 409 : 500;
|
||||||
|
return res.status(status).json({ message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = userUpdateSchema.safeParse(req.body);
|
const parsed = userUpdateSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -107,9 +209,27 @@ export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
const item = await userRepo().findOne({ where: { id: itemId } });
|
const item = await userRepo().findOne({ where: { id: itemId } });
|
||||||
if (!item) return res.status(404).json({ message: 'User not found' });
|
if (!item) return res.status(404).json({ message: 'User not found' });
|
||||||
|
|
||||||
|
if (parsed.data.email) {
|
||||||
|
const normalizedEmail = parsed.data.email.toLowerCase();
|
||||||
|
if (normalizedEmail !== item.email) {
|
||||||
|
const existing = await userRepo().findOne({ where: { email: normalizedEmail } });
|
||||||
|
if (existing && existing.id !== item.id) {
|
||||||
|
return res.status(409).json({ message: 'Email address is already in use' });
|
||||||
|
}
|
||||||
|
const previousEmail = item.email;
|
||||||
|
item.email = normalizedEmail;
|
||||||
|
if (item.reportPreferences?.sendToEmail === previousEmail) {
|
||||||
|
item.reportPreferences = { ...item.reportPreferences, sendToEmail: normalizedEmail };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.fullName) item.fullName = parsed.data.fullName;
|
||||||
|
if (parsed.data.password) item.passwordHash = await hashPassword(parsed.data.password);
|
||||||
if (parsed.data.role) item.role = parsed.data.role;
|
if (parsed.data.role) item.role = parsed.data.role;
|
||||||
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
||||||
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
|
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
|
||||||
|
if (typeof parsed.data.integrationsEnabled === 'boolean') item.integrationsEnabled = parsed.data.integrationsEnabled;
|
||||||
|
|
||||||
await userRepo().save(item);
|
await userRepo().save(item);
|
||||||
return res.json({ item: sanitizeUser(item) });
|
return res.json({ item: sanitizeUser(item) });
|
||||||
@@ -126,22 +246,58 @@ export const testSmtp = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
return res.status(400).json({ message: 'SMTP is not configured' });
|
return res.status(400).json({ message: 'SMTP is not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
try {
|
||||||
const transport = nodemailer.createTransport({
|
const transport = createSmtpTransport(settings);
|
||||||
host: settings.smtpHost,
|
await transport.verify();
|
||||||
port: settings.smtpPort,
|
|
||||||
secure: settings.smtpSecure,
|
|
||||||
auth: settings.smtpUser ? { user: settings.smtpUser, pass: settings.smtpPassword ?? '' } : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: settings.smtpFromName
|
from: formatFromAddress(settings),
|
||||||
? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>`
|
|
||||||
: settings.smtpFromEmail,
|
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
subject: `${settings.appName} - SMTP test`,
|
subject: `${settings.appName} - SMTP test`,
|
||||||
html: `<div style="font-family:Arial,sans-serif"><h2>SMTP connection works</h2><p>This message was sent from the admin panel test action.</p></div>`
|
html: `<div style="font-family:Arial,sans-serif"><h2>SMTP connection works</h2><p>This message was sent from the admin panel test action.</p></div>`
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ message: 'SMTP test message was sent' });
|
return res.json({ message: 'SMTP test message was sent', mode: describeSmtpMode(settings) });
|
||||||
|
} catch (error) {
|
||||||
|
const rawMessage = error instanceof Error ? error.message : 'SMTP connection failed';
|
||||||
|
const message = rawMessage.includes('wrong version number')
|
||||||
|
? `SMTP handshake failed. For port ${settings.smtpPort} the application now uses ${describeSmtpMode(settings)}.`
|
||||||
|
: rawMessage;
|
||||||
|
return res.status(500).json({ message, mode: describeSmtpMode(settings) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemInfo = async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const settings = await getSettingsEntity();
|
||||||
|
const [users, expenses, categories, merchants, budgets, recurring, usersWithIntegrations] = await Promise.all([
|
||||||
|
userRepo().count(),
|
||||||
|
expenseRepo().count(),
|
||||||
|
categoryRepo().count(),
|
||||||
|
merchantRepo().count(),
|
||||||
|
budgetRepo().count(),
|
||||||
|
recurringRepo().count(),
|
||||||
|
userRepo().find({ select: { id: true, shoppingListIntegration: true } })
|
||||||
|
]);
|
||||||
|
const shoppingIntegrations = usersWithIntegrations.filter((user) => Boolean(user.shoppingListIntegration?.enabled)).length;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
item: {
|
||||||
|
appName: settings?.appName ?? 'Expense Control',
|
||||||
|
suiteVersion: rootPackage?.version ?? apiPackage?.version ?? '0.0.0',
|
||||||
|
apiVersion: apiPackage?.version ?? '0.0.0',
|
||||||
|
webVersion: webPackage?.version ?? '0.0.0',
|
||||||
|
nodeVersion: process.version,
|
||||||
|
environment: process.env.NODE_ENV ?? 'development',
|
||||||
|
database: env.DB_TYPE,
|
||||||
|
uploadDir: env.UPLOAD_DIR,
|
||||||
|
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||||
|
smtpConfigured: Boolean(settings?.smtpEnabled && settings?.smtpHost && settings?.smtpFromEmail),
|
||||||
|
counters: { users, expenses, categories, merchants, budgets, recurring, shoppingIntegrations },
|
||||||
|
sources: {
|
||||||
|
appRepository: 'https://git.linuxiarz.pl/gru/expense-control',
|
||||||
|
shoppingListRepository: 'https://git.linuxiarz.pl/gru/lista_zakupowa_live',
|
||||||
|
apiBasePath: '/api'
|
||||||
|
},
|
||||||
|
checkedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ const loginSchema = z.object({
|
|||||||
password: z.string().min(8).max(100)
|
password: z.string().min(8).max(100)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_APP_NAME = 'Expense Control';
|
||||||
|
|
||||||
export const publicConfig = async (_req: Request, res: Response) => {
|
export const publicConfig = async (_req: Request, res: Response) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
const settings = await AppDataSource.getRepository(AppSetting).find({
|
const settings = await AppDataSource.getRepository(AppSetting).find({
|
||||||
take: 1,
|
take: 1,
|
||||||
order: { createdAt: 'ASC' }
|
order: { createdAt: 'ASC' }
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
appName: settings[0]?.appName ?? 'Expense Control',
|
appName: settings[0]?.appName ?? DEFAULT_APP_NAME,
|
||||||
registrationEnabled: settings[0]?.registrationEnabled ?? true
|
registrationEnabled: settings[0]?.registrationEnabled ?? true
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
|
import { Budget } from '../entities/Budget.js';
|
||||||
|
import { Category } from '../entities/Category.js';
|
||||||
|
import { Expense } from '../entities/Expense.js';
|
||||||
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||||
|
|
||||||
|
const budgetSchema = z.object({
|
||||||
|
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
name: z.string().max(120).nullable().optional(),
|
||||||
|
amount: z.coerce.number().positive(),
|
||||||
|
categoryId: z.string().uuid().nullable().optional(),
|
||||||
|
alertThresholds: z.array(z.coerce.number().min(1).max(100)).default([80, 100]),
|
||||||
|
isActive: z.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
const budgetRepo = () => AppDataSource.getRepository(Budget);
|
||||||
|
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||||
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
|
||||||
|
const serializeBudget = (item: Budget, spent: number) => {
|
||||||
|
const amount = Number(item.amount.toFixed(2));
|
||||||
|
const usagePercent = amount ? Number(((spent / amount) * 100).toFixed(1)) : 0;
|
||||||
|
const alertLevel = (item.alertThresholds ?? [80, 100]).filter((threshold) => usagePercent >= threshold).sort((a, b) => b - a)[0] ?? null;
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
month: item.month,
|
||||||
|
name: item.name,
|
||||||
|
amount,
|
||||||
|
spent: Number(spent.toFixed(2)),
|
||||||
|
usagePercent,
|
||||||
|
alertLevel,
|
||||||
|
alertThresholds: item.alertThresholds ?? [80, 100],
|
||||||
|
isActive: item.isActive,
|
||||||
|
category: item.category
|
||||||
|
? {
|
||||||
|
id: item.category.id,
|
||||||
|
name: item.category.name,
|
||||||
|
color: item.category.color,
|
||||||
|
isSystem: item.category.isSystem,
|
||||||
|
ownerId: item.category.user?.id ?? null
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.updatedAt
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` });
|
||||||
|
const normalizeNullableText = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
const month = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
|
||||||
|
? req.query.month
|
||||||
|
: `${new Date().getFullYear()}-${`${new Date().getMonth() + 1}`.padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const budgets = await budgetRepo().find({
|
||||||
|
where: { user: { id: req.user!.id } },
|
||||||
|
relations: { category: { user: true }, user: true },
|
||||||
|
order: { month: 'DESC', createdAt: 'DESC' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthBudgets = budgets.filter((item) => item.month === month);
|
||||||
|
const { startDate, endDate } = getMonthRange(month);
|
||||||
|
const expenses = await expenseRepo().find({
|
||||||
|
where: { user: { id: req.user!.id } },
|
||||||
|
relations: { category: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopedExpenses = expenses.filter((item) => item.expenseDate >= startDate && item.expenseDate <= endDate && item.status !== 'REJECTED' && item.status !== 'DRAFT');
|
||||||
|
const items = monthBudgets.map((item) => {
|
||||||
|
const spent = scopedExpenses
|
||||||
|
.filter((expense) => !item.category || expense.category.id === item.category.id)
|
||||||
|
.reduce((sum, expense) => sum + expense.amount, 0);
|
||||||
|
return serializeBudget(item, spent);
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = items.filter((item) => item.alertLevel !== null).map((item) => ({
|
||||||
|
budgetId: item.id,
|
||||||
|
message: `${item.name || item.category?.name || 'Monthly budget'} reached ${item.usagePercent}% of the limit.`,
|
||||||
|
usagePercent: item.usagePercent,
|
||||||
|
level: item.alertLevel
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
month,
|
||||||
|
items,
|
||||||
|
summary: {
|
||||||
|
totalBudget: Number(items.reduce((sum, item) => sum + item.amount, 0).toFixed(2)),
|
||||||
|
totalSpent: Number(items.reduce((sum, item) => sum + item.spent, 0).toFixed(2)),
|
||||||
|
alerts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const category = parsed.data.categoryId
|
||||||
|
? await categoryRepo().findOne({
|
||||||
|
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||||
|
relations: { user: true }
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (parsed.data.categoryId && !category) return res.status(404).json({ message: 'Category not found' });
|
||||||
|
|
||||||
|
const saved = await budgetRepo().save(
|
||||||
|
budgetRepo().create({
|
||||||
|
month: parsed.data.month,
|
||||||
|
name: parsed.data.name ?? null,
|
||||||
|
amount: parsed.data.amount,
|
||||||
|
alertThresholds: parsed.data.alertThresholds,
|
||||||
|
isActive: parsed.data.isActive,
|
||||||
|
category,
|
||||||
|
user: { id: req.user!.id } as never
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const full = await budgetRepo().findOneOrFail({ where: { id: saved.id }, relations: { category: { user: true }, user: true } });
|
||||||
|
return res.status(201).json({ item: serializeBudget(full, 0) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } });
|
||||||
|
if (!item) return res.status(404).json({ message: 'Budget not found' });
|
||||||
|
|
||||||
|
const category = parsed.data.categoryId
|
||||||
|
? await categoryRepo().findOne({
|
||||||
|
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||||
|
relations: { user: true }
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (parsed.data.categoryId && !category) return res.status(404).json({ message: 'Category not found' });
|
||||||
|
|
||||||
|
item.month = parsed.data.month;
|
||||||
|
item.name = parsed.data.name ?? null;
|
||||||
|
item.amount = parsed.data.amount;
|
||||||
|
item.alertThresholds = parsed.data.alertThresholds;
|
||||||
|
item.isActive = parsed.data.isActive;
|
||||||
|
item.category = category;
|
||||||
|
|
||||||
|
await budgetRepo().save(item);
|
||||||
|
return res.json({ item: serializeBudget(item, 0) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } } });
|
||||||
|
if (!item) return res.status(404).json({ message: 'Budget not found' });
|
||||||
|
await budgetRepo().remove(item);
|
||||||
|
return res.status(204).send();
|
||||||
|
};
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { Category } from '../entities/Category.js';
|
import { Category } from '../entities/Category.js';
|
||||||
import { Expense } from '../entities/Expense.js';
|
import { Expense, type DuplicateReviewStatus, type ExpenseStatus } from '../entities/Expense.js';
|
||||||
import { Proof } from '../entities/Proof.js';
|
import { Proof } from '../entities/Proof.js';
|
||||||
import { User } from '../entities/User.js';
|
import { User } from '../entities/User.js';
|
||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
import { serializeProof } from '../utils/http.js';
|
import { serializeProof } from '../utils/http.js';
|
||||||
|
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||||
|
|
||||||
const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']).nullable().optional();
|
const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']).nullable().optional();
|
||||||
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
|
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
|
||||||
|
const statusSchema = z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']);
|
||||||
|
const duplicateReviewSchema = z.object({ action: z.enum(['CONFIRM', 'DISMISS', 'REOPEN']) });
|
||||||
|
const expenseStatusUpdateSchema = z.object({ status: statusSchema });
|
||||||
|
const bulkIdsSchema = z.array(z.string().uuid()).min(1).max(200);
|
||||||
|
const bulkExpenseStatusUpdateSchema = z.object({ ids: bulkIdsSchema, status: statusSchema });
|
||||||
|
const bulkExpenseDeleteSchema = z.object({ ids: bulkIdsSchema });
|
||||||
|
|
||||||
const createExpenseSchema = z.object({
|
const createExpenseSchema = z.object({
|
||||||
title: z.string().min(2).max(140),
|
title: z.string().min(2).max(140),
|
||||||
@@ -23,6 +31,9 @@ const createExpenseSchema = z.object({
|
|||||||
merchant: z.string().max(120).nullable().optional(),
|
merchant: z.string().max(120).nullable().optional(),
|
||||||
paymentMethod: paymentMethodSchema,
|
paymentMethod: paymentMethodSchema,
|
||||||
currency: z.string().min(3).max(8).default('PLN'),
|
currency: z.string().min(3).max(8).default('PLN'),
|
||||||
|
status: statusSchema.default('PENDING'),
|
||||||
|
tags: z.array(z.string().min(1).max(40)).default([]),
|
||||||
|
customFields: z.record(z.string(), z.string()).default({}),
|
||||||
proofType: proofTypeSchema.optional(),
|
proofType: proofTypeSchema.optional(),
|
||||||
proofLabel: z.string().max(150).nullable().optional(),
|
proofLabel: z.string().max(150).nullable().optional(),
|
||||||
proofNote: z.string().max(1000).nullable().optional()
|
proofNote: z.string().max(1000).nullable().optional()
|
||||||
@@ -36,11 +47,18 @@ const updateExpenseSchema = z.object({
|
|||||||
categoryId: z.string().uuid(),
|
categoryId: z.string().uuid(),
|
||||||
merchant: z.string().max(120).nullable().optional(),
|
merchant: z.string().max(120).nullable().optional(),
|
||||||
paymentMethod: paymentMethodSchema,
|
paymentMethod: paymentMethodSchema,
|
||||||
currency: z.string().min(3).max(8).default('PLN')
|
currency: z.string().min(3).max(8).default('PLN'),
|
||||||
|
status: statusSchema.default('PENDING'),
|
||||||
|
tags: z.array(z.string().min(1).max(40)).default([]),
|
||||||
|
customFields: z.record(z.string(), z.string()).default({}),
|
||||||
|
proofType: proofTypeSchema.optional(),
|
||||||
|
proofLabel: z.string().max(150).nullable().optional(),
|
||||||
|
proofNote: z.string().max(1000).nullable().optional(),
|
||||||
|
removeProofIds: z.array(z.string().uuid()).default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
const addProofSchema = z.object({
|
const addProofSchema = z.object({
|
||||||
type: proofTypeSchema,
|
type: proofTypeSchema.default('OTHER'),
|
||||||
label: z.string().max(150).nullable().optional(),
|
label: z.string().max(150).nullable().optional(),
|
||||||
note: z.string().max(1000).nullable().optional()
|
note: z.string().max(1000).nullable().optional()
|
||||||
});
|
});
|
||||||
@@ -50,6 +68,114 @@ const categoryRepo = () => AppDataSource.getRepository(Category);
|
|||||||
const userRepo = () => AppDataSource.getRepository(User);
|
const userRepo = () => AppDataSource.getRepository(User);
|
||||||
const proofRepo = () => AppDataSource.getRepository(Proof);
|
const proofRepo = () => AppDataSource.getRepository(Proof);
|
||||||
|
|
||||||
|
const getUploadedFiles = (req: Request) => {
|
||||||
|
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||||
|
const single = req.file ? [req.file] : [];
|
||||||
|
return [...single, ...files].filter((file) => ['proofFile', 'proofFiles'].includes(file.fieldname));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUploadedFile = (filename?: string) => {
|
||||||
|
if (!filename) return;
|
||||||
|
const filePath = path.resolve(env.UPLOAD_DIR, filename);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUploadedFiles = (files: Express.Multer.File[]) => {
|
||||||
|
files.forEach((file) => removeUploadedFile(file.filename));
|
||||||
|
};
|
||||||
|
|
||||||
|
const slugifyForFilename = (value: string) =>
|
||||||
|
value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80) || 'expense';
|
||||||
|
|
||||||
|
const deriveFileExtension = (file: Express.Multer.File) => {
|
||||||
|
const fromOriginal = path.extname(file.originalname || '').trim();
|
||||||
|
if (fromOriginal) return fromOriginal.toLowerCase();
|
||||||
|
const fromStored = path.extname(file.filename || '').trim();
|
||||||
|
if (fromStored) return fromStored.toLowerCase();
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('pdf')) return '.pdf';
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('png')) return '.png';
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('jpeg') || (file.mimetype || '').toLowerCase().includes('jpg')) return '.jpg';
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('webp')) return '.webp';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameUploadedFilesForExpense = (files: Express.Multer.File[], expenseTitle: string, proofType?: string | null) => {
|
||||||
|
const base = [slugifyForFilename(expenseTitle), proofType?.toLowerCase() || null].filter(Boolean).join('-');
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
const extension = deriveFileExtension(file);
|
||||||
|
const nextFilename = `${base || 'expense'}-${Date.now()}-${index + 1}${extension}`.slice(0, 220);
|
||||||
|
const fromPath = path.resolve(env.UPLOAD_DIR, file.filename);
|
||||||
|
const toPath = path.resolve(env.UPLOAD_DIR, nextFilename);
|
||||||
|
if (fromPath !== toPath && fs.existsSync(fromPath)) fs.renameSync(fromPath, toPath);
|
||||||
|
file.filename = nextFilename;
|
||||||
|
file.originalname = nextFilename;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeIdList = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
} catch {}
|
||||||
|
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTagList = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
} catch {}
|
||||||
|
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCustomFields = (value: unknown) => {
|
||||||
|
if (!value) return {} as Record<string, string>;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(parsed as Record<string, unknown>)
|
||||||
|
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||||
|
.filter(([key, item]) => Boolean(key && item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||||
|
.filter(([key, item]) => Boolean(key && item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichPayload = (body: Record<string, unknown>) => ({
|
||||||
|
...body,
|
||||||
|
tags: normalizeTagList(body.tags),
|
||||||
|
customFields: normalizeCustomFields(body.customFields),
|
||||||
|
removeProofIds: normalizeIdList(body.removeProofIds)
|
||||||
|
});
|
||||||
|
|
||||||
const serializeExpense = (expense: Expense) => ({
|
const serializeExpense = (expense: Expense) => ({
|
||||||
id: expense.id,
|
id: expense.id,
|
||||||
title: expense.title,
|
title: expense.title,
|
||||||
@@ -59,7 +185,13 @@ const serializeExpense = (expense: Expense) => ({
|
|||||||
merchant: expense.merchant,
|
merchant: expense.merchant,
|
||||||
paymentMethod: expense.paymentMethod,
|
paymentMethod: expense.paymentMethod,
|
||||||
currency: expense.currency,
|
currency: expense.currency,
|
||||||
|
status: expense.status,
|
||||||
|
tags: expense.tags ?? [],
|
||||||
|
customFields: expense.customFields ?? {},
|
||||||
possibleDuplicate: expense.possibleDuplicate,
|
possibleDuplicate: expense.possibleDuplicate,
|
||||||
|
duplicateStatus: expense.duplicateStatus,
|
||||||
|
duplicateReviewedAt: expense.duplicateReviewedAt,
|
||||||
|
recurringSourceId: expense.recurringSourceId,
|
||||||
category: {
|
category: {
|
||||||
id: expense.category.id,
|
id: expense.category.id,
|
||||||
name: expense.category.name,
|
name: expense.category.name,
|
||||||
@@ -67,37 +199,116 @@ const serializeExpense = (expense: Expense) => ({
|
|||||||
isSystem: expense.category.isSystem,
|
isSystem: expense.category.isSystem,
|
||||||
ownerId: expense.category.user?.id ?? null
|
ownerId: expense.category.user?.id ?? null
|
||||||
},
|
},
|
||||||
proofs: expense.proofs?.map(serializeProof) ?? [],
|
proofs: expense.proofs?.map((proof) => serializeProof(proof, expense.id)) ?? [],
|
||||||
createdAt: expense.createdAt,
|
createdAt: expense.createdAt,
|
||||||
updatedAt: expense.updatedAt
|
updatedAt: expense.updatedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeUploadedFile = (filename?: string) => {
|
const normalizeInvoiceKey = (expense: Expense) =>
|
||||||
if (!filename) return;
|
Object.entries(expense.customFields ?? {}).find(([key]) => key.toLowerCase().includes('invoice'))?.[1]?.trim().toLowerCase() ?? null;
|
||||||
const filePath = path.resolve(env.UPLOAD_DIR, filename);
|
|
||||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDuplicate = async (userId: string, amount: number, expenseDate: string, merchant?: string | null) => {
|
const findDuplicateMatches = async (input: { userId: string; expenseId?: string; amount: number; expenseDate: string; merchant?: string | null; title?: string; customFields?: Record<string, string> }) => {
|
||||||
const items = await expenseRepo().find({
|
const items = await expenseRepo().find({
|
||||||
where: { user: { id: userId }, expenseDate },
|
where: { user: { id: input.userId } },
|
||||||
order: { createdAt: 'DESC' },
|
relations: { category: { user: true }, proofs: true, user: true },
|
||||||
take: 5
|
order: { expenseDate: 'DESC', createdAt: 'DESC' },
|
||||||
|
take: 200
|
||||||
});
|
});
|
||||||
|
|
||||||
const merchantKey = merchant?.trim().toLowerCase();
|
const invoiceKey = Object.entries(input.customFields ?? {}).find(([key]) => key.toLowerCase().includes('invoice'))?.[1]?.trim().toLowerCase();
|
||||||
return items.some(
|
const merchantKey = input.merchant?.trim().toLowerCase();
|
||||||
(item) =>
|
const inputDate = new Date(`${input.expenseDate}T00:00:00`).getTime();
|
||||||
Math.abs(item.amount - amount) < 0.001 &&
|
|
||||||
((merchantKey && item.merchant?.trim().toLowerCase() === merchantKey) || !merchantKey)
|
return items.filter((item) => {
|
||||||
);
|
if (input.expenseId && item.id === input.expenseId) return false;
|
||||||
|
if (item.duplicateStatus === 'DISMISSED') return false;
|
||||||
|
const itemDate = new Date(`${item.expenseDate}T00:00:00`).getTime();
|
||||||
|
const sameAmount = Math.abs(item.amount - input.amount) < 0.001;
|
||||||
|
const sameMerchant = merchantKey ? item.merchant?.trim().toLowerCase() === merchantKey : false;
|
||||||
|
const sameInvoice = invoiceKey ? normalizeInvoiceKey(item) === invoiceKey : false;
|
||||||
|
const closeDate = Math.abs(itemDate - inputDate) <= 1000 * 60 * 60 * 24 * 3;
|
||||||
|
const sameTitle = input.title ? item.title.trim().toLowerCase() === input.title.trim().toLowerCase() : false;
|
||||||
|
return sameInvoice || (sameAmount && closeDate && (sameMerchant || sameTitle));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWarnings = (duplicates: Expense[], amount: number, expenseDate: string) => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (duplicates.length) warnings.push(`Possible duplicate detected (${duplicates.length} matching expense${duplicates.length > 1 ? 's' : ''}).`);
|
||||||
|
if (new Date(`${expenseDate}T00:00:00`).getTime() > Date.now() + 1000 * 60 * 60 * 24) warnings.push('Expense date is in the future.');
|
||||||
|
if (amount > 50000) warnings.push('Unusually high amount. Please double-check the value.');
|
||||||
|
return warnings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hydrateExpense = (id: string) =>
|
||||||
|
expenseRepo().findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
relations: { category: { user: true }, proofs: true, user: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseFilterArray = (value: string | undefined) => value?.split(',').map((item) => item.trim()).filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const sortBySchema = z.enum(['expenseDate', 'title', 'amount', 'status', 'category', 'merchant', 'createdAt', 'updatedAt']).catch('expenseDate');
|
||||||
|
const sortDirSchema = z.enum(['asc', 'desc']).catch('desc');
|
||||||
|
|
||||||
|
const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED'];
|
||||||
|
const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
|
||||||
|
DRAFT: ['DRAFT', 'PENDING', 'REJECTED'],
|
||||||
|
PENDING: ['DRAFT', 'PENDING', 'APPROVED', 'REJECTED'],
|
||||||
|
APPROVED: ['APPROVED', 'PENDING'],
|
||||||
|
REJECTED: ['REJECTED', 'DRAFT', 'PENDING']
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateInitialStatus = (nextStatus: ExpenseStatus) => initialStatuses.includes(nextStatus);
|
||||||
|
const parsePositiveInt = (value: unknown, fallback: number, max = 100) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.min(Math.max(Math.trunc(parsed), 1), max);
|
||||||
|
};
|
||||||
|
const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus);
|
||||||
|
const isShoppingListImportedExpense = (customFields?: Record<string, string> | null) => String(customFields?.externalSource ?? '').trim().toLowerCase() === 'shopping-list-api';
|
||||||
|
const approvalNeedsProof = (nextStatus: ExpenseStatus, customFields?: Record<string, string> | null) => nextStatus === 'APPROVED' && !isShoppingListImportedExpense(customFields);
|
||||||
|
|
||||||
|
const loadOwnedExpenses = async (ids: string[], user: AuthenticatedRequest['user']) => {
|
||||||
|
const where = user?.role === 'ADMIN'
|
||||||
|
? { id: In(ids) }
|
||||||
|
: { id: In(ids), user: { id: user!.id } };
|
||||||
|
|
||||||
|
return expenseRepo().find({
|
||||||
|
where,
|
||||||
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
|
||||||
|
if (!duplicates.length) {
|
||||||
|
expense.possibleDuplicate = false;
|
||||||
|
expense.duplicateStatus = null;
|
||||||
|
expense.duplicateReviewedAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expense.possibleDuplicate = true;
|
||||||
|
if (expense.duplicateStatus !== 'CONFIRMED') {
|
||||||
|
expense.duplicateStatus = 'OPEN';
|
||||||
|
expense.duplicateReviewedAt = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
export const listExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
|
||||||
const startDate = typeof req.query.startDate === 'string' ? req.query.startDate : undefined;
|
const startDate = typeof req.query.startDate === 'string' ? req.query.startDate : undefined;
|
||||||
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined;
|
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined;
|
||||||
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
|
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
|
||||||
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase() : undefined;
|
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase().trim() : undefined;
|
||||||
|
const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase().trim() : undefined;
|
||||||
|
const tags = parseFilterArray(typeof req.query.tags === 'string' ? req.query.tags : undefined).map((item) => item.toLowerCase());
|
||||||
|
const duplicatesOnly = String(req.query.duplicatesOnly ?? '') === 'true';
|
||||||
|
const pageSize = parsePositiveInt(req.query.pageSize, 20, 100);
|
||||||
|
const requestedPage = parsePositiveInt(req.query.page, 1, 100000);
|
||||||
|
const sortBy = sortBySchema.parse(req.query.sortBy);
|
||||||
|
const sortDir = sortDirSchema.parse(req.query.sortDir);
|
||||||
|
|
||||||
const items = await expenseRepo().find({
|
const items = await expenseRepo().find({
|
||||||
where: { user: { id: req.user!.id } },
|
where: { user: { id: req.user!.id } },
|
||||||
@@ -109,23 +320,134 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
if (startDate && item.expenseDate < startDate) return false;
|
if (startDate && item.expenseDate < startDate) return false;
|
||||||
if (endDate && item.expenseDate > endDate) return false;
|
if (endDate && item.expenseDate > endDate) return false;
|
||||||
if (categoryId && item.category.id !== categoryId) return false;
|
if (categoryId && item.category.id !== categoryId) return false;
|
||||||
|
if (status && item.status !== status) return false;
|
||||||
|
if (duplicatesOnly && !(item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED')) return false;
|
||||||
|
if (tags.length) {
|
||||||
|
const itemTags = (item.tags ?? []).map((tag) => tag.toLowerCase());
|
||||||
|
if (!tags.every((tag) => itemTags.includes(tag))) return false;
|
||||||
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
const haystack = [item.title, item.description ?? '', item.merchant ?? ''].join(' ').toLowerCase();
|
const customValues = Object.entries(item.customFields ?? {}).flatMap(([key, value]) => [key, value]);
|
||||||
|
const haystack = [item.title, item.description ?? '', item.merchant ?? '', ...(item.tags ?? []), ...customValues]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
if (!haystack.includes(search)) return false;
|
if (!haystack.includes(search)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ items: filtered.map(serializeExpense) });
|
const sorted = [...filtered].sort((left, right) => {
|
||||||
|
const direction = sortDir === 'asc' ? 1 : -1;
|
||||||
|
const leftValue = (() => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'title': return left.title ?? '';
|
||||||
|
case 'amount': return left.amount ?? 0;
|
||||||
|
case 'status': return left.status ?? '';
|
||||||
|
case 'category': return left.category?.name ?? '';
|
||||||
|
case 'merchant': return left.merchant ?? '';
|
||||||
|
case 'createdAt': return left.createdAt?.toISOString?.() ?? String(left.createdAt ?? '');
|
||||||
|
case 'updatedAt': return left.updatedAt?.toISOString?.() ?? String(left.updatedAt ?? '');
|
||||||
|
case 'expenseDate':
|
||||||
|
default: return left.expenseDate ?? '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const rightValue = (() => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'title': return right.title ?? '';
|
||||||
|
case 'amount': return right.amount ?? 0;
|
||||||
|
case 'status': return right.status ?? '';
|
||||||
|
case 'category': return right.category?.name ?? '';
|
||||||
|
case 'merchant': return right.merchant ?? '';
|
||||||
|
case 'createdAt': return right.createdAt?.toISOString?.() ?? String(right.createdAt ?? '');
|
||||||
|
case 'updatedAt': return right.updatedAt?.toISOString?.() ?? String(right.updatedAt ?? '');
|
||||||
|
case 'expenseDate':
|
||||||
|
default: return right.expenseDate ?? '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (typeof leftValue === 'number' && typeof rightValue === 'number') return (leftValue - rightValue) * direction;
|
||||||
|
return String(leftValue).localeCompare(String(rightValue), 'pl', { sensitivity: 'base' }) * direction;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = sorted.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const page = Math.min(requestedPage, totalPages);
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const paged = sorted.slice(startIndex, startIndex + pageSize);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
items: paged.map(serializeExpense),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
hasPrev: page > 1,
|
||||||
|
hasNext: page < totalPages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const id = String(req.params.id || '').trim();
|
||||||
|
const where = req.user?.role === 'ADMIN'
|
||||||
|
? { id }
|
||||||
|
: { id, user: { id: req.user!.id } };
|
||||||
|
|
||||||
|
const item = await expenseRepo().findOne({
|
||||||
|
where,
|
||||||
|
relations: { category: { user: true }, proofs: true, user: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
return res.json({ item: serializeExpense(item) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDuplicates = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
const items = await expenseRepo().find({
|
||||||
|
where: { user: { id: req.user!.id } },
|
||||||
|
relations: { category: { user: true }, proofs: true, user: true },
|
||||||
|
order: { expenseDate: 'DESC', createdAt: 'DESC' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const flagged = items.filter((item) => item.possibleDuplicate && item.duplicateStatus === 'OPEN');
|
||||||
|
const groups = await Promise.all(
|
||||||
|
flagged.map(async (item) => ({
|
||||||
|
source: serializeExpense(item),
|
||||||
|
matches: (await findDuplicateMatches({
|
||||||
|
userId: req.user!.id,
|
||||||
|
expenseId: item.id,
|
||||||
|
amount: item.amount,
|
||||||
|
expenseDate: item.expenseDate,
|
||||||
|
merchant: item.merchant,
|
||||||
|
title: item.title,
|
||||||
|
customFields: item.customFields ?? {}
|
||||||
|
})).slice(0, 5).map(serializeExpense)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ items: groups.filter((group) => group.matches.length) });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createExpense = async (req: AuthenticatedRequest, res: Response) => {
|
export const createExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = createExpenseSchema.safeParse(req.body);
|
const uploadedFiles = getUploadedFiles(req);
|
||||||
|
const parsed = createExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
removeUploadedFile(req.file?.filename);
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validateInitialStatus(parsed.data.status)) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(400).json({ message: 'A new expense can start only as draft, pending, or approved.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalNeedsProof(parsed.data.status, parsed.data.customFields) && uploadedFiles.length === 0) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(400).json({ message: 'An attachment is required before an expense can be approved.' });
|
||||||
|
}
|
||||||
|
|
||||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||||
const category = await categoryRepo().findOne({
|
const category = await categoryRepo().findOne({
|
||||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||||
@@ -133,27 +455,48 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !category) {
|
if (!user || !category) {
|
||||||
removeUploadedFile(req.file?.filename);
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(404).json({ message: 'Category not found' });
|
return res.status(404).json({ message: 'Category not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const proofs: Proof[] = [];
|
const duplicates = await findDuplicateMatches({
|
||||||
if (req.file || parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType) {
|
userId: req.user!.id,
|
||||||
|
amount: parsed.data.amount,
|
||||||
|
expenseDate: parsed.data.expenseDate,
|
||||||
|
merchant: parsed.data.merchant,
|
||||||
|
title: parsed.data.title,
|
||||||
|
customFields: parsed.data.customFields
|
||||||
|
});
|
||||||
|
|
||||||
|
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
|
||||||
|
|
||||||
|
const proofs: Proof[] = uploadedFiles.map((file, index) =>
|
||||||
|
proofRepo().create({
|
||||||
|
type: parsed.data.proofType ?? 'OTHER',
|
||||||
|
label: uploadedFiles.length === 1 ? (parsed.data.proofLabel ?? file.originalname ?? 'Attachment') : file.originalname,
|
||||||
|
note: uploadedFiles.length === 1 && index === 0 ? (parsed.data.proofNote ?? null) : null,
|
||||||
|
originalName: file.originalname ?? null,
|
||||||
|
storedName: file.filename ?? null,
|
||||||
|
mimeType: file.mimetype ?? null,
|
||||||
|
fileSize: file.size ?? null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!proofs.length && (parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType)) {
|
||||||
proofs.push(
|
proofs.push(
|
||||||
proofRepo().create({
|
proofRepo().create({
|
||||||
type: parsed.data.proofType ?? 'OTHER',
|
type: parsed.data.proofType ?? 'OTHER',
|
||||||
label: parsed.data.proofLabel ?? req.file?.originalname ?? 'Attachment',
|
label: parsed.data.proofLabel ?? 'Attachment',
|
||||||
note: parsed.data.proofNote ?? null,
|
note: parsed.data.proofNote ?? null,
|
||||||
originalName: req.file?.originalname ?? null,
|
originalName: null,
|
||||||
storedName: req.file?.filename ?? null,
|
storedName: null,
|
||||||
mimeType: req.file?.mimetype ?? null,
|
mimeType: null,
|
||||||
fileSize: req.file?.size ?? null
|
fileSize: null
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await expenseRepo().save(
|
const item = expenseRepo().create({
|
||||||
expenseRepo().create({
|
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
description: parsed.data.description ?? null,
|
description: parsed.data.description ?? null,
|
||||||
amount: parsed.data.amount,
|
amount: parsed.data.amount,
|
||||||
@@ -161,27 +504,184 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
merchant: parsed.data.merchant ?? null,
|
merchant: parsed.data.merchant ?? null,
|
||||||
paymentMethod: parsed.data.paymentMethod ?? null,
|
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||||
currency: parsed.data.currency,
|
currency: parsed.data.currency,
|
||||||
possibleDuplicate: await isDuplicate(req.user!.id, parsed.data.amount, parsed.data.expenseDate, parsed.data.merchant),
|
status: parsed.data.status,
|
||||||
|
tags: parsed.data.tags,
|
||||||
|
customFields: parsed.data.customFields,
|
||||||
|
possibleDuplicate: false,
|
||||||
|
duplicateStatus: null,
|
||||||
|
duplicateReviewedAt: null,
|
||||||
|
recurringSourceId: null,
|
||||||
user,
|
user,
|
||||||
category,
|
category,
|
||||||
proofs
|
proofs
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const fullItem = await expenseRepo().findOneOrFail({
|
|
||||||
where: { id: item.id },
|
|
||||||
relations: { category: { user: true }, proofs: true, user: true }
|
|
||||||
});
|
});
|
||||||
|
applyDuplicateState(item, duplicates);
|
||||||
|
|
||||||
return res.status(201).json({ item: serializeExpense(fullItem) });
|
await expenseRepo().save(item);
|
||||||
|
|
||||||
|
const fullItem = await hydrateExpense(item.id);
|
||||||
|
return res.status(201).json({ item: serializeExpense(fullItem), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
|
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = updateExpenseSchema.safeParse(req.body);
|
const uploadedFiles = getUploadedFiles(req);
|
||||||
|
const parsed = updateExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const item = await expenseRepo().findOne({
|
||||||
|
where: { id: String(req.params.id) },
|
||||||
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
|
});
|
||||||
|
if (!item) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
}
|
||||||
|
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateStatusTransition(item.status, parsed.data.status)) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await categoryRepo().findOne({
|
||||||
|
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||||
|
relations: { user: true }
|
||||||
|
});
|
||||||
|
if (!category) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(404).json({ message: 'Category not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proofIdsToRemove = new Set(parsed.data.removeProofIds);
|
||||||
|
const remainingProofs = item.proofs.filter((proof) => !proofIdsToRemove.has(proof.id));
|
||||||
|
if (approvalNeedsProof(parsed.data.status, parsed.data.customFields) && remainingProofs.length + uploadedFiles.length === 0) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
|
||||||
|
|
||||||
|
const duplicates = await findDuplicateMatches({
|
||||||
|
userId: req.user!.id,
|
||||||
|
expenseId: item.id,
|
||||||
|
amount: parsed.data.amount,
|
||||||
|
expenseDate: parsed.data.expenseDate,
|
||||||
|
merchant: parsed.data.merchant,
|
||||||
|
title: parsed.data.title,
|
||||||
|
customFields: parsed.data.customFields
|
||||||
|
});
|
||||||
|
|
||||||
|
item.title = parsed.data.title;
|
||||||
|
item.description = parsed.data.description ?? null;
|
||||||
|
item.amount = parsed.data.amount;
|
||||||
|
item.expenseDate = parsed.data.expenseDate;
|
||||||
|
item.merchant = parsed.data.merchant ?? null;
|
||||||
|
item.paymentMethod = parsed.data.paymentMethod ?? null;
|
||||||
|
item.currency = parsed.data.currency;
|
||||||
|
item.status = parsed.data.status;
|
||||||
|
item.tags = parsed.data.tags;
|
||||||
|
item.customFields = parsed.data.customFields;
|
||||||
|
item.category = category;
|
||||||
|
applyDuplicateState(item, duplicates);
|
||||||
|
|
||||||
|
if (proofIdsToRemove.size) {
|
||||||
|
const proofsToDelete = item.proofs.filter((proof) => proofIdsToRemove.has(proof.id));
|
||||||
|
proofsToDelete.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined));
|
||||||
|
if (proofsToDelete.length) await proofRepo().remove(proofsToDelete);
|
||||||
|
item.proofs = remainingProofs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdProofs = uploadedFiles.map((file, index) =>
|
||||||
|
proofRepo().create({
|
||||||
|
type: parsed.data.proofType ?? 'OTHER',
|
||||||
|
label: uploadedFiles.length === 1 ? (parsed.data.proofLabel ?? file.originalname ?? 'Attachment') : file.originalname,
|
||||||
|
note: uploadedFiles.length === 1 && index === 0 ? (parsed.data.proofNote ?? null) : null,
|
||||||
|
originalName: file.originalname ?? null,
|
||||||
|
storedName: file.filename ?? null,
|
||||||
|
mimeType: file.mimetype ?? null,
|
||||||
|
fileSize: file.size ?? null,
|
||||||
|
expense: item
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!createdProofs.length && !item.proofs.length && (parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType)) {
|
||||||
|
createdProofs.push(
|
||||||
|
proofRepo().create({
|
||||||
|
type: parsed.data.proofType ?? 'OTHER',
|
||||||
|
label: parsed.data.proofLabel ?? 'Attachment',
|
||||||
|
note: parsed.data.proofNote ?? null,
|
||||||
|
originalName: null,
|
||||||
|
storedName: null,
|
||||||
|
mimeType: null,
|
||||||
|
fileSize: null,
|
||||||
|
expense: item
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expenseRepo().save(item);
|
||||||
|
if (createdProofs.length) await proofRepo().save(createdProofs);
|
||||||
|
|
||||||
|
const refreshed = await hydrateExpense(item.id);
|
||||||
|
return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkUpdateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = bulkExpenseStatusUpdateSchema.safeParse({
|
||||||
|
ids: Array.isArray(req.body?.ids) ? req.body.ids : [],
|
||||||
|
status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status
|
||||||
|
});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense status payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
|
||||||
|
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
|
||||||
|
|
||||||
|
const invalidTransition = items.find((item) => !validateStatusTransition(item.status, parsed.data.status));
|
||||||
|
if (invalidTransition) {
|
||||||
|
return res.status(400).json({ message: `Status transition from ${invalidTransition.status} to ${parsed.data.status} is not allowed for ${invalidTransition.title}.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingProof = items.find((item) => approvalNeedsProof(parsed.data.status, item.customFields) && !item.proofs.length);
|
||||||
|
if (missingProof) {
|
||||||
|
return res.status(400).json({ message: `Add at least one attachment before approving ${missingProof.title}.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
item.status = parsed.data.status;
|
||||||
|
});
|
||||||
|
await expenseRepo().save(items);
|
||||||
|
|
||||||
|
const refreshed = await expenseRepo().find({
|
||||||
|
where: { id: In(items.map((item) => item.id)) },
|
||||||
|
relations: { category: { user: true }, proofs: true, user: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ items: refreshed.map(serializeExpense), updated: refreshed.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkDeleteExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = bulkExpenseDeleteSchema.safeParse({ ids: Array.isArray(req.body?.ids) ? req.body.ids : [] });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense delete payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
|
||||||
|
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
|
||||||
|
|
||||||
|
items.forEach((item) => item.proofs.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined)));
|
||||||
|
await expenseRepo().remove(items);
|
||||||
|
|
||||||
|
return res.json({ deleted: items.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = expenseStatusUpdateSchema.safeParse({ status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid expense status payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
const item = await expenseRepo().findOne({
|
const item = await expenseRepo().findOne({
|
||||||
where: { id: String(req.params.id) },
|
where: { id: String(req.params.id) },
|
||||||
relations: { user: true, category: { user: true }, proofs: true }
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
@@ -191,23 +691,65 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await categoryRepo().findOne({
|
if (!validateStatusTransition(item.status, parsed.data.status)) {
|
||||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
|
||||||
relations: { user: true }
|
}
|
||||||
});
|
|
||||||
if (!category) return res.status(404).json({ message: 'Category not found' });
|
|
||||||
|
|
||||||
item.title = parsed.data.title;
|
if (approvalNeedsProof(parsed.data.status, item.customFields) && !item.proofs.length) {
|
||||||
item.description = parsed.data.description ?? null;
|
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
|
||||||
item.amount = parsed.data.amount;
|
}
|
||||||
item.expenseDate = parsed.data.expenseDate;
|
|
||||||
item.merchant = parsed.data.merchant ?? null;
|
item.status = parsed.data.status;
|
||||||
item.paymentMethod = parsed.data.paymentMethod ?? null;
|
await expenseRepo().save(item);
|
||||||
item.currency = parsed.data.currency;
|
|
||||||
item.category = category;
|
const refreshed = await hydrateExpense(item.id);
|
||||||
|
return res.json({ item: serializeExpense(refreshed) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reviewDuplicate = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = duplicateReviewSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid duplicate review payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const item = await expenseRepo().findOne({
|
||||||
|
where: { id: String(req.params.id) },
|
||||||
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
|
});
|
||||||
|
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||||
|
return res.status(403).json({ message: 'You cannot review duplicates for this expense' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (parsed.data.action === 'DISMISS') {
|
||||||
|
item.possibleDuplicate = false;
|
||||||
|
item.duplicateStatus = 'DISMISSED';
|
||||||
|
item.duplicateReviewedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.action === 'CONFIRM') {
|
||||||
|
item.possibleDuplicate = true;
|
||||||
|
item.duplicateStatus = 'CONFIRMED';
|
||||||
|
item.duplicateReviewedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.action === 'REOPEN') {
|
||||||
|
const duplicates = await findDuplicateMatches({
|
||||||
|
userId: item.user.id,
|
||||||
|
expenseId: item.id,
|
||||||
|
amount: item.amount,
|
||||||
|
expenseDate: item.expenseDate,
|
||||||
|
merchant: item.merchant,
|
||||||
|
title: item.title,
|
||||||
|
customFields: item.customFields ?? {}
|
||||||
|
});
|
||||||
|
item.possibleDuplicate = duplicates.length > 0;
|
||||||
|
item.duplicateStatus = duplicates.length > 0 ? ('OPEN' as DuplicateReviewStatus) : null;
|
||||||
|
item.duplicateReviewedAt = duplicates.length > 0 ? null : now;
|
||||||
|
}
|
||||||
|
|
||||||
await expenseRepo().save(item);
|
await expenseRepo().save(item);
|
||||||
return res.json({ item: serializeExpense(item) });
|
const refreshed = await hydrateExpense(item.id);
|
||||||
|
return res.json({ item: serializeExpense(refreshed) });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteExpense = async (req: AuthenticatedRequest, res: Response) => {
|
export const deleteExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
@@ -220,44 +762,102 @@ export const deleteExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
return res.status(403).json({ message: 'You cannot delete this expense' });
|
return res.status(403).json({ message: 'You cannot delete this expense' });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const proof of item.proofs ?? []) removeUploadedFile(proof.storedName ?? undefined);
|
item.proofs.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined));
|
||||||
await expenseRepo().remove(item);
|
await expenseRepo().remove(item);
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
export const deleteProof = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = addProofSchema.safeParse(req.body);
|
const expense = await expenseRepo().findOne({
|
||||||
if (!parsed.success) {
|
|
||||||
removeUploadedFile(req.file?.filename);
|
|
||||||
return res.status(400).json({ message: 'Invalid attachment payload', issues: parsed.error.issues });
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await expenseRepo().findOne({
|
|
||||||
where: { id: String(req.params.id) },
|
where: { id: String(req.params.id) },
|
||||||
relations: { user: true, proofs: true, category: { user: true } }
|
relations: { user: true, proofs: true, category: { user: true } }
|
||||||
});
|
});
|
||||||
if (!item) {
|
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
||||||
removeUploadedFile(req.file?.filename);
|
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
|
||||||
|
return res.status(403).json({ message: 'You cannot delete proof from this expense' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
|
||||||
|
if (!proof) return res.status(404).json({ message: 'Proof not found' });
|
||||||
|
|
||||||
|
removeUploadedFile(proof.storedName ?? undefined);
|
||||||
|
await proofRepo().remove(proof);
|
||||||
|
|
||||||
|
const refreshed = await hydrateExpense(expense.id);
|
||||||
|
return res.json({ item: serializeExpense(refreshed) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProofFile = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const expense = await expenseRepo().findOne({
|
||||||
|
where: { id: String(req.params.id) },
|
||||||
|
relations: { user: true, proofs: true }
|
||||||
|
});
|
||||||
|
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
|
||||||
|
return res.status(403).json({ message: 'You cannot access files for this expense' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
|
||||||
|
if (!proof || !proof.storedName) return res.status(404).json({ message: 'Proof file not found' });
|
||||||
|
|
||||||
|
const filePath = path.resolve(env.UPLOAD_DIR, proof.storedName);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).json({ message: 'Proof file not found' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${proof.originalName || proof.storedName}"`);
|
||||||
|
if (proof.mimeType) res.type(proof.mimeType);
|
||||||
|
return res.sendFile(filePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const uploadedFiles = getUploadedFiles(req);
|
||||||
|
const parsed = addProofSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(400).json({ message: 'Invalid proof payload', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = await expenseRepo().findOne({
|
||||||
|
where: { id: String(req.params.id) },
|
||||||
|
relations: { user: true, proofs: true, category: { user: true } }
|
||||||
|
});
|
||||||
|
if (!expense) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(404).json({ message: 'Expense not found' });
|
return res.status(404).json({ message: 'Expense not found' });
|
||||||
}
|
}
|
||||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
|
||||||
removeUploadedFile(req.file?.filename);
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
return res.status(403).json({ message: 'You cannot add proof to this expense' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const proof = await proofRepo().save(
|
renameUploadedFilesForExpense(uploadedFiles, expense.title, parsed.data.type);
|
||||||
|
|
||||||
|
const createdProofs = uploadedFiles.length
|
||||||
|
? uploadedFiles.map((file) =>
|
||||||
proofRepo().create({
|
proofRepo().create({
|
||||||
type: parsed.data.type,
|
type: parsed.data.type,
|
||||||
label: parsed.data.label ?? req.file?.originalname ?? 'Attachment',
|
label: parsed.data.label ?? file.originalname ?? 'Attachment',
|
||||||
note: parsed.data.note ?? null,
|
note: parsed.data.note ?? null,
|
||||||
originalName: req.file?.originalname ?? null,
|
originalName: file.originalname ?? null,
|
||||||
storedName: req.file?.filename ?? null,
|
storedName: file.filename ?? null,
|
||||||
mimeType: req.file?.mimetype ?? null,
|
mimeType: file.mimetype ?? null,
|
||||||
fileSize: req.file?.size ?? null,
|
fileSize: file.size ?? null,
|
||||||
expense: item
|
expense
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
|
: [
|
||||||
|
proofRepo().create({
|
||||||
|
type: parsed.data.type,
|
||||||
|
label: parsed.data.label ?? 'Attachment',
|
||||||
|
note: parsed.data.note ?? null,
|
||||||
|
originalName: null,
|
||||||
|
storedName: null,
|
||||||
|
mimeType: null,
|
||||||
|
fileSize: null,
|
||||||
|
expense
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
item.proofs = [...(item.proofs ?? []), proof];
|
await proofRepo().save(createdProofs);
|
||||||
return res.status(201).json({ proof: serializeProof(proof), expense: serializeExpense(item) });
|
const refreshed = await hydrateExpense(expense.id);
|
||||||
|
return res.status(201).json({ proofs: createdProofs.map((proof) => serializeProof(proof, expense.id)), expense: serializeExpense(refreshed) });
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
|
import { Category } from '../entities/Category.js';
|
||||||
|
import { Expense } from '../entities/Expense.js';
|
||||||
|
import { User } from '../entities/User.js';
|
||||||
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
baseUrl: z.string().max(500).nullable().optional(),
|
||||||
|
apiToken: z.string().max(500).optional(),
|
||||||
|
authMode: z.enum(['bearer', 'x-api-token', 'both']).default('both'),
|
||||||
|
ownerId: z.string().max(120).nullable().optional(),
|
||||||
|
defaultListId: z.string().max(120).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyQuerySchema = z.object({
|
||||||
|
start_date: z.string().optional(),
|
||||||
|
end_date: z.string().optional(),
|
||||||
|
list_id: z.string().optional(),
|
||||||
|
owner_id: z.string().optional(),
|
||||||
|
ownerId: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(500).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const importStatusSchema = z.enum(['DRAFT', 'PENDING']).default('PENDING');
|
||||||
|
|
||||||
|
const importListSchema = z.object({
|
||||||
|
listId: z.union([z.string(), z.number()]),
|
||||||
|
listTitle: z.string().max(180).nullable().optional(),
|
||||||
|
listCreatedAt: z.string().max(60).nullable().optional(),
|
||||||
|
categoryId: z.string().uuid(),
|
||||||
|
status: importStatusSchema,
|
||||||
|
merchant: z.string().max(120).nullable().optional(),
|
||||||
|
title: z.string().max(140).nullable().optional(),
|
||||||
|
description: z.string().max(1000).nullable().optional(),
|
||||||
|
expenseDate: z.string().min(10).max(10).nullable().optional(),
|
||||||
|
tags: z.array(z.string().min(1).max(40)).default([])
|
||||||
|
});
|
||||||
|
|
||||||
|
const importItemSchema = z.object({
|
||||||
|
expenseId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
listId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
listTitle: z.string().max(180).nullable().optional(),
|
||||||
|
categoryId: z.string().uuid(),
|
||||||
|
status: importStatusSchema,
|
||||||
|
title: z.string().min(2).max(140),
|
||||||
|
amount: z.coerce.number().positive(),
|
||||||
|
expenseDate: z.string().min(10).max(10),
|
||||||
|
merchant: z.string().max(120).nullable().optional(),
|
||||||
|
ownerName: z.string().max(160).nullable().optional(),
|
||||||
|
description: z.string().max(1000).nullable().optional(),
|
||||||
|
tags: z.array(z.string().min(1).max(40)).default([])
|
||||||
|
});
|
||||||
|
|
||||||
|
const importPeriodSchema = z.object({
|
||||||
|
period: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
categoryId: z.string().uuid(),
|
||||||
|
status: importStatusSchema,
|
||||||
|
merchant: z.string().max(120).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRepo = () => AppDataSource.getRepository(User);
|
||||||
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||||
|
|
||||||
|
const normalizeBaseUrl = (value?: string | null) => (value ?? '').trim().replace(/\/+$/, '');
|
||||||
|
const trimToNull = (value?: string | null) => {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTags = (values: string[]) =>
|
||||||
|
Array.from(new Set(values.map((item) => item.trim()).filter(Boolean).slice(0, 20)));
|
||||||
|
|
||||||
|
const getSettings = async (userId: string) => {
|
||||||
|
const user = await userRepo().findOne({ where: { id: userId } });
|
||||||
|
return user ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureIntegrationsAllowed = (user: User) => {
|
||||||
|
if (user.role === 'ADMIN' || user.integrationsEnabled) return;
|
||||||
|
const error = new Error('Integrations are disabled for this user.') as Error & { status?: number };
|
||||||
|
error.status = 403;
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({
|
||||||
|
enabled: Boolean(value?.enabled),
|
||||||
|
baseUrl: value?.baseUrl ?? '',
|
||||||
|
hasToken: Boolean(value?.apiToken),
|
||||||
|
authMode: value?.authMode ?? 'both',
|
||||||
|
ownerId: value?.ownerId ?? null,
|
||||||
|
defaultListId: value?.defaultListId ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
type ShoppingListConfig = NonNullable<User['shoppingListIntegration']>;
|
||||||
|
|
||||||
|
const buildHeaders = (config: ShoppingListConfig) => {
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
if (config.authMode === 'bearer' || config.authMode === 'both') headers.Authorization = `Bearer ${config.apiToken}`;
|
||||||
|
if (config.authMode === 'x-api-token' || config.authMode === 'both') headers['X-API-Token'] = String(config.apiToken);
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireConfig = async (userId: string) => {
|
||||||
|
const user = await getSettings(userId);
|
||||||
|
if (!user) throw new Error('User not found');
|
||||||
|
ensureIntegrationsAllowed(user);
|
||||||
|
const config = user.shoppingListIntegration;
|
||||||
|
if (!config?.enabled || !config.baseUrl || !config.apiToken) throw new Error('Shopping list integration is not configured for this user');
|
||||||
|
return { user, config };
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyRequest = async (config: ShoppingListConfig, endpoint: string, query?: Record<string, string | number | undefined>) => {
|
||||||
|
const url = new URL(`${normalizeBaseUrl(config.baseUrl)}${endpoint}`);
|
||||||
|
Object.entries(query ?? {}).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && `${value}` !== '') url.searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: buildHeaders(config),
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const contentType = response.headers.get('content-type') ?? 'application/json';
|
||||||
|
let payload: unknown = text;
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
payload = { raw: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = typeof payload === 'object' && payload && 'message' in payload ? String((payload as { message?: unknown }).message ?? 'Integration request failed') : 'Integration request failed';
|
||||||
|
const error = new Error(message) as Error & { status?: number; details?: unknown };
|
||||||
|
error.status = response.status;
|
||||||
|
error.details = payload;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickItems = (payload: unknown): Record<string, unknown>[] => {
|
||||||
|
if (!payload || typeof payload !== 'object') return [];
|
||||||
|
const items = (payload as { items?: unknown; data?: unknown }).items ?? (payload as { items?: unknown; data?: unknown }).data;
|
||||||
|
return Array.isArray(items) ? items.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === 'object')) : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readString = (...values: unknown[]) => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNumber = (...values: unknown[]) => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readDate = (...values: unknown[]) => {
|
||||||
|
const raw = readString(...values);
|
||||||
|
if (!raw) return null;
|
||||||
|
return raw.length >= 10 ? raw.slice(0, 10) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readItemAmount = (item: Record<string, unknown>) => readNumber(item.amount, item.total, item.value, item.price);
|
||||||
|
const readItemDate = (item: Record<string, unknown>) => readDate(item.expense_date, item.added_at, item.created_at, item.date);
|
||||||
|
const readItemTitle = (item: Record<string, unknown>) => {
|
||||||
|
const list = item.list && typeof item.list === 'object' ? (item.list as Record<string, unknown>) : null;
|
||||||
|
return readString(item.title, item.name, list?.title, list?.name) ?? 'Imported shopping list item';
|
||||||
|
};
|
||||||
|
const readOwnerName = (item: Record<string, unknown>) => {
|
||||||
|
const owner = item.owner && typeof item.owner === 'object' ? (item.owner as Record<string, unknown>) : null;
|
||||||
|
return readString(owner?.fullName, owner?.name, owner?.username, owner?.email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveListDate = (items: Record<string, unknown>[], listCreatedAt?: string | null) => {
|
||||||
|
const itemDates = items.map((item) => readItemDate(item)).filter((value): value is string => Boolean(value)).sort();
|
||||||
|
return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthRange = (period: string) => {
|
||||||
|
const [yearText, monthText] = period.split('-');
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText);
|
||||||
|
const start = `${period}-01`;
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
const end = `${period}-${String(lastDay).padStart(2, '0')}`;
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCategory = async (userId: string, categoryId: string) =>
|
||||||
|
categoryRepo().findOne({
|
||||||
|
where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }],
|
||||||
|
relations: { user: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrateExpense = async (id: string) =>
|
||||||
|
expenseRepo().findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializeExpense = (expense: Expense) => ({
|
||||||
|
id: expense.id,
|
||||||
|
title: expense.title,
|
||||||
|
description: expense.description,
|
||||||
|
amount: expense.amount,
|
||||||
|
expenseDate: expense.expenseDate,
|
||||||
|
merchant: expense.merchant,
|
||||||
|
paymentMethod: expense.paymentMethod,
|
||||||
|
currency: expense.currency,
|
||||||
|
status: expense.status,
|
||||||
|
tags: expense.tags ?? [],
|
||||||
|
customFields: expense.customFields ?? {},
|
||||||
|
possibleDuplicate: expense.possibleDuplicate,
|
||||||
|
duplicateStatus: expense.duplicateStatus,
|
||||||
|
duplicateReviewedAt: expense.duplicateReviewedAt,
|
||||||
|
recurringSourceId: expense.recurringSourceId,
|
||||||
|
category: {
|
||||||
|
id: expense.category.id,
|
||||||
|
name: expense.category.name,
|
||||||
|
color: expense.category.color,
|
||||||
|
isSystem: expense.category.isSystem,
|
||||||
|
ownerId: expense.category.user?.id ?? null
|
||||||
|
},
|
||||||
|
proofs: expense.proofs ?? [],
|
||||||
|
createdAt: expense.createdAt,
|
||||||
|
updatedAt: expense.updatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
const getExistingExpenses = async (userId: string) =>
|
||||||
|
expenseRepo().find({
|
||||||
|
where: { user: { id: userId } },
|
||||||
|
relations: { user: true, category: { user: true }, proofs: true },
|
||||||
|
order: { expenseDate: 'DESC', createdAt: 'DESC' },
|
||||||
|
take: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasExternalImport = (items: Expense[], key: string, value?: string | null) => {
|
||||||
|
if (!value) return false;
|
||||||
|
return items.some((item) => String(item.customFields?.[key] ?? '') === String(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDuplicateMatches = (items: Expense[], input: { amount: number; expenseDate: string; title: string; merchant?: string | null; externalExpenseId?: string | null; externalListId?: string | null }) => {
|
||||||
|
const merchantKey = input.merchant?.trim().toLowerCase() ?? null;
|
||||||
|
const titleKey = input.title.trim().toLowerCase();
|
||||||
|
const inputDate = new Date(`${input.expenseDate}T00:00:00`).getTime();
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (item.duplicateStatus === 'DISMISSED') return false;
|
||||||
|
if (input.externalExpenseId && String(item.customFields?.externalShoppingListExpenseId ?? '') === input.externalExpenseId) return true;
|
||||||
|
if (input.externalListId && String(item.customFields?.externalShoppingListListId ?? '') === input.externalListId) return true;
|
||||||
|
const itemDate = new Date(`${item.expenseDate}T00:00:00`).getTime();
|
||||||
|
const sameAmount = Math.abs(item.amount - input.amount) < 0.001;
|
||||||
|
const sameMerchant = merchantKey ? item.merchant?.trim().toLowerCase() === merchantKey : false;
|
||||||
|
const sameTitle = item.title.trim().toLowerCase() === titleKey;
|
||||||
|
const closeDate = Math.abs(itemDate - inputDate) <= 1000 * 60 * 60 * 24 * 3;
|
||||||
|
return sameAmount && closeDate && (sameMerchant || sameTitle);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
|
||||||
|
expense.possibleDuplicate = duplicates.length > 0;
|
||||||
|
expense.duplicateStatus = duplicates.length > 0 ? 'OPEN' : null;
|
||||||
|
expense.duplicateReviewedAt = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createImportedExpense = async (input: {
|
||||||
|
userId: string;
|
||||||
|
categoryId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
amount: number;
|
||||||
|
expenseDate: string;
|
||||||
|
merchant?: string | null;
|
||||||
|
status: 'DRAFT' | 'PENDING';
|
||||||
|
tags: string[];
|
||||||
|
customFields: Record<string, string>;
|
||||||
|
}) => {
|
||||||
|
const user = await userRepo().findOne({ where: { id: input.userId } });
|
||||||
|
const category = await resolveCategory(input.userId, input.categoryId);
|
||||||
|
if (!user || !category) throw new Error('Category not found');
|
||||||
|
|
||||||
|
const existing = await getExistingExpenses(input.userId);
|
||||||
|
const duplicates = findDuplicateMatches(existing, {
|
||||||
|
amount: input.amount,
|
||||||
|
expenseDate: input.expenseDate,
|
||||||
|
title: input.title,
|
||||||
|
merchant: input.merchant,
|
||||||
|
externalExpenseId: input.customFields.externalShoppingListExpenseId ?? null,
|
||||||
|
externalListId: input.customFields.externalShoppingListImportType === 'LIST' ? input.customFields.externalShoppingListListId ?? null : null
|
||||||
|
});
|
||||||
|
|
||||||
|
const expense = expenseRepo().create({
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
amount: input.amount,
|
||||||
|
expenseDate: input.expenseDate,
|
||||||
|
merchant: input.merchant ?? null,
|
||||||
|
paymentMethod: null,
|
||||||
|
currency: 'PLN',
|
||||||
|
status: input.status,
|
||||||
|
tags: input.tags,
|
||||||
|
customFields: input.customFields,
|
||||||
|
possibleDuplicate: false,
|
||||||
|
duplicateStatus: null,
|
||||||
|
duplicateReviewedAt: null,
|
||||||
|
recurringSourceId: null,
|
||||||
|
user,
|
||||||
|
category,
|
||||||
|
proofs: []
|
||||||
|
});
|
||||||
|
|
||||||
|
applyDuplicateState(expense, duplicates);
|
||||||
|
await expenseRepo().save(expense);
|
||||||
|
const hydrated = await hydrateExpense(expense.id);
|
||||||
|
const warnings = duplicates.length ? [`Possible duplicate detected (${duplicates.length} matching expense${duplicates.length > 1 ? 's' : ''}).`] : [];
|
||||||
|
return { item: serializeExpense(hydrated), warnings };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const user = await getSettings(req.user!.id);
|
||||||
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||||
|
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
|
||||||
|
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = settingsSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration settings payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const user = await getSettings(req.user!.id);
|
||||||
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||||
|
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
|
||||||
|
|
||||||
|
const current = user.shoppingListIntegration ?? {};
|
||||||
|
user.shoppingListIntegration = {
|
||||||
|
enabled: parsed.data.enabled,
|
||||||
|
baseUrl: normalizeBaseUrl(parsed.data.baseUrl),
|
||||||
|
apiToken: parsed.data.apiToken?.trim() ? parsed.data.apiToken.trim() : current.apiToken,
|
||||||
|
authMode: parsed.data.authMode,
|
||||||
|
ownerId: parsed.data.ownerId ?? null,
|
||||||
|
defaultListId: parsed.data.defaultListId ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
await userRepo().save(user);
|
||||||
|
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testShoppingListConnection = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { config } = await requireConfig(req.user!.id);
|
||||||
|
const payload = await proxyRequest(config, '/api/ping');
|
||||||
|
return res.json({ ok: true, payload });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShoppingListSummary = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = proxyQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||||
|
try {
|
||||||
|
const { config } = await requireConfig(req.user!.id);
|
||||||
|
const payload = await proxyRequest(config, '/api/expenses/summary', {
|
||||||
|
start_date: parsed.data.start_date,
|
||||||
|
end_date: parsed.data.end_date,
|
||||||
|
list_id: parsed.data.list_id ?? config.defaultListId ?? undefined,
|
||||||
|
owner_id: parsed.data.owner_id ?? parsed.data.ownerId ?? config.ownerId ?? undefined
|
||||||
|
});
|
||||||
|
return res.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShoppingListLatestExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = proxyQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||||
|
try {
|
||||||
|
const { config } = await requireConfig(req.user!.id);
|
||||||
|
const payload = await proxyRequest(config, '/api/expenses/latest', {
|
||||||
|
start_date: parsed.data.start_date,
|
||||||
|
end_date: parsed.data.end_date,
|
||||||
|
list_id: parsed.data.list_id ?? config.defaultListId ?? undefined,
|
||||||
|
owner_id: parsed.data.owner_id ?? parsed.data.ownerId ?? config.ownerId ?? undefined,
|
||||||
|
limit: parsed.data.limit
|
||||||
|
});
|
||||||
|
return res.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShoppingLists = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = proxyQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||||
|
try {
|
||||||
|
const { config } = await requireConfig(req.user!.id);
|
||||||
|
const payload = await proxyRequest(config, '/api/lists', {
|
||||||
|
owner_id: parsed.data.owner_id ?? parsed.data.ownerId ?? config.ownerId ?? undefined,
|
||||||
|
limit: parsed.data.limit
|
||||||
|
});
|
||||||
|
return res.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShoppingListExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = proxyQuerySchema.pick({ limit: true }).safeParse(req.query);
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||||
|
try {
|
||||||
|
const { config } = await requireConfig(req.user!.id);
|
||||||
|
const listId = String(req.params.id);
|
||||||
|
const payload = await proxyRequest(config, `/api/lists/${encodeURIComponent(listId)}/expenses`, { limit: parsed.data.limit });
|
||||||
|
return res.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = importListSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list import payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, config } = await requireConfig(req.user!.id);
|
||||||
|
const existing = await getExistingExpenses(user.id);
|
||||||
|
const listId = String(parsed.data.listId);
|
||||||
|
if (hasExternalImport(existing, 'externalShoppingListListId', listId)) {
|
||||||
|
return res.status(409).json({ message: 'This shopping list has already been imported as a local expense.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await proxyRequest(config, `/api/lists/${encodeURIComponent(listId)}/expenses`, { limit: 500 });
|
||||||
|
const items = pickItems(payload);
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + readItemAmount(item), 0);
|
||||||
|
if (!items.length || totalAmount <= 0) {
|
||||||
|
return res.status(400).json({ message: 'The selected shopping list does not contain any importable expenses.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerNames = Array.from(new Set(items.map((item) => readOwnerName(item)).filter((value): value is string => Boolean(value))));
|
||||||
|
const derivedDate = parsed.data.expenseDate ?? deriveListDate(items, parsed.data.listCreatedAt);
|
||||||
|
const title = trimToNull(parsed.data.title) ?? `Shopping list: ${trimToNull(parsed.data.listTitle) ?? listId}`;
|
||||||
|
const description = trimToNull(parsed.data.description) ?? `Imported aggregate from shopping list API (${items.length} item${items.length > 1 ? 's' : ''}).`;
|
||||||
|
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
||||||
|
const tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
|
||||||
|
|
||||||
|
const result = await createImportedExpense({
|
||||||
|
userId: user.id,
|
||||||
|
categoryId: parsed.data.categoryId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
amount: Number(totalAmount.toFixed(2)),
|
||||||
|
expenseDate: derivedDate,
|
||||||
|
merchant,
|
||||||
|
status: parsed.data.status,
|
||||||
|
tags,
|
||||||
|
customFields: {
|
||||||
|
externalSource: 'shopping-list-api',
|
||||||
|
externalShoppingListImportType: 'LIST',
|
||||||
|
externalShoppingListListId: listId,
|
||||||
|
externalShoppingListListTitle: trimToNull(parsed.data.listTitle) ?? listId,
|
||||||
|
externalShoppingListItemCount: String(items.length),
|
||||||
|
externalShoppingListOwner: ownerNames.join(', ')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const importShoppingPeriodAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = importPeriodSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping period import payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, config } = await requireConfig(req.user!.id);
|
||||||
|
const range = monthRange(parsed.data.period);
|
||||||
|
const existing = await getExistingExpenses(user.id);
|
||||||
|
if (hasExternalImport(existing, 'externalShoppingListPeriod', parsed.data.period)) {
|
||||||
|
return res.status(409).json({ message: 'This shopping month has already been imported as a local expense.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await proxyRequest(config, '/api/expenses/summary', {
|
||||||
|
start_date: range.start,
|
||||||
|
end_date: range.end,
|
||||||
|
owner_id: config.ownerId ?? undefined,
|
||||||
|
list_id: config.defaultListId ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmount = readNumber((payload as { total?: unknown }).total, (payload as { amount?: unknown }).amount, (payload as { meta?: { total_amount?: unknown } }).meta?.total_amount);
|
||||||
|
const totalCount = readNumber((payload as { count?: unknown }).count, (payload as { records?: unknown }).records, (payload as { meta?: { total_count?: unknown } }).meta?.total_count);
|
||||||
|
const listGroups = [
|
||||||
|
(payload as { lists?: unknown }).lists,
|
||||||
|
(payload as { totals?: unknown }).totals,
|
||||||
|
(payload as { aggregates?: unknown }).aggregates
|
||||||
|
].find((value) => Array.isArray(value));
|
||||||
|
const listCount = Array.isArray(listGroups) ? listGroups.length : 0;
|
||||||
|
|
||||||
|
if (totalAmount <= 0) {
|
||||||
|
return res.status(400).json({ message: 'The selected shopping month does not contain any importable expenses.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createImportedExpense({
|
||||||
|
userId: user.id,
|
||||||
|
categoryId: parsed.data.categoryId,
|
||||||
|
title: `Zakupy ${parsed.data.period}`,
|
||||||
|
description: `Zbiorczy import zakupów za okres ${parsed.data.period}.`,
|
||||||
|
amount: Number(totalAmount.toFixed(2)),
|
||||||
|
expenseDate: range.end,
|
||||||
|
merchant: trimToNull(parsed.data.merchant) ?? 'Zakupy',
|
||||||
|
status: parsed.data.status,
|
||||||
|
tags: ['shopping-list'],
|
||||||
|
customFields: {
|
||||||
|
externalSource: 'shopping-list-api',
|
||||||
|
externalShoppingListImportType: 'PERIOD',
|
||||||
|
externalShoppingListPeriod: parsed.data.period,
|
||||||
|
externalShoppingListListCount: String(listCount),
|
||||||
|
externalShoppingListItemCount: String(totalCount)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = importItemSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list item import payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user } = await requireConfig(req.user!.id);
|
||||||
|
const externalExpenseId = parsed.data.expenseId !== undefined ? String(parsed.data.expenseId) : null;
|
||||||
|
const existing = await getExistingExpenses(user.id);
|
||||||
|
if (externalExpenseId && hasExternalImport(existing, 'externalShoppingListExpenseId', externalExpenseId)) {
|
||||||
|
return res.status(409).json({ message: 'This shopping list item has already been imported as a local expense.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = parsed.data.title.trim();
|
||||||
|
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
||||||
|
const tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
|
||||||
|
const description = trimToNull(parsed.data.description) ?? `Imported from shopping list API${parsed.data.listTitle ? ` (${parsed.data.listTitle})` : ''}.`;
|
||||||
|
|
||||||
|
const result = await createImportedExpense({
|
||||||
|
userId: user.id,
|
||||||
|
categoryId: parsed.data.categoryId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
amount: Number(parsed.data.amount.toFixed(2)),
|
||||||
|
expenseDate: parsed.data.expenseDate,
|
||||||
|
merchant,
|
||||||
|
status: parsed.data.status,
|
||||||
|
tags,
|
||||||
|
customFields: {
|
||||||
|
externalSource: 'shopping-list-api',
|
||||||
|
externalShoppingListImportType: 'ITEM',
|
||||||
|
externalShoppingListExpenseId: externalExpenseId ?? '',
|
||||||
|
externalShoppingListListId: parsed.data.listId !== undefined ? String(parsed.data.listId) : '',
|
||||||
|
externalShoppingListListTitle: trimToNull(parsed.data.listTitle) ?? '',
|
||||||
|
externalShoppingListOwner: trimToNull(parsed.data.ownerName) ?? ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -36,7 +36,7 @@ export const listMerchants = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
export const createMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
export const createMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = schema.safeParse(req.body);
|
const parsed = schema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
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 } });
|
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) => {
|
export const updateMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = schema.safeParse(req.body);
|
const parsed = schema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
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({
|
const item = await merchantRepo().findOne({
|
||||||
where: { id: String(req.params.id), user: { id: req.user!.id } }
|
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.name = parsed.data.name;
|
||||||
item.kind = parsed.data.kind;
|
item.kind = parsed.data.kind;
|
||||||
@@ -68,7 +68,7 @@ export const deleteMerchant = async (req: AuthenticatedRequest, res: Response) =
|
|||||||
const item = await merchantRepo().findOne({
|
const item = await merchantRepo().findOne({
|
||||||
where: { id: String(req.params.id), user: { id: req.user!.id } }
|
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);
|
await merchantRepo().remove(item);
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
|
import { Category } from '../entities/Category.js';
|
||||||
|
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||||
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||||
|
|
||||||
|
const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']).nullable().optional();
|
||||||
|
const recurringSchema = z.object({
|
||||||
|
title: z.string().min(2).max(140),
|
||||||
|
description: z.string().max(1000).nullable().optional(),
|
||||||
|
amount: z.coerce.number().positive(),
|
||||||
|
categoryId: z.string().uuid(),
|
||||||
|
merchant: z.string().max(120).nullable().optional(),
|
||||||
|
paymentMethod: paymentMethodSchema,
|
||||||
|
currency: z.string().min(3).max(8).default('PLN'),
|
||||||
|
frequency: z.enum(['WEEKLY', 'MONTHLY', 'YEARLY']).default('MONTHLY'),
|
||||||
|
intervalValue: z.coerce.number().int().min(1).max(24).default(1),
|
||||||
|
startDate: z.string().min(10).max(10),
|
||||||
|
nextRunDate: z.string().min(10).max(10),
|
||||||
|
endDate: z.string().min(10).max(10).nullable().optional(),
|
||||||
|
maxOccurrences: z.coerce.number().int().min(1).max(500).nullable().optional(),
|
||||||
|
defaultStatus: z.enum(['DRAFT', 'PENDING']).default('PENDING'),
|
||||||
|
tags: z.array(z.string().min(1).max(40)).default([]),
|
||||||
|
customFields: z.record(z.string(), z.string()).default({}),
|
||||||
|
isActive: z.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
const recurringRepo = () => AppDataSource.getRepository(RecurringExpense);
|
||||||
|
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||||
|
|
||||||
|
const normalizeTagList = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
} catch {}
|
||||||
|
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCustomFields = (value: unknown) => {
|
||||||
|
if (!value) return {} as Record<string, string>;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(parsed as Record<string, unknown>)
|
||||||
|
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||||
|
.filter(([key, item]) => Boolean(key && item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||||
|
.filter(([key, item]) => Boolean(key && item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeRecurring = (item: RecurringExpense) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
amount: item.amount,
|
||||||
|
merchant: item.merchant,
|
||||||
|
paymentMethod: item.paymentMethod,
|
||||||
|
currency: item.currency,
|
||||||
|
frequency: item.frequency,
|
||||||
|
intervalValue: item.intervalValue,
|
||||||
|
startDate: item.startDate,
|
||||||
|
nextRunDate: item.nextRunDate,
|
||||||
|
lastRunDate: item.lastRunDate,
|
||||||
|
endDate: item.endDate,
|
||||||
|
maxOccurrences: item.maxOccurrences,
|
||||||
|
generatedCount: item.generatedCount,
|
||||||
|
defaultStatus: item.defaultStatus,
|
||||||
|
tags: item.tags ?? [],
|
||||||
|
customFields: item.customFields ?? {},
|
||||||
|
isActive: item.isActive,
|
||||||
|
category: {
|
||||||
|
id: item.category.id,
|
||||||
|
name: item.category.name,
|
||||||
|
color: item.category.color,
|
||||||
|
isSystem: item.category.isSystem,
|
||||||
|
ownerId: item.category.user?.id ?? null
|
||||||
|
},
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.updatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateSchedule = (data: z.infer<typeof recurringSchema>) => {
|
||||||
|
if (data.endDate && data.endDate < data.startDate) return 'End date cannot be earlier than start date.';
|
||||||
|
if (data.endDate && data.nextRunDate > data.endDate) return 'Next run date cannot be later than the end date.';
|
||||||
|
if (data.nextRunDate < data.startDate) return 'Next run date cannot be earlier than the start date.';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listRecurringExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
const items = await recurringRepo().find({
|
||||||
|
where: { user: { id: req.user!.id } },
|
||||||
|
relations: { category: { user: true }, user: true },
|
||||||
|
order: { nextRunDate: 'ASC', createdAt: 'DESC' }
|
||||||
|
});
|
||||||
|
return res.json({ items: items.map(serializeRecurring) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRecurringExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = recurringSchema.safeParse({ ...req.body, tags: normalizeTagList(req.body.tags), customFields: normalizeCustomFields(req.body.customFields) });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid recurring expense payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const scheduleError = validateSchedule(parsed.data);
|
||||||
|
if (scheduleError) return res.status(400).json({ message: scheduleError });
|
||||||
|
|
||||||
|
const category = await categoryRepo().findOne({
|
||||||
|
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||||
|
relations: { user: true }
|
||||||
|
});
|
||||||
|
if (!category) return res.status(404).json({ message: 'Category not found' });
|
||||||
|
|
||||||
|
const saved = await recurringRepo().save(
|
||||||
|
recurringRepo().create({
|
||||||
|
...parsed.data,
|
||||||
|
description: parsed.data.description ?? null,
|
||||||
|
merchant: parsed.data.merchant ?? null,
|
||||||
|
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||||
|
endDate: parsed.data.endDate ?? null,
|
||||||
|
maxOccurrences: parsed.data.maxOccurrences ?? null,
|
||||||
|
generatedCount: 0,
|
||||||
|
category,
|
||||||
|
user: { id: req.user!.id } as never
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const full = await recurringRepo().findOneOrFail({ where: { id: saved.id }, relations: { category: { user: true }, user: true } });
|
||||||
|
return res.status(201).json({ item: serializeRecurring(full) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRecurringExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = recurringSchema.safeParse({ ...req.body, tags: normalizeTagList(req.body.tags), customFields: normalizeCustomFields(req.body.customFields) });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid recurring expense payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const scheduleError = validateSchedule(parsed.data);
|
||||||
|
if (scheduleError) return res.status(400).json({ message: scheduleError });
|
||||||
|
|
||||||
|
const item = await recurringRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } });
|
||||||
|
if (!item) return res.status(404).json({ message: 'Recurring expense not found' });
|
||||||
|
|
||||||
|
const category = await categoryRepo().findOne({
|
||||||
|
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||||
|
relations: { user: true }
|
||||||
|
});
|
||||||
|
if (!category) return res.status(404).json({ message: 'Category not found' });
|
||||||
|
|
||||||
|
Object.assign(item, {
|
||||||
|
...parsed.data,
|
||||||
|
description: parsed.data.description ?? null,
|
||||||
|
merchant: parsed.data.merchant ?? null,
|
||||||
|
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||||
|
endDate: parsed.data.endDate ?? null,
|
||||||
|
maxOccurrences: parsed.data.maxOccurrences ?? null,
|
||||||
|
category
|
||||||
|
});
|
||||||
|
|
||||||
|
await recurringRepo().save(item);
|
||||||
|
return res.json({ item: serializeRecurring(item) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteRecurringExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const item = await recurringRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } } });
|
||||||
|
if (!item) return res.status(404).json({ message: 'Recurring expense not found' });
|
||||||
|
await recurringRepo().remove(item);
|
||||||
|
return res.status(204).send();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processRecurringNow = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
return res.json({ message: 'Recurring expenses processed successfully' });
|
||||||
|
};
|
||||||
@@ -1,25 +1,47 @@
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
import { AppSetting } from '../entities/AppSetting.js';
|
import { AppSetting } from '../entities/AppSetting.js';
|
||||||
|
import { Expense } from '../entities/Expense.js';
|
||||||
import { User } from '../entities/User.js';
|
import { User } from '../entities/User.js';
|
||||||
import { getStatistics } from '../services/statistics.service.js';
|
import { getStatistics } from '../services/statistics.service.js';
|
||||||
|
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||||
|
import { createSmtpTransport, formatFromAddress } from '../services/mail.service.js';
|
||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
|
||||||
const preferencesSchema = z.object({
|
const preferencesSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
frequency: z.enum(['monthly', 'yearly', 'threshold']),
|
frequency: z.enum(['monthly', 'yearly', 'threshold']),
|
||||||
thresholdAmount: z.number().min(0).default(0),
|
thresholdAmount: z.number().min(0).default(0),
|
||||||
sendToEmail: z.email().nullable().optional(),
|
sendToEmail: z.string().email().nullable().optional(),
|
||||||
categoryIds: z.array(z.string().uuid()).default([])
|
categoryIds: z.array(z.string().uuid()).default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewOverrideSchema = preferencesSchema.partial();
|
const previewOverrideSchema = preferencesSchema.partial();
|
||||||
|
const exportQuerySchema = z.object({
|
||||||
|
format: z.enum(['csv', 'json', 'html', 'pdf']).default('csv'),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
categoryIds: z.string().optional(),
|
||||||
|
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']).optional(),
|
||||||
|
tag: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
const userRepo = () => AppDataSource.getRepository(User);
|
const userRepo = () => AppDataSource.getRepository(User);
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||||
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
|
||||||
|
const normalizeOptionalString = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNullableString = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultPrefs = (email: string) => ({
|
const defaultPrefs = (email: string) => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -29,16 +51,23 @@ const defaultPrefs = (email: string) => ({
|
|||||||
categoryIds: [] as 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 periodRange = (frequency: 'monthly' | 'yearly' | 'threshold') => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const endDate = now.toISOString().slice(0, 10);
|
const endDate = formatLocalDate(now);
|
||||||
|
|
||||||
if (frequency === 'yearly') {
|
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));
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
return { startDate: start.toISOString().slice(0, 10), endDate, bucket: 'month' as const, label: 'Current month' };
|
return { startDate: formatLocalDate(start), endDate, bucket: 'month' as const, label: 'Current month' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>) => {
|
const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>) => {
|
||||||
@@ -52,6 +81,11 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
|
|||||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.label}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.label}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
const tagRows = (summary.byTag ?? [])
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.tag}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-family:Arial,sans-serif;color:#111827;max-width:760px;margin:0 auto">
|
<div style="font-family:Arial,sans-serif;color:#111827;max-width:760px;margin:0 auto">
|
||||||
<h1 style="margin-bottom:8px">${title}</h1>
|
<h1 style="margin-bottom:8px">${title}</h1>
|
||||||
@@ -60,10 +94,112 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
|
|||||||
<table style="width:100%;border-collapse:collapse">${categoryRows || '<tr><td>No data</td></tr>'}</table>
|
<table style="width:100%;border-collapse:collapse">${categoryRows || '<tr><td>No data</td></tr>'}</table>
|
||||||
<h2 style="margin-top:32px">Timeline</h2>
|
<h2 style="margin-top:32px">Timeline</h2>
|
||||||
<table style="width:100%;border-collapse:collapse">${timelineRows || '<tr><td>No data</td></tr>'}</table>
|
<table style="width:100%;border-collapse:collapse">${timelineRows || '<tr><td>No data</td></tr>'}</table>
|
||||||
|
<h2 style="margin-top:32px">Top tags</h2>
|
||||||
|
<table style="width:100%;border-collapse:collapse">${tagRows || '<tr><td>No data</td></tr>'}</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const collectExportItems = async (userId: string, filters: { startDate?: string; endDate?: string; categoryIds?: string[]; status?: string; tag?: string }) => {
|
||||||
|
const items = await expenseRepo().find({ where: { user: { id: userId } }, relations: { category: true, proofs: true }, order: { expenseDate: 'DESC', createdAt: 'DESC' } });
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (filters.startDate && item.expenseDate < filters.startDate) return false;
|
||||||
|
if (filters.endDate && item.expenseDate > filters.endDate) return false;
|
||||||
|
if (filters.categoryIds?.length && !filters.categoryIds.includes(item.category.id)) return false;
|
||||||
|
if (filters.status && item.status !== filters.status) return false;
|
||||||
|
if (filters.tag && !(item.tags ?? []).some((tag) => tag.toLowerCase() === filters.tag!.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeCsv = (value: unknown) => {
|
||||||
|
const text = String(value ?? '');
|
||||||
|
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapePdfText = (value: string) => value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)').replace(/\r/g, '').replace(/\n/g, ' ');
|
||||||
|
|
||||||
|
const buildPdfBuffer = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>, items: Expense[]) => {
|
||||||
|
const lines = [
|
||||||
|
title,
|
||||||
|
`Generated: ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`,
|
||||||
|
`Total: ${summary.total.toFixed(2)} | Count: ${summary.count} | Average: ${summary.average.toFixed(2)}`,
|
||||||
|
'',
|
||||||
|
'Top categories:'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (summary.byCategory.length) {
|
||||||
|
summary.byCategory.slice(0, 8).forEach((item) => lines.push(`- ${item.categoryName}: ${item.total.toFixed(2)} (${item.count})`));
|
||||||
|
} else {
|
||||||
|
lines.push('- No data');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'Timeline:');
|
||||||
|
if (summary.timeline.length) {
|
||||||
|
summary.timeline.slice(-8).forEach((item) => lines.push(`- ${item.label}: ${item.total.toFixed(2)}`));
|
||||||
|
} else {
|
||||||
|
lines.push('- No data');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'Expenses:');
|
||||||
|
if (items.length) {
|
||||||
|
items.slice(0, 30).forEach((item) => lines.push(`- ${item.expenseDate} | ${item.title} | ${item.category.name} | ${item.amount.toFixed(2)} ${item.currency} | ${item.status}`));
|
||||||
|
} else {
|
||||||
|
lines.push('- No data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageHeight = 792;
|
||||||
|
const lineHeight = 16;
|
||||||
|
const startY = 750;
|
||||||
|
const linesPerPage = 40;
|
||||||
|
const chunks = [] as string[][];
|
||||||
|
for (let index = 0; index < lines.length; index += linesPerPage) chunks.push(lines.slice(index, index + linesPerPage));
|
||||||
|
|
||||||
|
const objects: string[] = [];
|
||||||
|
const kids: string[] = [];
|
||||||
|
const fontObjectNumber = 3 + chunks.length * 2;
|
||||||
|
|
||||||
|
objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||||
|
|
||||||
|
chunks.forEach((chunk, pageIndex) => {
|
||||||
|
const pageObjectNumber = 3 + pageIndex * 2;
|
||||||
|
const contentObjectNumber = pageObjectNumber + 1;
|
||||||
|
kids.push(`${pageObjectNumber} 0 R`);
|
||||||
|
|
||||||
|
const contentLines = ['BT', '/F1 12 Tf', `50 ${startY} Td`];
|
||||||
|
chunk.forEach((line, index) => {
|
||||||
|
if (index > 0) contentLines.push(`0 -${lineHeight} Td`);
|
||||||
|
contentLines.push(`(${escapePdfText(line)}) Tj`);
|
||||||
|
});
|
||||||
|
contentLines.push('ET');
|
||||||
|
const stream = contentLines.join('\n');
|
||||||
|
|
||||||
|
objects[pageObjectNumber] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${pageHeight}] /Resources << /Font << /F1 ${fontObjectNumber} 0 R >> >> /Contents ${contentObjectNumber} 0 R >>`;
|
||||||
|
objects[contentObjectNumber] = `<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`;
|
||||||
|
});
|
||||||
|
|
||||||
|
objects[2] = `<< /Type /Pages /Count ${kids.length} /Kids [${kids.join(' ')}] >>`;
|
||||||
|
objects[fontObjectNumber] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
|
||||||
|
|
||||||
|
let pdf = '%PDF-1.4\n';
|
||||||
|
const offsets: number[] = [0];
|
||||||
|
for (let index = 1; index < objects.length; index += 1) {
|
||||||
|
if (!objects[index]) continue;
|
||||||
|
offsets[index] = Buffer.byteLength(pdf, 'utf8');
|
||||||
|
pdf += `${index} 0 obj\n${objects[index]}\nendobj\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf, 'utf8');
|
||||||
|
pdf += `xref\n0 ${objects.length}\n`;
|
||||||
|
pdf += '0000000000 65535 f \n';
|
||||||
|
for (let index = 1; index < objects.length; index += 1) {
|
||||||
|
pdf += `${String(offsets[index] ?? 0).padStart(10, '0')} 00000 n \n`;
|
||||||
|
}
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||||
|
return Buffer.from(pdf, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
export const getPreferences = async (req: AuthenticatedRequest, res: Response) => {
|
export const getPreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||||
@@ -71,7 +207,7 @@ export const getPreferences = async (req: AuthenticatedRequest, res: Response) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
|
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = preferencesSchema.safeParse(req.body);
|
const parsed = preferencesSchema.safeParse({ ...req.body, sendToEmail: normalizeNullableString(req.body?.sendToEmail) });
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
|
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
|
||||||
}
|
}
|
||||||
@@ -85,6 +221,7 @@ export const updatePreferences = async (req: AuthenticatedRequest, res: Response
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const previewReport = async (req: AuthenticatedRequest, res: Response) => {
|
export const previewReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||||
|
|
||||||
@@ -113,6 +250,7 @@ export const previewReport = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||||
|
|
||||||
@@ -134,19 +272,11 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
range.bucket
|
range.bucket
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
const transport = createSmtpTransport(appSettings);
|
||||||
const transport = nodemailer.createTransport({
|
|
||||||
host: appSettings.smtpHost,
|
|
||||||
port: appSettings.smtpPort,
|
|
||||||
secure: appSettings.smtpSecure,
|
|
||||||
auth: appSettings.smtpUser ? { user: appSettings.smtpUser, pass: appSettings.smtpPassword ?? '' } : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
const to = prefs.sendToEmail || user.email;
|
const to = prefs.sendToEmail || user.email;
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: appSettings.smtpFromName
|
from: formatFromAddress(appSettings),
|
||||||
? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>`
|
|
||||||
: appSettings.smtpFromEmail,
|
|
||||||
to,
|
to,
|
||||||
subject: `${appSettings.appName} - ${range.label} report`,
|
subject: `${appSettings.appName} - ${range.label} report`,
|
||||||
html: buildReportHtml(`${range.label} report`, summary)
|
html: buildReportHtml(`${range.label} report`, summary)
|
||||||
@@ -154,3 +284,92 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
|
|
||||||
return res.json({ message: 'Report email was sent', sentTo: to });
|
return res.json({ message: 'Report email was sent', sentTo: to });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
const parsed = exportQuerySchema.safeParse({ ...req.query, format: normalizeOptionalString(req.query.format), startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), status: normalizeOptionalString(req.query.status), tag: normalizeOptionalString(req.query.tag) });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? [];
|
||||||
|
const items = await collectExportItems(req.user!.id, {
|
||||||
|
startDate: parsed.data.startDate,
|
||||||
|
endDate: parsed.data.endDate,
|
||||||
|
categoryIds,
|
||||||
|
status: parsed.data.status,
|
||||||
|
tag: parsed.data.tag
|
||||||
|
});
|
||||||
|
const summary = await getStatistics(
|
||||||
|
{
|
||||||
|
userId: req.user!.id,
|
||||||
|
startDate: parsed.data.startDate,
|
||||||
|
endDate: parsed.data.endDate,
|
||||||
|
categoryIds,
|
||||||
|
status: parsed.data.status,
|
||||||
|
tag: parsed.data.tag
|
||||||
|
},
|
||||||
|
'month'
|
||||||
|
);
|
||||||
|
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
if (parsed.data.format === 'json') {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.json"`);
|
||||||
|
return res.send(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
filters: parsed.data,
|
||||||
|
summary,
|
||||||
|
items: items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
date: item.expenseDate,
|
||||||
|
amount: item.amount,
|
||||||
|
currency: item.currency,
|
||||||
|
status: item.status,
|
||||||
|
category: item.category.name,
|
||||||
|
merchant: item.merchant,
|
||||||
|
tags: item.tags ?? [],
|
||||||
|
customFields: item.customFields ?? {},
|
||||||
|
attachments: item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment')
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.format === 'html') {
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.html"`);
|
||||||
|
return res.send(buildReportHtml('Expense export', summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.format === 'pdf') {
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.pdf"`);
|
||||||
|
return res.send(buildPdfBuffer('Expense export', summary, items));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
['Date', 'Title', 'Category', 'Merchant', 'Amount', 'Currency', 'Status', 'Tags', 'Custom fields', 'Attachments'],
|
||||||
|
...items.map((item) => [
|
||||||
|
item.expenseDate,
|
||||||
|
item.title,
|
||||||
|
item.category.name,
|
||||||
|
item.merchant ?? '',
|
||||||
|
item.amount.toFixed(2),
|
||||||
|
item.currency,
|
||||||
|
item.status,
|
||||||
|
(item.tags ?? []).join('|'),
|
||||||
|
Object.entries(item.customFields ?? {})
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join(' | '),
|
||||||
|
item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment').join(' | ')
|
||||||
|
])
|
||||||
|
];
|
||||||
|
const csv = rows.map((row) => row.map(escapeCsv).join(',')).join('\n');
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.csv"`);
|
||||||
|
return res.send(csv);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getStatistics } from '../services/statistics.service.js';
|
import { getCashflowSummary, getStatistics } from '../services/statistics.service.js';
|
||||||
|
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
|
||||||
|
const normalizeOptionalString = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
startDate: z.string().optional(),
|
startDate: z.string().optional(),
|
||||||
endDate: z.string().optional(),
|
endDate: z.string().optional(),
|
||||||
categoryIds: z.string().optional(),
|
categoryIds: z.string().optional(),
|
||||||
bucket: z.enum(['month', 'quarter', 'year']).optional()
|
bucket: z.enum(['month', 'quarter', 'year']).optional(),
|
||||||
|
tag: z.string().optional(),
|
||||||
|
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = querySchema.safeParse(req.query);
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
const parsed = querySchema.safeParse({ ...req.query, startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), bucket: normalizeOptionalString(req.query.bucket), tag: normalizeOptionalString(req.query.tag), status: normalizeOptionalString(req.query.status) });
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
|
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
res.set('Expires', '0');
|
||||||
|
|
||||||
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
|
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
|
||||||
return res.json(
|
return res.json(
|
||||||
await getStatistics(
|
await getStatistics(
|
||||||
@@ -23,9 +37,19 @@ export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
userId: req.user!.id,
|
userId: req.user!.id,
|
||||||
startDate: parsed.data.startDate,
|
startDate: parsed.data.startDate,
|
||||||
endDate: parsed.data.endDate,
|
endDate: parsed.data.endDate,
|
||||||
categoryIds
|
categoryIds,
|
||||||
|
tag: parsed.data.tag,
|
||||||
|
status: parsed.data.status
|
||||||
},
|
},
|
||||||
parsed.data.bucket ?? 'month'
|
parsed.data.bucket ?? 'month'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCashflow = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
res.set('Expires', '0');
|
||||||
|
return res.json(await getCashflowSummary(req.user!.id));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
@Entity('app_settings')
|
@Entity('app_settings')
|
||||||
export class AppSetting {
|
export class AppSetting {
|
||||||
@@ -44,9 +45,9 @@ export class AppSetting {
|
|||||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||||
smtpFromEmail!: string | null;
|
smtpFromEmail!: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'datetime' })
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'datetime' })
|
@UpdateDateColumn({ type: dateTimeColumnType })
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
|
import { Category } from './Category.js';
|
||||||
|
import { User } from './User.js';
|
||||||
|
import { decimalTransformer } from '../utils/decimal.js';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
|
@Entity('budgets')
|
||||||
|
export class Budget {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 7 })
|
||||||
|
month!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||||
|
name!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2, transformer: decimalTransformer })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
alertThresholds!: number[] | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: dateTimeColumnType })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
user!: Relation<User>;
|
||||||
|
|
||||||
|
@ManyToOne(() => Category, { eager: true, nullable: true, onDelete: 'SET NULL' })
|
||||||
|
category!: Relation<Category> | null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
import { Expense } from './Expense.js';
|
import { Expense } from './Expense.js';
|
||||||
import { User } from './User.js';
|
import { User } from './User.js';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
@Entity('categories')
|
@Entity('categories')
|
||||||
export class Category {
|
export class Category {
|
||||||
@@ -16,12 +18,12 @@ export class Category {
|
|||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
isSystem!: boolean;
|
isSystem!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'datetime' })
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@ManyToOne(() => User, (user) => user.categories, { nullable: true, onDelete: 'CASCADE' })
|
@ManyToOne(() => User, (user) => user.categories, { nullable: true, onDelete: 'CASCADE' })
|
||||||
user!: User | null;
|
user!: Relation<User> | null;
|
||||||
|
|
||||||
@OneToMany(() => Expense, (expense) => expense.category)
|
@OneToMany(() => Expense, (expense) => expense.category)
|
||||||
expenses!: Expense[];
|
expenses!: Relation<Expense[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
import { Category } from './Category.js';
|
import { Category } from './Category.js';
|
||||||
import { Proof } from './Proof.js';
|
import { Proof } from './Proof.js';
|
||||||
import { User } from './User.js';
|
import { User } from './User.js';
|
||||||
import { decimalTransformer } from '../utils/decimal.js';
|
import { decimalTransformer } from '../utils/decimal.js';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
|
export type ExpenseStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||||
|
export type DuplicateReviewStatus = 'OPEN' | 'CONFIRMED' | 'DISMISSED';
|
||||||
|
|
||||||
@Entity('expenses')
|
@Entity('expenses')
|
||||||
export class Expense {
|
export class Expense {
|
||||||
@@ -30,21 +35,39 @@ export class Expense {
|
|||||||
@Column({ type: 'varchar', length: 12, default: 'PLN' })
|
@Column({ type: 'varchar', length: 12, default: 'PLN' })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'PENDING' })
|
||||||
|
status!: ExpenseStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
tags!: string[] | null;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
customFields!: Record<string, string> | null;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
possibleDuplicate!: boolean;
|
possibleDuplicate!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'datetime' })
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
duplicateStatus!: DuplicateReviewStatus | null;
|
||||||
|
|
||||||
|
@Column({ type: dateTimeColumnType, nullable: true })
|
||||||
|
duplicateReviewedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 36, nullable: true })
|
||||||
|
recurringSourceId!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'datetime' })
|
@UpdateDateColumn({ type: dateTimeColumnType })
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@ManyToOne(() => User, (user) => user.expenses, { onDelete: 'CASCADE' })
|
@ManyToOne(() => User, (user) => user.expenses, { onDelete: 'CASCADE' })
|
||||||
user!: User;
|
user!: Relation<User>;
|
||||||
|
|
||||||
@ManyToOne(() => Category, (category) => category.expenses, { eager: true, onDelete: 'RESTRICT' })
|
@ManyToOne(() => Category, (category) => category.expenses, { eager: true, onDelete: 'RESTRICT' })
|
||||||
category!: Category;
|
category!: Relation<Category>;
|
||||||
|
|
||||||
@OneToMany(() => Proof, (proof) => proof.expense, { eager: true, cascade: true })
|
@OneToMany(() => Proof, (proof) => proof.expense, { eager: true, cascade: true })
|
||||||
proofs!: Proof[];
|
proofs!: Relation<Proof[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
import { User } from './User.js';
|
import { User } from './User.js';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
@Entity('merchants')
|
@Entity('merchants')
|
||||||
export class Merchant {
|
export class Merchant {
|
||||||
@@ -18,12 +20,12 @@ export class Merchant {
|
|||||||
@Column({ type: 'boolean', default: true })
|
@Column({ type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'datetime' })
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'datetime' })
|
@UpdateDateColumn({ type: dateTimeColumnType })
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
user!: User;
|
user!: Relation<User>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
import { Expense } from './Expense.js';
|
import { Expense } from './Expense.js';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
export type ProofType = 'RECEIPT' | 'INVOICE' | 'NOTE' | 'BANK_STATEMENT' | 'OTHER';
|
export type ProofType = 'RECEIPT' | 'INVOICE' | 'NOTE' | 'BANK_STATEMENT' | 'OTHER';
|
||||||
|
|
||||||
@@ -29,9 +31,9 @@ export class Proof {
|
|||||||
@Column({ type: 'int', nullable: true })
|
@Column({ type: 'int', nullable: true })
|
||||||
fileSize!: number | null;
|
fileSize!: number | null;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'datetime' })
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@ManyToOne(() => Expense, (expense) => expense.proofs, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Expense, (expense) => expense.proofs, { onDelete: 'CASCADE' })
|
||||||
expense!: Expense;
|
expense!: Relation<Expense>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
|
import { Category } from './Category.js';
|
||||||
|
import { User } from './User.js';
|
||||||
|
import { decimalTransformer } from '../utils/decimal.js';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
|
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
||||||
|
|
||||||
|
@Entity('recurring_expenses')
|
||||||
|
export class RecurringExpense {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 140 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2, transformer: decimalTransformer })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
merchant!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
paymentMethod!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 12, default: 'PLN' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'MONTHLY' })
|
||||||
|
frequency!: RecurringFrequency;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 1 })
|
||||||
|
intervalValue!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'date' })
|
||||||
|
startDate!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date' })
|
||||||
|
nextRunDate!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true })
|
||||||
|
lastRunDate!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true })
|
||||||
|
endDate!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
maxOccurrences!: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
generatedCount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'PENDING' })
|
||||||
|
defaultStatus!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
tags!: string[] | null;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
customFields!: Record<string, string> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: dateTimeColumnType })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
user!: Relation<User>;
|
||||||
|
|
||||||
|
@ManyToOne(() => Category, { eager: true, onDelete: 'RESTRICT' })
|
||||||
|
category!: Relation<Category>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
import { Category } from './Category.js';
|
import { Category } from './Category.js';
|
||||||
import { Expense } from './Expense.js';
|
import { Expense } from './Expense.js';
|
||||||
|
import { dateTimeColumnType } from '../utils/db-column-types.js';
|
||||||
|
|
||||||
export type UserRole = 'ADMIN' | 'USER';
|
export type UserRole = 'ADMIN' | 'USER';
|
||||||
|
|
||||||
@@ -27,6 +29,9 @@ export class User {
|
|||||||
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
||||||
defaultCurrency!: string;
|
defaultCurrency!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
integrationsEnabled!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'simple-json', nullable: true })
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
reportPreferences!: {
|
reportPreferences!: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -36,12 +41,22 @@ export class User {
|
|||||||
categoryIds?: string[];
|
categoryIds?: string[];
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'datetime' })
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
shoppingListIntegration!: {
|
||||||
|
enabled?: boolean;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiToken?: string;
|
||||||
|
authMode?: 'bearer' | 'x-api-token' | 'both';
|
||||||
|
ownerId?: string | null;
|
||||||
|
defaultListId?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: dateTimeColumnType })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@OneToMany(() => Category, (category) => category.user)
|
@OneToMany(() => Category, (category) => category.user)
|
||||||
categories!: Category[];
|
categories!: Relation<Category[]>;
|
||||||
|
|
||||||
@OneToMany(() => Expense, (expense) => expense.user)
|
@OneToMany(() => Expense, (expense) => expense.user)
|
||||||
expenses!: Expense[];
|
expenses!: Relation<Expense[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { env } from '../config/env.js';
|
|||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
const header = req.headers.authorization;
|
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 {
|
try {
|
||||||
req.user = jwt.verify(header.replace('Bearer ', ''), env.JWT_SECRET) as { id: string; email: string; role: 'ADMIN' | 'USER' };
|
req.user = jwt.verify(header.replace('Bearer ', ''), env.JWT_SECRET) as { id: string; email: string; role: 'ADMIN' | 'USER' };
|
||||||
return next();
|
return next();
|
||||||
} catch {
|
} 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) => {
|
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();
|
return next();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ import path from 'node:path';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadDir), filename: (_req, file, cb) => cb(null, `${Date.now()}-${uuidv4()}${path.extname(file.originalname || '')}`) });
|
|
||||||
export const uploadSingleProof = multer({ storage, limits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 } }).single('proofFile');
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||||
|
filename: (_req, file, cb) => cb(null, `${Date.now()}-${uuidv4()}${path.extname(file.originalname || '')}`)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadProofFiles = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, files: 8 }
|
||||||
|
}).any();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import {
|
||||||
|
createAdminUser,
|
||||||
getSettings,
|
getSettings,
|
||||||
|
getSystemInfo,
|
||||||
listUsers,
|
listUsers,
|
||||||
testSmtp,
|
testSmtp,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
@@ -14,5 +16,7 @@ adminRouter.use(requireAuth, requireAdmin);
|
|||||||
adminRouter.get('/settings', getSettings);
|
adminRouter.get('/settings', getSettings);
|
||||||
adminRouter.put('/settings', updateSettings);
|
adminRouter.put('/settings', updateSettings);
|
||||||
adminRouter.get('/users', listUsers);
|
adminRouter.get('/users', listUsers);
|
||||||
|
adminRouter.post('/users', createAdminUser);
|
||||||
adminRouter.patch('/users/:id', updateUser);
|
adminRouter.patch('/users/:id', updateUser);
|
||||||
adminRouter.post('/test-smtp', testSmtp);
|
adminRouter.post('/test-smtp', testSmtp);
|
||||||
|
adminRouter.get('/system-info', getSystemInfo);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { createBudget, deleteBudget, listBudgets, updateBudget } from '../controllers/budget.controller.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
export const budgetRouter = Router();
|
||||||
|
budgetRouter.use(requireAuth);
|
||||||
|
budgetRouter.get('/', listBudgets);
|
||||||
|
budgetRouter.post('/', createBudget);
|
||||||
|
budgetRouter.put('/:id', updateBudget);
|
||||||
|
budgetRouter.delete('/:id', deleteBudget);
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { addProof, createExpense, deleteExpense, listExpenses, updateExpense } from '../controllers/expense.controller.js';
|
import { addProof, bulkDeleteExpenses, bulkUpdateExpenseStatus, createExpense, deleteExpense, deleteProof, getExpense, getProofFile, listDuplicates, listExpenses, reviewDuplicate, updateExpense, updateExpenseStatus } from '../controllers/expense.controller.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { uploadSingleProof } from '../middleware/upload.js';
|
import { uploadProofFiles } from '../middleware/upload.js';
|
||||||
|
|
||||||
export const expenseRouter = Router();
|
export const expenseRouter = Router();
|
||||||
expenseRouter.use(requireAuth);
|
expenseRouter.use(requireAuth);
|
||||||
expenseRouter.get('/', listExpenses);
|
expenseRouter.get('/', listExpenses);
|
||||||
expenseRouter.post('/', uploadSingleProof, createExpense);
|
expenseRouter.get('/duplicates', listDuplicates);
|
||||||
expenseRouter.put('/:id', updateExpense);
|
expenseRouter.patch('/bulk/status', bulkUpdateExpenseStatus);
|
||||||
|
expenseRouter.post('/bulk/delete', bulkDeleteExpenses);
|
||||||
|
expenseRouter.get('/item/:id', getExpense);
|
||||||
|
expenseRouter.get('/:id', getExpense);
|
||||||
|
expenseRouter.post('/', uploadProofFiles, createExpense);
|
||||||
|
expenseRouter.put('/:id', uploadProofFiles, updateExpense);
|
||||||
|
expenseRouter.patch('/:id/status', updateExpenseStatus);
|
||||||
|
expenseRouter.post('/:id/duplicate-review', reviewDuplicate);
|
||||||
expenseRouter.delete('/:id', deleteExpense);
|
expenseRouter.delete('/:id', deleteExpense);
|
||||||
expenseRouter.post('/:id/proofs', uploadSingleProof, addProof);
|
expenseRouter.get('/:id/proofs/:proofId/file', getProofFile);
|
||||||
|
expenseRouter.post('/:id/proofs', uploadProofFiles, addProof);
|
||||||
|
expenseRouter.delete('/:id/proofs/:proofId', deleteProof);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { adminRouter } from './admin.routes.js';
|
import { adminRouter } from './admin.routes.js';
|
||||||
import { authRouter } from './auth.routes.js';
|
import { authRouter } from './auth.routes.js';
|
||||||
|
import { budgetRouter } from './budget.routes.js';
|
||||||
import { categoryRouter } from './category.routes.js';
|
import { categoryRouter } from './category.routes.js';
|
||||||
import { expenseRouter } from './expense.routes.js';
|
import { expenseRouter } from './expense.routes.js';
|
||||||
|
import { integrationRouter } from './integration.routes.js';
|
||||||
import { merchantRouter } from './merchant.routes.js';
|
import { merchantRouter } from './merchant.routes.js';
|
||||||
|
import { recurringRouter } from './recurring.routes.js';
|
||||||
import { reportRouter } from './report.routes.js';
|
import { reportRouter } from './report.routes.js';
|
||||||
import { statisticsRouter } from './statistics.routes.js';
|
import { statisticsRouter } from './statistics.routes.js';
|
||||||
|
|
||||||
@@ -16,4 +19,7 @@ apiRouter.use('/expenses', expenseRouter);
|
|||||||
apiRouter.use('/statistics', statisticsRouter);
|
apiRouter.use('/statistics', statisticsRouter);
|
||||||
apiRouter.use('/merchants', merchantRouter);
|
apiRouter.use('/merchants', merchantRouter);
|
||||||
apiRouter.use('/reports', reportRouter);
|
apiRouter.use('/reports', reportRouter);
|
||||||
|
apiRouter.use('/budgets', budgetRouter);
|
||||||
|
apiRouter.use('/recurring-expenses', recurringRouter);
|
||||||
|
apiRouter.use('/integrations', integrationRouter);
|
||||||
apiRouter.use('/admin', adminRouter);
|
apiRouter.use('/admin', adminRouter);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getShoppingListExpenses,
|
||||||
|
getShoppingListLatestExpenses,
|
||||||
|
getShoppingListSettings,
|
||||||
|
getShoppingListSummary,
|
||||||
|
getShoppingLists,
|
||||||
|
importShoppingListAsExpense,
|
||||||
|
importShoppingListItemAsExpense,
|
||||||
|
importShoppingPeriodAsExpense,
|
||||||
|
testShoppingListConnection,
|
||||||
|
updateShoppingListSettings
|
||||||
|
} from '../controllers/integration.controller.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
export const integrationRouter = Router();
|
||||||
|
integrationRouter.use(requireAuth);
|
||||||
|
integrationRouter.get('/shopping-list', getShoppingListSettings);
|
||||||
|
integrationRouter.put('/shopping-list', updateShoppingListSettings);
|
||||||
|
integrationRouter.post('/shopping-list/test', testShoppingListConnection);
|
||||||
|
integrationRouter.get('/shopping-list/summary', getShoppingListSummary);
|
||||||
|
integrationRouter.get('/shopping-list/latest', getShoppingListLatestExpenses);
|
||||||
|
integrationRouter.get('/shopping-list/lists', getShoppingLists);
|
||||||
|
integrationRouter.get('/shopping-list/lists/:id/expenses', getShoppingListExpenses);
|
||||||
|
integrationRouter.post('/shopping-list/import-list', importShoppingListAsExpense);
|
||||||
|
integrationRouter.post('/shopping-list/import-period', importShoppingPeriodAsExpense);
|
||||||
|
integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { createRecurringExpense, deleteRecurringExpense, listRecurringExpenses, processRecurringNow, updateRecurringExpense } from '../controllers/recurring.controller.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
export const recurringRouter = Router();
|
||||||
|
recurringRouter.use(requireAuth);
|
||||||
|
recurringRouter.get('/', listRecurringExpenses);
|
||||||
|
recurringRouter.post('/', createRecurringExpense);
|
||||||
|
recurringRouter.put('/:id', updateRecurringExpense);
|
||||||
|
recurringRouter.delete('/:id', deleteRecurringExpense);
|
||||||
|
recurringRouter.post('/run', processRecurringNow);
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import { exportReport, getPreferences, previewReport, sendReport, updatePreferences } from '../controllers/report.controller.js';
|
||||||
getPreferences,
|
|
||||||
previewReport,
|
|
||||||
sendReport,
|
|
||||||
updatePreferences
|
|
||||||
} from '../controllers/report.controller.js';
|
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
export const reportRouter = Router();
|
export const reportRouter = Router();
|
||||||
@@ -14,3 +9,4 @@ reportRouter.get('/preferences', getPreferences);
|
|||||||
reportRouter.put('/preferences', updatePreferences);
|
reportRouter.put('/preferences', updatePreferences);
|
||||||
reportRouter.post('/preview', previewReport);
|
reportRouter.post('/preview', previewReport);
|
||||||
reportRouter.post('/send', sendReport);
|
reportRouter.post('/send', sendReport);
|
||||||
|
reportRouter.get('/export', exportReport);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getOverview } from '../controllers/statistics.controller.js';
|
import { getCashflow, getOverview } from '../controllers/statistics.controller.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
export const statisticsRouter = Router();
|
export const statisticsRouter = Router();
|
||||||
statisticsRouter.use(requireAuth);
|
statisticsRouter.use(requireAuth);
|
||||||
statisticsRouter.get('/overview', getOverview);
|
statisticsRouter.get('/overview', getOverview);
|
||||||
|
statisticsRouter.get('/cashflow', getCashflow);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const sanitizeUser = (user: User) => ({
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
defaultCurrency: user.defaultCurrency,
|
defaultCurrency: user.defaultCurrency,
|
||||||
|
integrationsEnabled: Boolean(user.integrationsEnabled),
|
||||||
reportPreferences: user.reportPreferences ?? {
|
reportPreferences: user.reportPreferences ?? {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
frequency: 'monthly',
|
frequency: 'monthly',
|
||||||
@@ -39,7 +40,7 @@ export const createUser = async (input: {
|
|||||||
defaultCurrency?: string;
|
defaultCurrency?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const existing = await repo().findOne({ where: { email: input.email.toLowerCase() } });
|
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({
|
const user = repo().create({
|
||||||
fullName: input.fullName,
|
fullName: input.fullName,
|
||||||
@@ -47,6 +48,7 @@ export const createUser = async (input: {
|
|||||||
passwordHash: await hashPassword(input.password),
|
passwordHash: await hashPassword(input.password),
|
||||||
role: input.role ?? 'USER',
|
role: input.role ?? 'USER',
|
||||||
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
|
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
|
||||||
|
integrationsEnabled: false,
|
||||||
reportPreferences: {
|
reportPreferences: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
frequency: 'monthly',
|
frequency: 'monthly',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { AppSetting } from "../entities/AppSetting.js";
|
||||||
|
|
||||||
|
type MailSettings = Pick<AppSetting, 'smtpHost' | 'smtpPort' | 'smtpSecure' | 'smtpUser' | 'smtpPassword' | 'smtpFromName' | 'smtpFromEmail' | 'appName'>;
|
||||||
|
|
||||||
|
const normalizePort = (value: number | null | undefined) => {
|
||||||
|
const port = Number(value ?? 0);
|
||||||
|
return Number.isFinite(port) && port > 0 ? port : 587;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSmtpSecurity = (settings: Pick<AppSetting, 'smtpPort' | 'smtpSecure'>) => {
|
||||||
|
const port = normalizePort(settings.smtpPort);
|
||||||
|
const wantsSecure = Boolean(settings.smtpSecure);
|
||||||
|
const secure = port === 465 ? true : wantsSecure && port !== 587;
|
||||||
|
const requireTLS = port === 587 || (!secure && wantsSecure);
|
||||||
|
return { port, secure, requireTLS };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSmtpTransport = (settings: MailSettings) => {
|
||||||
|
const security = resolveSmtpSecurity(settings);
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: settings.smtpHost ?? undefined,
|
||||||
|
port: security.port,
|
||||||
|
secure: security.secure,
|
||||||
|
requireTLS: security.requireTLS,
|
||||||
|
auth: settings.smtpUser ? { user: settings.smtpUser, pass: settings.smtpPassword ?? '' } : undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatFromAddress = (settings: Pick<AppSetting, 'smtpFromName' | 'smtpFromEmail'>) => {
|
||||||
|
if (!settings.smtpFromEmail) return '';
|
||||||
|
return settings.smtpFromName
|
||||||
|
? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>`
|
||||||
|
: settings.smtpFromEmail;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeSmtpMode = (settings: Pick<AppSetting, 'smtpPort' | 'smtpSecure'>) => {
|
||||||
|
const { port, secure, requireTLS } = resolveSmtpSecurity(settings);
|
||||||
|
if (secure) return `implicit TLS on port ${port}`;
|
||||||
|
if (requireTLS) return `STARTTLS on port ${port}`;
|
||||||
|
return `plain/upgrade connection on port ${port}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
|
import { Expense, type ExpenseStatus } from '../entities/Expense.js';
|
||||||
|
import { RecurringExpense, type RecurringFrequency } from '../entities/RecurringExpense.js';
|
||||||
|
|
||||||
|
const recurringRepo = () => AppDataSource.getRepository(RecurringExpense);
|
||||||
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
|
||||||
|
const toDate = (value: string) => new Date(`${value}T00:00:00`);
|
||||||
|
const toDateString = (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 advanceDate = (value: string, frequency: RecurringFrequency, intervalValue: number) => {
|
||||||
|
const date = toDate(value);
|
||||||
|
if (frequency === 'WEEKLY') date.setDate(date.getDate() + intervalValue * 7);
|
||||||
|
if (frequency === 'MONTHLY') date.setMonth(date.getMonth() + intervalValue);
|
||||||
|
if (frequency === 'YEARLY') date.setFullYear(date.getFullYear() + intervalValue);
|
||||||
|
return toDateString(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectDuplicate = async (userId: string, amount: number, expenseDate: string, merchant?: string | null) => {
|
||||||
|
const items = await expenseRepo().find({
|
||||||
|
where: { user: { id: userId }, expenseDate },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: 10
|
||||||
|
});
|
||||||
|
const merchantKey = merchant?.trim().toLowerCase();
|
||||||
|
return items.some(
|
||||||
|
(item) =>
|
||||||
|
item.duplicateStatus !== 'DISMISSED' &&
|
||||||
|
Math.abs(item.amount - amount) < 0.001 &&
|
||||||
|
((merchantKey && item.merchant?.trim().toLowerCase() === merchantKey) || !merchantKey)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processDueRecurringExpenses = async (userId?: string) => {
|
||||||
|
const today = toDateString(new Date());
|
||||||
|
const rules = await recurringRepo().find({
|
||||||
|
where: userId ? { isActive: true, user: { id: userId } } : { isActive: true },
|
||||||
|
relations: { user: true, category: true },
|
||||||
|
order: { nextRunDate: 'ASC' }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
let nextRun = rule.nextRunDate;
|
||||||
|
let changed = false;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (nextRun <= today && guard < 60) {
|
||||||
|
guard += 1;
|
||||||
|
if (rule.endDate && nextRun > rule.endDate) {
|
||||||
|
rule.isActive = false;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (typeof rule.maxOccurrences === 'number' && rule.generatedCount >= rule.maxOccurrences) {
|
||||||
|
rule.isActive = false;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = await expenseRepo().findOne({
|
||||||
|
where: { user: { id: rule.user.id }, recurringSourceId: rule.id, expenseDate: nextRun }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
const isDuplicate = await detectDuplicate(rule.user.id, rule.amount, nextRun, rule.merchant);
|
||||||
|
await expenseRepo().save(
|
||||||
|
expenseRepo().create({
|
||||||
|
title: rule.title,
|
||||||
|
description: rule.description,
|
||||||
|
amount: rule.amount,
|
||||||
|
expenseDate: nextRun,
|
||||||
|
merchant: rule.merchant,
|
||||||
|
paymentMethod: rule.paymentMethod,
|
||||||
|
currency: rule.currency,
|
||||||
|
status: (rule.defaultStatus as ExpenseStatus) ?? 'PENDING',
|
||||||
|
tags: rule.tags ?? [],
|
||||||
|
customFields: rule.customFields ?? {},
|
||||||
|
possibleDuplicate: isDuplicate,
|
||||||
|
duplicateStatus: isDuplicate ? 'OPEN' : null,
|
||||||
|
duplicateReviewedAt: null,
|
||||||
|
recurringSourceId: rule.id,
|
||||||
|
user: rule.user,
|
||||||
|
category: rule.category,
|
||||||
|
proofs: []
|
||||||
|
})
|
||||||
|
);
|
||||||
|
rule.generatedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.lastRunDate = nextRun;
|
||||||
|
nextRun = advanceDate(nextRun, rule.frequency, rule.intervalValue);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
rule.nextRunDate = nextRun;
|
||||||
|
if ((rule.endDate && rule.nextRunDate > rule.endDate) || (typeof rule.maxOccurrences === 'number' && rule.generatedCount >= rule.maxOccurrences)) {
|
||||||
|
rule.isActive = false;
|
||||||
|
}
|
||||||
|
await recurringRepo().save(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,8 @@ import { AppSetting } from '../entities/AppSetting.js';
|
|||||||
import { Category } from '../entities/Category.js';
|
import { Category } from '../entities/Category.js';
|
||||||
import { createUser, findUserByEmail } from './auth.service.js';
|
import { createUser, findUserByEmail } from './auth.service.js';
|
||||||
|
|
||||||
|
const DEFAULT_APP_NAME = 'Expense Control';
|
||||||
|
|
||||||
const systemCategories = [
|
const systemCategories = [
|
||||||
{ name: 'Rachunki', color: '#b91c1c' },
|
{ name: 'Rachunki', color: '#b91c1c' },
|
||||||
{ name: 'Zakupy', color: '#2563eb' },
|
{ name: 'Zakupy', color: '#2563eb' },
|
||||||
@@ -41,7 +43,7 @@ export const bootstrapData = async () => {
|
|||||||
if (!settings) {
|
if (!settings) {
|
||||||
await settingsRepo.save(
|
await settingsRepo.save(
|
||||||
settingsRepo.create({
|
settingsRepo.create({
|
||||||
appName: env.APP_NAME,
|
appName: DEFAULT_APP_NAME,
|
||||||
defaultCurrency: env.DEFAULT_CURRENCY,
|
defaultCurrency: env.DEFAULT_CURRENCY,
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
allowedProofTypes: ['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER'],
|
allowedProofTypes: ['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER'],
|
||||||
@@ -52,7 +54,7 @@ export const bootstrapData = async () => {
|
|||||||
smtpSecure: false,
|
smtpSecure: false,
|
||||||
smtpUser: null,
|
smtpUser: null,
|
||||||
smtpPassword: null,
|
smtpPassword: null,
|
||||||
smtpFromName: env.APP_NAME,
|
smtpFromName: DEFAULT_APP_NAME,
|
||||||
smtpFromEmail: env.ADMIN_EMAIL
|
smtpFromEmail: env.ADMIN_EMAIL
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,84 @@
|
|||||||
import { Between, In } from 'typeorm';
|
import { Between, In } from 'typeorm';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
|
import { Budget } from '../entities/Budget.js';
|
||||||
import { Expense } from '../entities/Expense.js';
|
import { Expense } from '../entities/Expense.js';
|
||||||
export type StatsFilters = { userId?: string; startDate?: string; endDate?: string; categoryIds?: string[] };
|
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||||
export type FlatExpense = { id: string; amount: number; expenseDate: string; categoryId: string; categoryName: string };
|
|
||||||
|
export type StatsFilters = { userId?: string; startDate?: string; endDate?: string; categoryIds?: string[]; tag?: string; status?: string };
|
||||||
|
export type FlatExpense = {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
expenseDate: string;
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
tags?: string[];
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const labelMonth = (date: string) => date.slice(0, 7);
|
const labelMonth = (date: string) => date.slice(0, 7);
|
||||||
const labelQuarter = (date: string) => { const [year, month] = date.split('-').map(Number); return `${year}-Q${Math.ceil(month / 3)}`; };
|
const labelQuarter = (date: string) => {
|
||||||
|
const [year, month] = date.split('-').map(Number);
|
||||||
|
return `${year}-Q${Math.ceil(month / 3)}`;
|
||||||
|
};
|
||||||
const labelYear = (date: string) => date.slice(0, 4);
|
const labelYear = (date: string) => date.slice(0, 4);
|
||||||
export const buildBucketLabel = (date: string, bucket: 'month' | 'quarter' | 'year') => bucket === 'year' ? labelYear(date) : bucket === 'quarter' ? labelQuarter(date) : labelMonth(date);
|
|
||||||
|
export const buildBucketLabel = (date: string, bucket: 'month' | 'quarter' | 'year') =>
|
||||||
|
bucket === 'year' ? labelYear(date) : bucket === 'quarter' ? labelQuarter(date) : labelMonth(date);
|
||||||
|
|
||||||
export const aggregateStatistics = (expenses: FlatExpense[], bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
export const aggregateStatistics = (expenses: FlatExpense[], bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||||
const total = expenses.reduce((sum, item) => sum + item.amount, 0);
|
const total = expenses.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const byCategoryMap = new Map<string, { categoryId: string; categoryName: string; total: number; count: number }>();
|
const byCategoryMap = new Map<string, { categoryId: string; categoryName: string; total: number; count: number }>();
|
||||||
const timelineMap = new Map<string, number>();
|
const timelineMap = new Map<string, number>();
|
||||||
|
const byTagMap = new Map<string, number>();
|
||||||
|
const byStatusMap = new Map<string, number>();
|
||||||
|
|
||||||
for (const expense of expenses) {
|
for (const expense of expenses) {
|
||||||
const existing = byCategoryMap.get(expense.categoryId) ?? { categoryId: expense.categoryId, categoryName: expense.categoryName, total: 0, count: 0 };
|
const existing = byCategoryMap.get(expense.categoryId) ?? {
|
||||||
|
categoryId: expense.categoryId,
|
||||||
|
categoryName: expense.categoryName,
|
||||||
|
total: 0,
|
||||||
|
count: 0
|
||||||
|
};
|
||||||
existing.total += expense.amount;
|
existing.total += expense.amount;
|
||||||
existing.count += 1;
|
existing.count += 1;
|
||||||
byCategoryMap.set(expense.categoryId, existing);
|
byCategoryMap.set(expense.categoryId, existing);
|
||||||
|
|
||||||
const bucketLabel = buildBucketLabel(expense.expenseDate, bucket);
|
const bucketLabel = buildBucketLabel(expense.expenseDate, bucket);
|
||||||
timelineMap.set(bucketLabel, (timelineMap.get(bucketLabel) ?? 0) + expense.amount);
|
timelineMap.set(bucketLabel, (timelineMap.get(bucketLabel) ?? 0) + expense.amount);
|
||||||
|
|
||||||
|
for (const tag of expense.tags ?? []) {
|
||||||
|
byTagMap.set(tag, (byTagMap.get(tag) ?? 0) + expense.amount);
|
||||||
}
|
}
|
||||||
const byCategory = [...byCategoryMap.values()].sort((a, b) => b.total - a.total).map((item) => ({ ...item, total: Number(item.total.toFixed(2)) }));
|
|
||||||
const timeline = [...timelineMap.entries()].map(([label, totalValue]) => ({ label, total: Number(totalValue.toFixed(2)) })).sort((a, b) => a.label.localeCompare(b.label));
|
if (expense.status) byStatusMap.set(expense.status, (byStatusMap.get(expense.status) ?? 0) + 1);
|
||||||
return { total: Number(total.toFixed(2)), count: expenses.length, average: expenses.length ? Number((total / expenses.length).toFixed(2)) : 0, byCategory, timeline, topCategory: byCategory[0] ?? null };
|
}
|
||||||
|
|
||||||
|
const byCategory = [...byCategoryMap.values()]
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.map((item) => ({ ...item, total: Number(item.total.toFixed(2)) }));
|
||||||
|
const timeline = [...timelineMap.entries()]
|
||||||
|
.map(([label, totalValue]) => ({ label, total: Number(totalValue.toFixed(2)) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
const byTag = [...byTagMap.entries()]
|
||||||
|
.map(([tag, totalValue]) => ({ tag, total: Number(totalValue.toFixed(2)) }))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 10);
|
||||||
|
const byStatus = [...byStatusMap.entries()]
|
||||||
|
.map(([status, count]) => ({ status, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: Number(total.toFixed(2)),
|
||||||
|
count: expenses.length,
|
||||||
|
average: expenses.length ? Number((total / expenses.length).toFixed(2)) : 0,
|
||||||
|
byCategory,
|
||||||
|
timeline,
|
||||||
|
byTag,
|
||||||
|
byStatus,
|
||||||
|
topCategory: byCategory[0] ?? null
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||||
const repo = AppDataSource.getRepository(Expense);
|
const repo = AppDataSource.getRepository(Expense);
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
@@ -31,11 +87,125 @@ export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'qu
|
|||||||
else if (filters.startDate) where.expenseDate = Between(filters.startDate, '2999-12-31');
|
else if (filters.startDate) where.expenseDate = Between(filters.startDate, '2999-12-31');
|
||||||
else if (filters.endDate) where.expenseDate = Between('1900-01-01', filters.endDate);
|
else if (filters.endDate) where.expenseDate = Between('1900-01-01', filters.endDate);
|
||||||
if (filters.categoryIds?.length) where.category = { id: In(filters.categoryIds) };
|
if (filters.categoryIds?.length) where.category = { id: In(filters.categoryIds) };
|
||||||
|
if (filters.status) where.status = filters.status;
|
||||||
|
|
||||||
const expenses = await repo.find({ where, relations: { category: true }, order: { expenseDate: 'DESC' } });
|
const expenses = await repo.find({ where, relations: { category: true }, order: { expenseDate: 'DESC' } });
|
||||||
return aggregateStatistics(expenses.map((expense) => ({ id: expense.id, amount: expense.amount, expenseDate: expense.expenseDate, categoryId: expense.category.id, categoryName: expense.category.name })), bucket);
|
const filteredByTag = filters.tag
|
||||||
|
? expenses.filter((expense) => (expense.tags ?? []).some((tag) => tag.toLowerCase() === filters.tag!.toLowerCase()))
|
||||||
|
: expenses;
|
||||||
|
|
||||||
|
return aggregateStatistics(
|
||||||
|
filteredByTag.map((expense) => ({
|
||||||
|
id: expense.id,
|
||||||
|
amount: expense.amount,
|
||||||
|
expenseDate: expense.expenseDate,
|
||||||
|
categoryId: expense.category.id,
|
||||||
|
categoryName: expense.category.name,
|
||||||
|
tags: expense.tags ?? [],
|
||||||
|
status: expense.status
|
||||||
|
})),
|
||||||
|
bucket
|
||||||
|
);
|
||||||
};
|
};
|
||||||
export const detectPotentialDuplicate = async (input: { userId: string; amount: number; expenseDate: string; merchant?: string | null }) => {
|
|
||||||
const repo = AppDataSource.getRepository(Expense);
|
const currentMonthKey = () => {
|
||||||
const candidates = await repo.find({ where: { user: { id: input.userId }, expenseDate: Between(input.expenseDate, input.expenseDate) } });
|
const date = new Date();
|
||||||
return candidates.some((item) => Math.abs(item.amount - input.amount) < 0.001 && (input.merchant ? item.merchant?.toLowerCase() === input.merchant.toLowerCase() : true));
|
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthRange = (monthKey: string) => {
|
||||||
|
const [yearText, monthText] = monthKey.split('-');
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText);
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
return { startDate: `${monthKey}-01`, endDate: `${monthKey}-${String(lastDay).padStart(2, '0')}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCashflowSummary = async (userId: string) => {
|
||||||
|
const expenseRepo = AppDataSource.getRepository(Expense);
|
||||||
|
const budgetRepo = AppDataSource.getRepository(Budget);
|
||||||
|
const recurringRepo = AppDataSource.getRepository(RecurringExpense);
|
||||||
|
|
||||||
|
const currentMonth = currentMonthKey();
|
||||||
|
const currentRange = monthRange(currentMonth);
|
||||||
|
const startWindow = new Date();
|
||||||
|
startWindow.setMonth(startWindow.getMonth() - 5, 1);
|
||||||
|
const startDate = `${startWindow.getFullYear()}-${`${startWindow.getMonth() + 1}`.padStart(2, '0')}-01`;
|
||||||
|
|
||||||
|
const expenses = await expenseRepo.find({
|
||||||
|
where: { user: { id: userId }, expenseDate: Between(startDate, currentRange.endDate) },
|
||||||
|
relations: { category: true },
|
||||||
|
order: { expenseDate: 'ASC' }
|
||||||
|
});
|
||||||
|
const budgets = await budgetRepo.find({ where: { user: { id: userId }, month: currentMonth }, relations: { category: true } });
|
||||||
|
const recurring = await recurringRepo.find({ where: { user: { id: userId }, isActive: true }, relations: { category: true }, order: { nextRunDate: 'ASC' } });
|
||||||
|
|
||||||
|
const months = new Map<string, { label: string; actual: number; budget: number }>();
|
||||||
|
for (let offset = 5; offset >= 0; offset -= 1) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(date.getMonth() - offset, 1);
|
||||||
|
const key = `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
||||||
|
months.set(key, { label: key, actual: 0, budget: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const expense of expenses) {
|
||||||
|
const month = expense.expenseDate.slice(0, 7);
|
||||||
|
const entry = months.get(month);
|
||||||
|
if (entry && expense.status !== 'REJECTED' && expense.status !== 'DRAFT') entry.actual += expense.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const budget of budgets) {
|
||||||
|
const entry = months.get(budget.month);
|
||||||
|
if (entry) entry.budget += budget.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateCount = expenses.filter((expense) => expense.possibleDuplicate && expense.duplicateStatus === 'OPEN' && expense.expenseDate.startsWith(currentMonth)).length;
|
||||||
|
const currentMonthExpenses = expenses.filter((expense) => expense.expenseDate.startsWith(currentMonth));
|
||||||
|
const actualCurrent = currentMonthExpenses
|
||||||
|
.filter((expense) => expense.status !== 'REJECTED' && expense.status !== 'DRAFT')
|
||||||
|
.reduce((sum, expense) => sum + expense.amount, 0);
|
||||||
|
const pendingApproval = currentMonthExpenses.filter((expense) => expense.status === 'PENDING').length;
|
||||||
|
const upcomingRecurring = recurring
|
||||||
|
.filter((item) => item.nextRunDate >= currentRange.startDate && item.nextRunDate <= currentRange.endDate)
|
||||||
|
.map((item) => ({ id: item.id, title: item.title, amount: item.amount, nextRunDate: item.nextRunDate, frequency: item.frequency }));
|
||||||
|
|
||||||
|
const totalBudget = budgets.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
const budgetUsagePercent = totalBudget ? Number(((actualCurrent / totalBudget) * 100).toFixed(1)) : 0;
|
||||||
|
const alerts = budgets
|
||||||
|
.map((budget) => {
|
||||||
|
const spent = currentMonthExpenses
|
||||||
|
.filter((expense) => expense.status !== 'REJECTED' && expense.status !== 'DRAFT')
|
||||||
|
.filter((expense) => !budget.category || expense.category.id === budget.category.id)
|
||||||
|
.reduce((sum, expense) => sum + expense.amount, 0);
|
||||||
|
const usagePercent = budget.amount ? Number(((spent / budget.amount) * 100).toFixed(1)) : 0;
|
||||||
|
return { id: budget.id, name: budget.name || budget.category?.name || 'Monthly budget', usagePercent, spent: Number(spent.toFixed(2)), amount: budget.amount };
|
||||||
|
})
|
||||||
|
.filter((item) => item.usagePercent >= 80)
|
||||||
|
.sort((a, b) => b.usagePercent - a.usagePercent);
|
||||||
|
|
||||||
|
const statusSummary = aggregateStatistics(
|
||||||
|
currentMonthExpenses.map((expense) => ({
|
||||||
|
id: expense.id,
|
||||||
|
amount: expense.amount,
|
||||||
|
expenseDate: expense.expenseDate,
|
||||||
|
categoryId: expense.category.id,
|
||||||
|
categoryName: expense.category.name,
|
||||||
|
tags: expense.tags ?? [],
|
||||||
|
status: expense.status
|
||||||
|
}))
|
||||||
|
).byStatus;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentMonth,
|
||||||
|
actualCurrent: Number(actualCurrent.toFixed(2)),
|
||||||
|
totalBudget: Number(totalBudget.toFixed(2)),
|
||||||
|
budgetUsagePercent,
|
||||||
|
duplicateCount,
|
||||||
|
pendingApproval,
|
||||||
|
forecastCurrentMonth: Number((actualCurrent + upcomingRecurring.reduce((sum, item) => sum + item.amount, 0)).toFixed(2)),
|
||||||
|
trend: [...months.values()].map((item) => ({ label: item.label, actual: Number(item.actual.toFixed(2)), budget: Number(item.budget.toFixed(2)) })),
|
||||||
|
alerts,
|
||||||
|
upcomingRecurring,
|
||||||
|
statusSummary
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module 'nodemailer';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const dateTimeColumnType: 'timestamp' | 'datetime' =
|
||||||
|
process.env.DB_TYPE === 'postgres' ? 'timestamp' : 'datetime';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Proof } from '../entities/Proof.js';
|
import type { Proof } from '../entities/Proof.js';
|
||||||
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
|
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
|
||||||
export const serializeProof = (proof: Proof) => ({
|
export const serializeProof = (proof: Proof, _expenseId?: string) => ({
|
||||||
id: proof.id,
|
id: proof.id,
|
||||||
type: proof.type,
|
type: proof.type,
|
||||||
label: proof.label,
|
label: proof.label,
|
||||||
@@ -9,5 +9,6 @@ export const serializeProof = (proof: Proof) => ({
|
|||||||
mimeType: proof.mimeType,
|
mimeType: proof.mimeType,
|
||||||
fileSize: proof.fileSize,
|
fileSize: proof.fileSize,
|
||||||
fileUrl: buildProofUrl(proof.storedName),
|
fileUrl: buildProofUrl(proof.storedName),
|
||||||
|
previewUrl: buildProofUrl(proof.storedName),
|
||||||
createdAt: proof.createdAt
|
createdAt: proof.createdAt
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveSmtpSecurity } from '../src/services/mail.service.js';
|
||||||
|
|
||||||
|
describe('mail.service', () => {
|
||||||
|
it('uses STARTTLS for port 587 even when secure flag is enabled', () => {
|
||||||
|
expect(resolveSmtpSecurity({ smtpPort: 587, smtpSecure: true })).toEqual({
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
requireTLS: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses implicit TLS for port 465', () => {
|
||||||
|
expect(resolveSmtpSecurity({ smtpPort: 465, smtpSecure: false })).toEqual({
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
requireTLS: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -13,5 +13,5 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -1,10 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:18
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- ./docker-data/postgres:/var/lib/postgresql
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -21,7 +25,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- ./docker-data/uploads:/app/uploads
|
||||||
expose:
|
expose:
|
||||||
- "4000"
|
- "4000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -61,9 +65,5 @@ services:
|
|||||||
- "${PUBLIC_HTTP_PORT:-8080}:80"
|
- "${PUBLIC_HTTP_PORT:-8080}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./reverse-proxy/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
|
- ./reverse-proxy/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||||
- uploads_data:/srv/uploads:ro
|
- ./docker-data/uploads:/srv/uploads:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
uploads_data:
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "expense-control-suite",
|
"name": "expense-control-suite",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"workspaces": ["api", "web"],
|
"workspaces": ["api", "web"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "sh ./start_dev.sh",
|
"dev": "sh ./start_dev.sh",
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
server {
|
map $uri $static_file {
|
||||||
listen 80;
|
~*\.(?:css|js|mjs|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ 1;
|
||||||
server_name ${SERVER_NAME};
|
default 0;
|
||||||
|
}
|
||||||
|
|
||||||
client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_tokens off;
|
||||||
|
etag off;
|
||||||
|
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -10,20 +16,38 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_hide_header Etag;
|
||||||
|
proxy_hide_header strict-transport-security;
|
||||||
|
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:4000/api/;
|
proxy_pass http://api:4000/api/;
|
||||||
|
add_header Cache-Control "no-store, no-cache" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
alias /srv/uploads/;
|
alias /srv/uploads/;
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 7d;
|
expires 30d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public, max-age=86400, immutable" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://web:80/;
|
proxy_pass http://web:80/;
|
||||||
|
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
|
||||||
|
if ($static_file) {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_header Cache-Control "no-store, no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Executable → Regular
-1
@@ -39,7 +39,6 @@ export DB_SYNC=${DB_SYNC:-true}
|
|||||||
export DB_LOGGING=${DB_LOGGING:-false}
|
export DB_LOGGING=${DB_LOGGING:-false}
|
||||||
export JWT_SECRET=${JWT_SECRET:-dev-secret-key}
|
export JWT_SECRET=${JWT_SECRET:-dev-secret-key}
|
||||||
export JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
|
export JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
|
||||||
export APP_NAME=${APP_NAME:-Expense Control Dev}
|
|
||||||
export DEFAULT_CURRENCY=${DEFAULT_CURRENCY:-PLN}
|
export DEFAULT_CURRENCY=${DEFAULT_CURRENCY:-PLN}
|
||||||
export ADMIN_EMAIL=${ADMIN_EMAIL:-admin@local.dev}
|
export ADMIN_EMAIL=${ADMIN_EMAIL:-admin@local.dev}
|
||||||
export ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin123!}
|
export ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin123!}
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "1.5MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "2MB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
+4
-2
@@ -2,6 +2,8 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
server_tokens off;
|
||||||
|
etag off;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -9,8 +11,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
|
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
|
||||||
expires 7d;
|
expires 1y;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+35
-125
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "expense-control-web",
|
"name": "expense-control-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "expense-control-web",
|
"name": "expense-control-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^21.2.7",
|
"@angular/common": "^21.2.7",
|
||||||
"@angular/compiler": "^21.2.7",
|
"@angular/compiler": "^21.2.7",
|
||||||
@@ -625,9 +625,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -649,9 +649,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2452,9 +2452,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2472,9 +2469,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2492,9 +2486,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2512,9 +2503,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2532,9 +2520,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2552,9 +2537,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2572,9 +2554,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2689,9 +2668,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@npmcli/agent/node_modules/lru-cache": {
|
"node_modules/@npmcli/agent/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2742,9 +2721,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@npmcli/git/node_modules/lru-cache": {
|
"node_modules/@npmcli/git/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3018,9 +2997,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3042,9 +3018,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3066,9 +3039,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3090,9 +3060,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3114,9 +3081,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3138,9 +3102,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3328,9 +3289,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3348,9 +3306,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3368,9 +3323,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3388,9 +3340,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3567,9 +3516,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3584,9 +3530,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3601,9 +3544,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3618,9 +3558,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3635,9 +3572,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3652,9 +3586,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3669,9 +3600,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3686,9 +3614,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3703,9 +3628,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3720,9 +3642,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3737,9 +3656,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3754,9 +3670,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3771,9 +3684,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4342,9 +4252,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.14",
|
"version": "2.10.15",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
||||||
"integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==",
|
"integrity": "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4523,9 +4433,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cacache/node_modules/lru-cache": {
|
"node_modules/cacache/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4564,9 +4474,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001785",
|
"version": "1.0.30001786",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
||||||
"integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==",
|
"integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4931,9 +4841,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5643,9 +5553,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.10",
|
"version": "4.12.11",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
|
||||||
"integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==",
|
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5666,9 +5576,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hosted-git-info/node_modules/lru-cache": {
|
"node_modules/hosted-git-info/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7107,9 +7017,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "expense-control-web",
|
"name": "expense-control-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
|
<app-toast-outlet></app-toast-outlet>
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { adminGuard } from './core/guards/admin.guard';
|
import { adminGuard } from './core/guards/admin.guard';
|
||||||
import { authGuard } from './core/guards/auth.guard';
|
import { authGuard } from './core/guards/auth.guard';
|
||||||
|
import { integrationsGuard } from './core/guards/integrations.guard';
|
||||||
import { AdminComponent } from './features/admin/admin.component';
|
import { AdminComponent } from './features/admin/admin.component';
|
||||||
import { LoginComponent } from './features/auth/login.component';
|
import { LoginComponent } from './features/auth/login.component';
|
||||||
|
import { BudgetsComponent } from './features/budgets/budgets.component';
|
||||||
|
import { CashflowComponent } from './features/cashflow/cashflow.component';
|
||||||
import { CategoriesComponent } from './features/categories/categories.component';
|
import { CategoriesComponent } from './features/categories/categories.component';
|
||||||
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
||||||
import { ExpensesComponent } from './features/expenses/expenses.component';
|
import { ExpensesComponent } from './features/expenses/expenses.component';
|
||||||
|
import { ExpenseListComponent } from './features/expenses/expense-list.component';
|
||||||
|
import { ExpenseDetailComponent } from './features/expenses/expense-detail.component';
|
||||||
|
import { IntegrationsComponent } from './features/integrations/integrations.component';
|
||||||
import { MerchantsComponent } from './features/merchants/merchants.component';
|
import { MerchantsComponent } from './features/merchants/merchants.component';
|
||||||
|
import { RecurringComponent } from './features/recurring/recurring.component';
|
||||||
import { ReportsComponent } from './features/reports/reports.component';
|
import { ReportsComponent } from './features/reports/reports.component';
|
||||||
import { StatsComponent } from './features/stats/stats.component';
|
import { StatsComponent } from './features/stats/stats.component';
|
||||||
import { ShellComponent } from './layout/shell.component';
|
import { ShellComponent } from './layout/shell.component';
|
||||||
@@ -19,11 +26,23 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: DashboardComponent },
|
{ path: '', component: DashboardComponent },
|
||||||
{ path: 'expenses', component: ExpensesComponent },
|
{
|
||||||
|
path: 'expenses',
|
||||||
|
children: [
|
||||||
|
{ path: '', redirectTo: 'list', pathMatch: 'full' },
|
||||||
|
{ path: 'add', component: ExpensesComponent },
|
||||||
|
{ path: 'list', component: ExpenseListComponent },
|
||||||
|
{ path: ':id', component: ExpenseDetailComponent }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ path: 'stats', component: StatsComponent },
|
{ path: 'stats', component: StatsComponent },
|
||||||
|
{ path: 'cashflow', component: CashflowComponent },
|
||||||
|
{ path: 'budgets', component: BudgetsComponent },
|
||||||
|
{ path: 'recurring', component: RecurringComponent },
|
||||||
{ path: 'merchants', component: MerchantsComponent },
|
{ path: 'merchants', component: MerchantsComponent },
|
||||||
{ path: 'reports', component: ReportsComponent },
|
{ path: 'reports', component: ReportsComponent },
|
||||||
{ path: 'categories', component: CategoriesComponent },
|
{ path: 'categories', component: CategoriesComponent },
|
||||||
|
{ path: 'integrations', component: IntegrationsComponent, canActivate: [integrationsGuard] },
|
||||||
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
|
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-4
@@ -8,10 +8,7 @@ import { ToastOutletComponent } from './shared/ui/toast-outlet.component';
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, ToastOutletComponent],
|
imports: [RouterOutlet, ToastOutletComponent],
|
||||||
template: `
|
templateUrl: './app.html'
|
||||||
<router-outlet></router-outlet>
|
|
||||||
<app-toast-outlet></app-toast-outlet>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
|
import { UiService } from '../services/ui.service';
|
||||||
|
|
||||||
|
export const integrationsGuard: CanActivateFn = () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
const toast = inject(ToastService);
|
||||||
|
const ui = inject(UiService);
|
||||||
|
|
||||||
|
if (auth.currentUser()?.integrationsEnabled) return true;
|
||||||
|
|
||||||
|
toast.warning(ui.t('integrations.disabledForUser'));
|
||||||
|
return router.createUrlTree(['/']);
|
||||||
|
};
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { AppSettings, User } from '../../shared/models';
|
import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
||||||
|
|
||||||
|
export interface AdminUserPayload {
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
role?: 'ADMIN' | 'USER';
|
||||||
|
defaultCurrency?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
integrationsEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@@ -19,11 +29,19 @@ export class AdminService {
|
|||||||
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUser(id: string, payload: Partial<User>) {
|
createUser(payload: AdminUserPayload) {
|
||||||
|
return this.http.post<{ item: User }>(`${environment.apiBaseUrl}/admin/users`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser(id: string, payload: AdminUserPayload) {
|
||||||
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
|
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
testSmtp(to: string) {
|
testSmtp(to: string) {
|
||||||
return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to });
|
return this.http.post<{ message: string; mode?: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to });
|
||||||
|
}
|
||||||
|
|
||||||
|
getSystemInfo() {
|
||||||
|
return this.http.get<{ item: AdminSystemInfo }>(`${environment.apiBaseUrl}/admin/system-info`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { interval, of } from 'rxjs';
|
||||||
|
import { catchError, startWith, switchMap } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export type ApiConnectionState = 'online' | 'offline';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApiStatusService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
readonly state = signal<ApiConnectionState>('online');
|
||||||
|
readonly checkedAt = signal<string | null>(null);
|
||||||
|
readonly label = computed(() => (this.state() === 'online' ? 'API online' : 'API offline'));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
interval(30000)
|
||||||
|
.pipe(
|
||||||
|
startWith(0),
|
||||||
|
switchMap(() =>
|
||||||
|
this.http.get<{ status: string }>(`${environment.apiBaseUrl}/health`).pipe(
|
||||||
|
catchError(() => of({ status: 'offline' }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.state.set(response.status === 'ok' ? 'online' : 'offline');
|
||||||
|
this.checkedAt.set(new Date().toISOString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { AppSettings } from '../../shared/models';
|
import type { AppSettings } from '../../shared/models';
|
||||||
@@ -9,25 +11,86 @@ export interface PublicAppConfig {
|
|||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PUBLIC_CONFIG_STORAGE_KEY = 'expense-control-public-config';
|
||||||
|
const DEFAULT_PUBLIC_CONFIG: PublicAppConfig = {
|
||||||
|
appName: 'Expense Control',
|
||||||
|
registrationEnabled: true
|
||||||
|
};
|
||||||
|
|
||||||
|
type GlobalWithPublicConfig = typeof globalThis & {
|
||||||
|
__EXPENSE_CONTROL_PUBLIC_CONFIG__?: PublicAppConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPublicConfig = (value: unknown): value is PublicAppConfig => {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
return typeof candidate['appName'] === 'string' && typeof candidate['registrationEnabled'] === 'boolean';
|
||||||
|
};
|
||||||
|
|
||||||
|
const readCachedPublicConfig = (): PublicAppConfig | null => {
|
||||||
|
try {
|
||||||
|
const raw = globalThis.localStorage?.getItem(PUBLIC_CONFIG_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return isPublicConfig(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = (() => {
|
||||||
|
const fromBootstrap = (globalThis as GlobalWithPublicConfig).__EXPENSE_CONTROL_PUBLIC_CONFIG__;
|
||||||
|
if (isPublicConfig(fromBootstrap)) {
|
||||||
|
return { config: fromBootstrap, bootstrapped: true };
|
||||||
|
}
|
||||||
|
const cached = readCachedPublicConfig();
|
||||||
|
if (cached) {
|
||||||
|
return { config: cached, bootstrapped: false };
|
||||||
|
}
|
||||||
|
return { config: DEFAULT_PUBLIC_CONFIG, bootstrapped: false };
|
||||||
|
})();
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AppSettingsService {
|
export class AppSettingsService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private loadedPublic = initialState.bootstrapped;
|
||||||
|
|
||||||
readonly publicConfig = signal<PublicAppConfig>({
|
readonly publicConfig = signal<PublicAppConfig>(initialState.config);
|
||||||
appName: 'Expense Control',
|
|
||||||
registrationEnabled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly settings = signal<AppSettings | null>(null);
|
readonly settings = signal<AppSettings | null>(null);
|
||||||
readonly appName = computed(() => this.settings()?.appName || this.publicConfig().appName || 'Expense Control');
|
readonly appName = computed(() => this.settings()?.appName || this.publicConfig().appName || DEFAULT_PUBLIC_CONFIG.appName);
|
||||||
readonly registrationEnabled = computed(
|
readonly registrationEnabled = computed(
|
||||||
() => this.settings()?.registrationEnabled ?? this.publicConfig().registrationEnabled ?? true
|
() => this.settings()?.registrationEnabled ?? this.publicConfig().registrationEnabled ?? DEFAULT_PUBLIC_CONFIG.registrationEnabled
|
||||||
);
|
);
|
||||||
|
|
||||||
loadPublic() {
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const config = {
|
||||||
|
appName: this.appName(),
|
||||||
|
registrationEnabled: this.registrationEnabled()
|
||||||
|
};
|
||||||
|
this.document.title = config.appName;
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.setItem(PUBLIC_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPublic(force = false): Observable<PublicAppConfig> {
|
||||||
|
if (this.loadedPublic && !force) {
|
||||||
|
return of(this.publicConfig());
|
||||||
|
}
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`)
|
.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`)
|
||||||
.pipe(tap((config) => this.publicConfig.set(config)));
|
.pipe(
|
||||||
|
tap((config) => {
|
||||||
|
this.publicConfig.set(config);
|
||||||
|
this.loadedPublic = true;
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
applySettings(item: AppSettings) {
|
applySettings(item: AppSettings) {
|
||||||
@@ -36,5 +99,6 @@ export class AppSettingsService {
|
|||||||
appName: item.appName,
|
appName: item.appName,
|
||||||
registrationEnabled: item.registrationEnabled
|
registrationEnabled: item.registrationEnabled
|
||||||
});
|
});
|
||||||
|
this.loadedPublic = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import type { Budget, BudgetListResponse } from '../../shared/models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BudgetsService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
list(month?: string) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (month) params = params.set('month', month);
|
||||||
|
return this.http.get<BudgetListResponse>(`${environment.apiBaseUrl}/budgets`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(payload: { month: string; name?: string; amount: number; categoryId?: string | null; alertThresholds: number[]; isActive: boolean }) {
|
||||||
|
return this.http.post<{ item: Budget }>(`${environment.apiBaseUrl}/budgets`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, payload: { month: string; name?: string; amount: number; categoryId?: string | null; alertThresholds: number[]; isActive: boolean }) {
|
||||||
|
return this.http.put<{ item: Budget }>(`${environment.apiBaseUrl}/budgets/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string) {
|
||||||
|
return this.http.delete<void>(`${environment.apiBaseUrl}/budgets/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,62 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { Expense, Proof } from '../../shared/models';
|
import type { DuplicateGroup, Expense, ExpenseListResponse, Proof } from '../../shared/models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ExpensesService {
|
export class ExpensesService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string } = {}) { let params = new HttpParams(); Object.entries(filters).forEach(([key, value]) => { if (value) params = params.set(key, value); }); return this.http.get<{ items: Expense[] }>(`${environment.apiBaseUrl}/expenses`, { params }); }
|
|
||||||
create(formData: FormData) { return this.http.post<{ item: Expense }>(`${environment.apiBaseUrl}/expenses`, formData); }
|
list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string; status?: string; tags?: string; duplicatesOnly?: boolean; page?: number; pageSize?: number; sortBy?: string; sortDir?: 'asc' | 'desc' } = {}) {
|
||||||
update(id: string, payload: Partial<Expense> & { categoryId: string }) { return this.http.put<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}`, payload); }
|
let params = new HttpParams();
|
||||||
delete(id: string) { return this.http.delete<void>(`${environment.apiBaseUrl}/expenses/${id}`); }
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
addProof(id: string, formData: FormData) { return this.http.post<{ proof: Proof; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData); }
|
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
||||||
|
});
|
||||||
|
return this.http.get<ExpenseListResponse>(`${environment.apiBaseUrl}/expenses`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getById(id: string) {
|
||||||
|
return this.http.get<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/item/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicates() {
|
||||||
|
return this.http.get<{ items: DuplicateGroup[] }>(`${environment.apiBaseUrl}/expenses/duplicates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(formData: FormData) {
|
||||||
|
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, formData: FormData) {
|
||||||
|
return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(id: string, status: Expense['status']) {
|
||||||
|
return this.http.patch<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/status`, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUpdateStatus(ids: string[], status: Expense['status']) {
|
||||||
|
return this.http.patch<{ items: Expense[]; updated: number }>(`${environment.apiBaseUrl}/expenses/bulk/status`, { ids, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDelete(ids: string[]) {
|
||||||
|
return this.http.post<{ deleted: number }>(`${environment.apiBaseUrl}/expenses/bulk/delete`, { ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewDuplicate(id: string, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
||||||
|
return this.http.post<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/duplicate-review`, { action });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string) {
|
||||||
|
return this.http.delete<void>(`${environment.apiBaseUrl}/expenses/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addProof(id: string, formData: FormData) {
|
||||||
|
return this.http.post<{ proofs: Proof[]; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProof(expenseId: string, proofId: string) {
|
||||||
|
return this.http.delete<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${expenseId}/proofs/${proofId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import type { RecurringExpense } from '../../shared/models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RecurringExpensesService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.http.get<{ items: RecurringExpense[] }>(`${environment.apiBaseUrl}/recurring-expenses`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(payload: Partial<RecurringExpense> & { categoryId: string }) {
|
||||||
|
return this.http.post<{ item: RecurringExpense }>(`${environment.apiBaseUrl}/recurring-expenses`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, payload: Partial<RecurringExpense> & { categoryId: string }) {
|
||||||
|
return this.http.put<{ item: RecurringExpense }>(`${environment.apiBaseUrl}/recurring-expenses/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string) {
|
||||||
|
return this.http.delete<void>(`${environment.apiBaseUrl}/recurring-expenses/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
runNow() {
|
||||||
|
return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/recurring-expenses/run`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
||||||
|
|
||||||
@@ -25,4 +25,12 @@ export class ReportsService {
|
|||||||
send() {
|
send() {
|
||||||
return this.http.post<{ message: string; sentTo: string }>(`${environment.apiBaseUrl}/reports/send`, {});
|
return this.http.post<{ message: string; sentTo: string }>(`${environment.apiBaseUrl}/reports/send`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export(filters: { format: 'csv' | 'json' | 'html' | 'pdf'; startDate?: string; endDate?: string; categoryIds?: string; status?: string; tag?: string }) {
|
||||||
|
let params = new HttpParams().set('format', filters.format);
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (key !== 'format' && value) params = params.set(key, value);
|
||||||
|
});
|
||||||
|
return this.http.get(`${environment.apiBaseUrl}/reports/export`, { params, responseType: 'blob' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import type { Expense, ShoppingListExpenseItem, ShoppingListIntegrationSettings, ShoppingListPeriodImportResponse, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ShoppingListIntegrationService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
getSettings() {
|
||||||
|
return this.http.get<{ item: ShoppingListIntegrationSettings }>(`${environment.apiBaseUrl}/integrations/shopping-list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(payload: { enabled: boolean; baseUrl?: string | null; apiToken?: string; authMode: 'bearer' | 'x-api-token' | 'both'; ownerId?: string | null; defaultListId?: string | null }) {
|
||||||
|
return this.http.put<{ item: ShoppingListIntegrationSettings }>(`${environment.apiBaseUrl}/integrations/shopping-list`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
test() {
|
||||||
|
return this.http.post<{ ok: boolean; payload: unknown }>(`${environment.apiBaseUrl}/integrations/shopping-list/test`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
summary(filters: { start_date?: string; end_date?: string; list_id?: string; owner_id?: string } = {}) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value) params = params.set(key, value);
|
||||||
|
});
|
||||||
|
return this.http.get<ShoppingListSummary>(`${environment.apiBaseUrl}/integrations/shopping-list/summary`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
latest(filters: { start_date?: string; end_date?: string; list_id?: string; owner_id?: string; limit?: number } = {}) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
||||||
|
});
|
||||||
|
return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/latest`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
lists(filters: { owner_id?: string; limit?: number } = {}) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
||||||
|
});
|
||||||
|
return this.http.get<{ items?: ShoppingListRef[]; data?: ShoppingListRef[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
listExpenses(id: string | number, limit = 50) {
|
||||||
|
const params = new HttpParams().set('limit', String(limit));
|
||||||
|
return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists/${id}/expenses`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
importPeriod(payload: {
|
||||||
|
period: string;
|
||||||
|
categoryId: string;
|
||||||
|
status: 'DRAFT' | 'PENDING';
|
||||||
|
merchant?: string | null;
|
||||||
|
}) {
|
||||||
|
return this.http.post<ShoppingListPeriodImportResponse>(`${environment.apiBaseUrl}/integrations/shopping-list/import-period`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
importList(payload: {
|
||||||
|
listId: string | number;
|
||||||
|
listTitle?: string | null;
|
||||||
|
listCreatedAt?: string | null;
|
||||||
|
categoryId: string;
|
||||||
|
status: 'DRAFT' | 'PENDING';
|
||||||
|
merchant?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
expenseDate?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
}) {
|
||||||
|
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/import-list`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
importItem(payload: {
|
||||||
|
expenseId?: string | number | null;
|
||||||
|
listId?: string | number | null;
|
||||||
|
listTitle?: string | null;
|
||||||
|
categoryId: string;
|
||||||
|
status: 'DRAFT' | 'PENDING';
|
||||||
|
title: string;
|
||||||
|
amount: number;
|
||||||
|
expenseDate: string;
|
||||||
|
merchant?: string | null;
|
||||||
|
ownerName?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
}) {
|
||||||
|
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/import-item`, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { StatsResponse } from '../../shared/models';
|
import type { CashflowResponse, StatsResponse } from '../../shared/models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class StatsService {
|
export class StatsService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year' }) { let params = new HttpParams(); Object.entries(filters).forEach(([key, value]) => { if (value) params = params.set(key, value); }); return this.http.get<StatsResponse>(`${environment.apiBaseUrl}/statistics/overview`, { params }); }
|
|
||||||
|
overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year'; tag?: string; status?: string }) {
|
||||||
|
let params = new HttpParams().set('_ts', String(Date.now()));
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value) params = params.set(key, value);
|
||||||
|
});
|
||||||
|
return this.http.get<StatsResponse>(`${environment.apiBaseUrl}/statistics/overview`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
cashflow() {
|
||||||
|
const params = new HttpParams().set('_ts', String(Date.now()));
|
||||||
|
return this.http.get<CashflowResponse>(`${environment.apiBaseUrl}/statistics/cashflow`, { params });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
export type ToastItem = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,6 +10,7 @@ export type ToastItem = {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ToastService {
|
export class ToastService {
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
readonly items = signal<ToastItem[]>([]);
|
readonly items = signal<ToastItem[]>([]);
|
||||||
private counter = 0;
|
private counter = 0;
|
||||||
|
|
||||||
@@ -22,23 +24,29 @@ export class ToastService {
|
|||||||
this.items.update((items) => items.filter((item) => item.id !== id));
|
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);
|
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);
|
this.show(message, 'danger', title);
|
||||||
}
|
}
|
||||||
|
|
||||||
warning(message: string, title = 'Uwaga') {
|
warning(message: string, title = this.ui.t('toast.warning')) {
|
||||||
this.show(message, 'warning', title);
|
this.show(message, 'warning', title);
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, title = 'Informacja') {
|
info(message: string, title = this.ui.t('toast.info')) {
|
||||||
this.show(message, 'info', title);
|
this.show(message, 'info', title);
|
||||||
}
|
}
|
||||||
|
|
||||||
private defaultTitle(tone: ToastItem['tone']) {
|
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.reports': 'Raporty',
|
||||||
'nav.categories': 'Kategorie',
|
'nav.categories': 'Kategorie',
|
||||||
'nav.admin': 'Administracja',
|
'nav.admin': 'Administracja',
|
||||||
|
|
||||||
'action.logout': 'Wyloguj',
|
'action.logout': 'Wyloguj',
|
||||||
'action.addExpense': 'Dodaj wydatek',
|
'action.addExpense': 'Dodaj wydatek',
|
||||||
'action.openReports': 'Raporty',
|
'action.openReports': 'Raporty',
|
||||||
@@ -23,38 +24,460 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.creatingAccount': 'Tworzenie konta...',
|
'action.creatingAccount': 'Tworzenie konta...',
|
||||||
'action.loginMode': 'Logowanie',
|
'action.loginMode': 'Logowanie',
|
||||||
'action.registerMode': 'Rejestracja',
|
'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.view': 'Szczegóły',
|
||||||
|
'action.backToList': 'Wróć do listy',
|
||||||
|
'action.clearSelection': 'Wyczyść zaznaczenie',
|
||||||
|
'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',
|
||||||
|
'action.import': 'Importuj',
|
||||||
|
'action.addUser': 'Dodaj użytkownika',
|
||||||
|
'action.enableIntegrations': 'Włącz integracje',
|
||||||
|
'action.disableIntegrations': 'Wyłącz integracje',
|
||||||
|
|
||||||
'theme.label': 'Motyw',
|
'theme.label': 'Motyw',
|
||||||
'theme.dark': 'Ciemny',
|
'theme.dark': 'Ciemny',
|
||||||
'theme.light': 'Jasny',
|
'theme.light': 'Jasny',
|
||||||
'lang.label': 'Język',
|
'lang.label': 'Język',
|
||||||
'lang.pl': 'Polski',
|
'lang.pl': 'Polski',
|
||||||
'lang.en': 'English',
|
'lang.en': 'English',
|
||||||
|
|
||||||
'login.email': 'E-mail',
|
'login.email': 'E-mail',
|
||||||
'login.password': 'Hasło',
|
'login.password': 'Hasło',
|
||||||
'login.fullName': 'Imię i nazwisko',
|
'login.fullName': 'Imię i nazwisko',
|
||||||
'dashboard.section': 'Panel wydatków',
|
'login.subtitle': 'Zaloguj się',
|
||||||
'dashboard.subtitle': 'Szybki podgląd wydatków, kontrahentów i raportów SMTP.',
|
'register.subtitle': 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.',
|
||||||
|
'login.footer': 'Użyj swojego konta.',
|
||||||
|
'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.total': 'Suma miesiąca',
|
||||||
'dashboard.count': 'Liczba wydatków',
|
'dashboard.count': 'Liczba wydatków',
|
||||||
'dashboard.avg': 'Średnia',
|
'dashboard.avg': 'Średnia',
|
||||||
'dashboard.top': 'Największa kategoria',
|
'dashboard.top': 'Największa kategoria',
|
||||||
'dashboard.share': 'Udział kategorii',
|
'dashboard.share': 'Udział kategorii',
|
||||||
|
'dashboard.shareHint': 'Miesięczny przekrój kosztów według kategorii.',
|
||||||
'dashboard.areas': 'Najmocniejsze obszary kosztów',
|
'dashboard.areas': 'Najmocniejsze obszary kosztów',
|
||||||
|
'dashboard.areasHint': 'Najważniejsze obszary kosztowe w aktualnym okresie.',
|
||||||
'dashboard.recent': 'Ostatnie wydatki',
|
'dashboard.recent': 'Ostatnie wydatki',
|
||||||
|
'dashboard.recentHint': 'Ostatnio dodane pozycje wraz z kontrahentami.',
|
||||||
|
'dashboard.noChartData': 'Brak danych do pokazania wykresu kategorii.',
|
||||||
|
'dashboard.trend': 'Wykres wydatków',
|
||||||
|
'dashboard.noTrendData': 'Brak danych do wykresu wydatków.',
|
||||||
|
'dashboard.range.1m': 'Miesiąc',
|
||||||
|
'dashboard.range.2m': '2 mies.',
|
||||||
|
'dashboard.range.3m': '3 mies.',
|
||||||
|
'dashboard.range.q': 'Kwartał',
|
||||||
|
'dashboard.range.6m': 'Pół roku',
|
||||||
|
'dashboard.range.12m': 'Rok',
|
||||||
|
|
||||||
|
'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',
|
||||||
|
'table.createdAt': 'Utworzono',
|
||||||
|
'table.updatedAt': 'Zmieniono',
|
||||||
|
|
||||||
|
'expenses.title': 'Wydatki',
|
||||||
|
'expenses.subtitle': 'Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.',
|
||||||
|
'expenses.listSubtitle': 'Przeglądaj, filtruj i sortuj zapisane wydatki.',
|
||||||
|
'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.listTitle': 'Lista wydatków',
|
||||||
|
'expenses.detailTitle': 'Szczegóły wydatku',
|
||||||
|
'expenses.meta': 'Metadane',
|
||||||
|
'expenses.noProofs': 'Brak załączników.',
|
||||||
|
'expenses.selectedCount': 'Zaznaczone',
|
||||||
|
'expenses.bulkUpdated': 'Zbiorcza zmiana statusu została zapisana.',
|
||||||
|
'expenses.bulkDeleted': 'Wybrane wydatki zostały usunięte.',
|
||||||
|
'expenses.bulkActionError': 'Nie udało się wykonać operacji zbiorczej.',
|
||||||
|
'expenses.bulkDeleteConfirm': 'Usunąć zaznaczone wydatki?',
|
||||||
|
'expenses.totalItems': 'Łącznie',
|
||||||
|
'expenses.perPage': 'na stronę',
|
||||||
|
'expenses.proof': 'Potwierdzenie',
|
||||||
|
'expenses.saving': 'Zapisywanie...',
|
||||||
|
'expenses.added': 'Wydatek został dodany.',
|
||||||
|
'expenses.saved': 'Wydatek został zapisany.',
|
||||||
|
'expenses.deleted': 'Wydatek został usunięty.',
|
||||||
|
'expenses.statusUpdated': 'Status wydatku został zmieniony.',
|
||||||
|
'expenses.statusUpdateError': 'Nie udało się zmienić statusu wydatku.',
|
||||||
|
'expenses.addError': 'Nie udało się dodać wydatku.',
|
||||||
|
'expenses.saveError': 'Nie udało się zapisać wydatku.',
|
||||||
|
'expenses.loadError': 'Nie udało się pobrać 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ę.',
|
||||||
|
'expenses.existingProofs': 'Obecne załączniki',
|
||||||
|
|
||||||
|
'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.smtpHint': 'Port 587 działa przez STARTTLS, a port 465 przez implicit TLS.',
|
||||||
|
'admin.users': 'Użytkownicy',
|
||||||
|
'admin.newUser': 'Nowy użytkownik',
|
||||||
|
'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.',
|
||||||
|
'admin.integrationsAccess': 'Integracje',
|
||||||
|
'admin.integrationsUpdated': 'Dostęp do integracji został zaktualizowany.',
|
||||||
|
'admin.passwordHint': 'Pozostaw puste, jeśli hasło ma zostać bez zmian.',
|
||||||
|
'admin.userCreated': 'Użytkownik został dodany.',
|
||||||
|
'admin.userCreateError': 'Nie udało się dodać użytkownika.',
|
||||||
|
'admin.userUpdated': 'Dane użytkownika zostały zaktualizowane.',
|
||||||
|
'admin.userUpdateError': 'Nie udało się zaktualizować użytkownika.',
|
||||||
|
|
||||||
|
|
||||||
|
'nav.cashflow': 'Cashflow',
|
||||||
|
'nav.budgets': 'Budżety',
|
||||||
|
'nav.recurring': 'Cykliczne',
|
||||||
|
|
||||||
|
'action.saveDraft': 'Zapisz szkic',
|
||||||
|
|
||||||
|
'status.draft': 'Szkic',
|
||||||
|
'status.pending': 'Oczekuje',
|
||||||
|
'status.approved': 'Zatwierdzony',
|
||||||
|
'status.rejected': 'Odrzucony',
|
||||||
|
|
||||||
|
'dashboard.cashflowHint': 'Przegląd kosztów, budżetów, duplikatów i przyszłych obciążeń.',
|
||||||
|
'dashboard.budgetUsage': 'Wykorzystanie budżetu',
|
||||||
|
|
||||||
|
'expenses.field.status': 'Status',
|
||||||
|
'expenses.field.tags': 'Tagi',
|
||||||
|
'expenses.field.customFields': 'Własne pola',
|
||||||
|
'expenses.field.customKey': 'Nazwa pola',
|
||||||
|
'expenses.field.customValue': 'Wartość',
|
||||||
|
'expenses.tagPlaceholder': 'np. projekt-x, marketing',
|
||||||
|
'expenses.noCustomFields': 'Brak własnych pól.',
|
||||||
|
'expenses.attachmentsSelected': 'Wybrane załączniki',
|
||||||
|
'expenses.duplicatesTitle': 'Wykryte potencjalne duplikaty',
|
||||||
|
'expenses.potentialMatches': 'podobnych pozycji',
|
||||||
|
'expenses.duplicatesOnly': 'Pokaż tylko potencjalne duplikaty',
|
||||||
|
'expenses.duplicate': 'Duplikat',
|
||||||
|
'expenses.draftSaved': 'Szkic wydatku został zapisany.',
|
||||||
|
|
||||||
|
'stats.tags': 'Analiza tagów',
|
||||||
|
|
||||||
|
'reports.exportTitle': 'Eksport raportów',
|
||||||
|
'reports.exportError': 'Nie udało się wyeksportować raportu.',
|
||||||
|
|
||||||
|
'budget.title': 'Budżety miesięczne',
|
||||||
|
'budget.subtitle': 'Limity miesięczne ogólne i per kategoria z alertami zużycia.',
|
||||||
|
'budget.new': 'Nowy budżet',
|
||||||
|
'budget.edit': 'Edytuj budżet',
|
||||||
|
'budget.month': 'Miesiąc',
|
||||||
|
'budget.name': 'Nazwa',
|
||||||
|
'budget.amount': 'Kwota budżetu',
|
||||||
|
'budget.category': 'Kategoria',
|
||||||
|
'budget.overall': 'Budżet ogólny',
|
||||||
|
'budget.thresholds': 'Progi alertów',
|
||||||
|
'budget.total': 'Łączny budżet',
|
||||||
|
'budget.spent': 'Wydano',
|
||||||
|
'budget.usage': 'Zużycie',
|
||||||
|
'budget.alerts': 'Alerty budżetowe',
|
||||||
|
'budget.saved': 'Budżet został zapisany.',
|
||||||
|
'budget.saveError': 'Nie udało się zapisać budżetu.',
|
||||||
|
'budget.deleted': 'Budżet został usunięty.',
|
||||||
|
'budget.deleteError': 'Nie udało się usunąć budżetu.',
|
||||||
|
|
||||||
|
'recurring.title': 'Cykliczne wydatki',
|
||||||
|
'recurring.subtitle': 'Szablony kosztów generowanych automatycznie w czasie.',
|
||||||
|
'recurring.new': 'Nowy harmonogram',
|
||||||
|
'recurring.edit': 'Edytuj harmonogram',
|
||||||
|
'recurring.frequency': 'Częstotliwość',
|
||||||
|
'recurring.weekly': 'Co tydzień',
|
||||||
|
'recurring.monthly': 'Co miesiąc',
|
||||||
|
'recurring.yearly': 'Co rok',
|
||||||
|
'recurring.interval': 'Interwał',
|
||||||
|
'recurring.startDate': 'Data startu',
|
||||||
|
'recurring.nextRunDate': 'Następne utworzenie',
|
||||||
|
'recurring.runNow': 'Uruchom teraz',
|
||||||
|
'recurring.saved': 'Harmonogram został zapisany.',
|
||||||
|
'recurring.saveError': 'Nie udało się zapisać harmonogramu.',
|
||||||
|
'recurring.deleted': 'Harmonogram został usunięty.',
|
||||||
|
'recurring.deleteError': 'Nie udało się usunąć harmonogramu.',
|
||||||
|
'recurring.ran': 'Cykliczne wydatki zostały przetworzone.',
|
||||||
|
'recurring.badge': 'Cykliczny',
|
||||||
|
|
||||||
|
'cashflow.subtitle': 'Rzeczywiste koszty, budżet, prognoza i najbliższe cykliczne obciążenia.',
|
||||||
|
'cashflow.actual': 'Rzeczywiste koszty',
|
||||||
|
'cashflow.budget': 'Budżet',
|
||||||
|
'cashflow.forecast': 'Prognoza miesiąca',
|
||||||
|
'cashflow.pending': 'Do akceptacji',
|
||||||
|
'cashflow.duplicates': 'Duplikaty',
|
||||||
|
'cashflow.trend': 'Trend cashflow',
|
||||||
|
'cashflow.statusSummary': 'Statusy wydatków',
|
||||||
|
'cashflow.upcomingRecurring': 'Nadchodzące cykliczne',
|
||||||
'common.none': 'Brak',
|
'common.none': 'Brak',
|
||||||
|
'common.loading': 'Ładowanie...',
|
||||||
|
'common.select': 'Wybierz',
|
||||||
'common.noData': 'Brak danych.',
|
'common.noData': 'Brak danych.',
|
||||||
'common.noExpenses': 'Brak wydatków.',
|
'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',
|
||||||
|
|
||||||
|
'nav.integrations': 'Integracje',
|
||||||
|
|
||||||
|
'nav.navigation': 'Nawigacja',
|
||||||
|
'nav.toggleMenu': 'Przełącz menu',
|
||||||
|
|
||||||
|
'admin.techTitle': 'Dane techniczne',
|
||||||
|
'admin.techSubtitle': 'Informacje widoczne tylko dla administratora o aplikacji, API i stanie środowiska.',
|
||||||
|
'admin.appVersion': 'Wersja aplikacji',
|
||||||
|
'admin.database': 'Baza danych',
|
||||||
|
'admin.smtpReady': 'SMTP gotowe',
|
||||||
|
'admin.smtpNotReady': 'SMTP niegotowe',
|
||||||
|
'admin.kpi.users': 'Użytkownicy',
|
||||||
|
'admin.kpi.expenses': 'Wydatki',
|
||||||
|
'admin.kpi.categories': 'Kategorie',
|
||||||
|
'admin.kpi.merchants': 'Kontrahenci',
|
||||||
|
'admin.kpi.budgets': 'Budżety',
|
||||||
|
'admin.kpi.recurring': 'Cykliczne',
|
||||||
|
'admin.kpi.integrations': 'Integracje users',
|
||||||
|
|
||||||
|
'integrations.projectLink': 'Repozytorium list zakupowych',
|
||||||
|
'integrations.selfHostedTitle': 'Połączenie self-hosted',
|
||||||
|
'integrations.selfHostedHint': 'Tutaj ustawiasz URL i token do osobnej, samodzielnie hostowanej aplikacji list zakupowych.',
|
||||||
|
'integrations.importExplainTitle': 'Jak działa import',
|
||||||
|
'integrations.importExplainBody': 'Import z list zakupowych zapisuje dane jako zwykły lokalny wydatek w tej aplikacji. Możesz zaimportować całą listę jako 1 wydatek albo pojedyncze pozycje osobno.',
|
||||||
|
'integrations.importExplainBodySimple': 'Masz dwa proste tryby: import całego miesiąca jako jeden wydatek albo import wybranej listy jako jeden wydatek.',
|
||||||
|
|
||||||
|
'footer.apiOnline': 'API online',
|
||||||
|
'footer.apiOffline': 'API offline',
|
||||||
|
'footer.selfHosted': 'Self-hosted expense stack',
|
||||||
|
'footer.source': 'Kod źródłowy expense-control',
|
||||||
|
'footer.shoppingSource': 'Kod źródłowy list zakupowych',
|
||||||
|
|
||||||
|
'action.testConnection': 'Test połączenia',
|
||||||
|
'action.refresh': 'Odśwież',
|
||||||
|
|
||||||
|
'expenses.duplicateDismissed': 'Duplikat został odrzucony.',
|
||||||
|
'expenses.duplicateConfirmed': 'Wydatek został oznaczony jako potwierdzony duplikat.',
|
||||||
|
'expenses.duplicateReopened': 'Sprawdzenie duplikatu zostało przywrócone.',
|
||||||
|
'expenses.duplicateStatus.open': 'Do sprawdzenia',
|
||||||
|
'expenses.duplicateStatus.confirmed': 'Potwierdzony',
|
||||||
|
'expenses.duplicateStatus.dismissed': 'Odrzucony',
|
||||||
|
|
||||||
|
'recurring.endDate': 'Data końcowa',
|
||||||
|
'recurring.maxOccurrences': 'Maks. liczba utworzeń',
|
||||||
|
'recurring.generatedCount': 'Utworzono',
|
||||||
|
|
||||||
|
'integrations.title': 'Integracje',
|
||||||
|
'integrations.subtitle': 'Połączenia per użytkownik z zewnętrznymi źródłami danych oraz import historyczny.',
|
||||||
|
'integrations.shoppingList': 'Lista zakupów API',
|
||||||
|
'integrations.enabled': 'Włącz integrację dla tego użytkownika',
|
||||||
|
'integrations.baseUrl': 'URL API',
|
||||||
|
'integrations.apiToken': 'Token API',
|
||||||
|
'integrations.keepToken': 'Zostaw puste, aby zachować obecny token.',
|
||||||
|
'integrations.authMode': 'Tryb autoryzacji',
|
||||||
|
'integrations.ownerId': 'Domyślny owner ID',
|
||||||
|
'integrations.defaultListId': 'Domyślne list ID',
|
||||||
|
'integrations.history': 'Import historyczny',
|
||||||
|
'integrations.period': 'Miesiąc / rok',
|
||||||
|
'integrations.limit': 'Limit rekordów',
|
||||||
|
'integrations.summary': 'Podsumowanie zewnętrzne',
|
||||||
|
'integrations.latest': 'Wydatki z wybranego okresu',
|
||||||
|
'integrations.lists': 'Listy zakupowe z okresu',
|
||||||
|
'integrations.externalLists': 'Liczba list',
|
||||||
|
'integrations.summaryLists': 'List',
|
||||||
|
'integrations.summarySpend': 'Kwota',
|
||||||
|
'integrations.listExpenses': 'Pozycje wybranej listy',
|
||||||
|
'integrations.importTitle': 'Import do lokalnych wydatków',
|
||||||
|
'integrations.importMonthTitle': 'Import zbiorczy miesiąca',
|
||||||
|
'integrations.importMonthHint': 'Doda jeden wydatek zakupowy dla wybranego miesiąca.',
|
||||||
|
'integrations.importListTitle': 'Import wybranej listy',
|
||||||
|
'integrations.importSelectedList': 'Importuj wybraną listę jako 1 wydatek',
|
||||||
|
'integrations.selectListHint': 'Wybierz listę po lewej, aby podejrzeć pozycje i zaimportować całą listę lub pojedyncze wydatki.',
|
||||||
|
'integrations.selectListHintSimple': 'Wybierz listę po lewej stronie.',
|
||||||
|
'integrations.selectedListSummary': 'Pozycje / suma',
|
||||||
|
'integrations.tags': 'Tagi importu',
|
||||||
|
'integrations.tagsHint': 'Oddzielaj tagi przecinkami.',
|
||||||
|
'integrations.externalSpend': 'Suma zewnętrzna',
|
||||||
|
'integrations.externalCount': 'Rekordy zewnętrzne',
|
||||||
|
'integrations.notConfigured': 'Skonfiguruj integrację i zapisz ustawienia, aby pobrać dane.',
|
||||||
|
'integrations.saveSuccess': 'Ustawienia integracji zostały zapisane.',
|
||||||
|
'integrations.saveError': 'Nie udało się zapisać ustawień integracji.',
|
||||||
|
'integrations.testSuccess': 'Połączenie z zewnętrznym API działa.',
|
||||||
|
'integrations.testError': 'Nie udało się połączyć z zewnętrznym API.',
|
||||||
|
'integrations.loadError': 'Nie udało się pobrać danych integracji.',
|
||||||
|
'integrations.importListSuccess': 'Lista zakupowa została zaimportowana jako lokalny wydatek.',
|
||||||
|
'integrations.importPeriod': 'Importuj miesiąc',
|
||||||
|
'integrations.importPeriodSuccess': 'Miesiąc zakupów został zaimportowany.',
|
||||||
|
'integrations.disabledForUser': 'Integracje są wyłączone dla tego użytkownika.',
|
||||||
|
'integrations.importItemSuccess': 'Pozycja z listy zakupowej została zaimportowana.',
|
||||||
|
'integrations.importError': 'Nie udało się zaimportować danych z list zakupowych.',
|
||||||
|
|
||||||
|
'dashboard.externalSpend': 'Zewnętrzna suma',
|
||||||
|
'dashboard.externalRecords': 'Zewnętrzne rekordy',
|
||||||
|
|
||||||
|
'table.title': 'Tytuł',
|
||||||
|
'table.merchant': 'Kontrahent',
|
||||||
|
'table.date': 'Data',
|
||||||
|
'table.amount': 'Kwota',
|
||||||
|
'table.count': 'Liczba',
|
||||||
|
'table.category': 'Kategoria',
|
||||||
|
'table.actions': 'Akcje',
|
||||||
|
|
||||||
|
'toast.ready': 'Gotowe',
|
||||||
|
'toast.error': 'Błąd',
|
||||||
|
'toast.warning': 'Uwaga',
|
||||||
|
'toast.info': 'Informacja'
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
'app.name': 'Expense Control',
|
'app.name': 'Expense Control',
|
||||||
'nav.dashboard': 'Dashboard',
|
'nav.dashboard': 'Dashboard',
|
||||||
'nav.expenses': 'Expenses',
|
'nav.expenses': 'Expenses',
|
||||||
'nav.stats': 'Statistics',
|
'nav.stats': 'Statistics',
|
||||||
'nav.merchants': 'Partners',
|
'nav.merchants': 'Merchants',
|
||||||
'nav.reports': 'Reports',
|
'nav.reports': 'Reports',
|
||||||
'nav.categories': 'Categories',
|
'nav.categories': 'Categories',
|
||||||
'nav.admin': 'Admin',
|
'nav.admin': 'Administration',
|
||||||
|
|
||||||
'action.logout': 'Sign out',
|
'action.logout': 'Sign out',
|
||||||
'action.addExpense': 'Add expense',
|
'action.addExpense': 'Add expense',
|
||||||
'action.openReports': 'Reports',
|
'action.openReports': 'Reports',
|
||||||
@@ -64,28 +487,447 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.creatingAccount': 'Creating account...',
|
'action.creatingAccount': 'Creating account...',
|
||||||
'action.loginMode': 'Sign in',
|
'action.loginMode': 'Sign in',
|
||||||
'action.registerMode': 'Register',
|
'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.view': 'Details',
|
||||||
|
'action.backToList': 'Back to list',
|
||||||
|
'action.clearSelection': 'Clear selection',
|
||||||
|
'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',
|
||||||
|
'action.import': 'Import',
|
||||||
|
'action.addUser': 'Add user',
|
||||||
|
'action.enableIntegrations': 'Enable integrations',
|
||||||
|
'action.disableIntegrations': 'Disable integrations',
|
||||||
|
|
||||||
'theme.label': 'Theme',
|
'theme.label': 'Theme',
|
||||||
'theme.dark': 'Dark',
|
'theme.dark': 'Dark',
|
||||||
'theme.light': 'Light',
|
'theme.light': 'Light',
|
||||||
'lang.label': 'Language',
|
'lang.label': 'Language',
|
||||||
'lang.pl': 'Polish',
|
'lang.pl': 'Polish',
|
||||||
'lang.en': 'English',
|
'lang.en': 'English',
|
||||||
|
|
||||||
'login.email': 'Email',
|
'login.email': 'Email',
|
||||||
'login.password': 'Password',
|
'login.password': 'Password',
|
||||||
'login.fullName': 'Full name',
|
'login.fullName': 'Full name',
|
||||||
'dashboard.section': 'Expense overview',
|
'login.subtitle': 'Sign in',
|
||||||
'dashboard.subtitle': 'Fast access to expenses, partners and SMTP reports.',
|
'register.subtitle': 'Create an account and start collecting proofs and analytics.',
|
||||||
|
'login.footer': 'Use your account.',
|
||||||
|
'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.total': 'Month total',
|
||||||
'dashboard.count': 'Expense count',
|
'dashboard.count': 'Expense count',
|
||||||
'dashboard.avg': 'Average',
|
'dashboard.avg': 'Average',
|
||||||
'dashboard.top': 'Top category',
|
'dashboard.top': 'Top category',
|
||||||
'dashboard.share': 'Category share',
|
'dashboard.share': 'Category share',
|
||||||
|
'dashboard.shareHint': 'Monthly cost split by category.',
|
||||||
'dashboard.areas': 'Top cost areas',
|
'dashboard.areas': 'Top cost areas',
|
||||||
|
'dashboard.areasHint': 'Most important cost areas in the current period.',
|
||||||
'dashboard.recent': 'Recent expenses',
|
'dashboard.recent': 'Recent expenses',
|
||||||
|
'dashboard.recentHint': 'Most recently added items with merchants.',
|
||||||
|
'dashboard.noChartData': 'No category chart data available.',
|
||||||
|
'dashboard.trend': 'Expense trend',
|
||||||
|
'dashboard.noTrendData': 'No expense trend data available.',
|
||||||
|
'dashboard.range.1m': 'Month',
|
||||||
|
'dashboard.range.2m': '2 mo',
|
||||||
|
'dashboard.range.3m': '3 mo',
|
||||||
|
'dashboard.range.q': 'Quarter',
|
||||||
|
'dashboard.range.6m': 'Half year',
|
||||||
|
'dashboard.range.12m': 'Year',
|
||||||
|
|
||||||
|
'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',
|
||||||
|
'table.createdAt': 'Created',
|
||||||
|
'table.updatedAt': 'Updated',
|
||||||
|
|
||||||
|
'expenses.title': 'Expenses',
|
||||||
|
'expenses.listSubtitle': 'Browse, filter, and sort saved 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.listTitle': 'Expense list',
|
||||||
|
'expenses.detailTitle': 'Expense details',
|
||||||
|
'expenses.meta': 'Metadata',
|
||||||
|
'expenses.noProofs': 'No attachments.',
|
||||||
|
'expenses.selectedCount': 'Selected',
|
||||||
|
'expenses.bulkUpdated': 'Bulk status update saved.',
|
||||||
|
'expenses.bulkDeleted': 'Selected expenses were deleted.',
|
||||||
|
'expenses.bulkActionError': 'Bulk action failed.',
|
||||||
|
'expenses.bulkDeleteConfirm': 'Delete selected expenses?',
|
||||||
|
'expenses.totalItems': 'Total',
|
||||||
|
'expenses.perPage': 'per page',
|
||||||
|
'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.loadError': 'Failed to load 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.',
|
||||||
|
'expenses.existingProofs': 'Current attachments',
|
||||||
|
|
||||||
|
'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.smtpHint': 'Port 587 uses STARTTLS, while port 465 uses implicit TLS.',
|
||||||
|
'admin.users': 'Users',
|
||||||
|
'admin.newUser': 'New user',
|
||||||
|
'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.',
|
||||||
|
'admin.integrationsAccess': 'Integrations',
|
||||||
|
'admin.integrationsUpdated': 'Integrations access has been updated.',
|
||||||
|
'admin.passwordHint': 'Leave empty to keep the current password.',
|
||||||
|
'admin.userCreated': 'User has been created.',
|
||||||
|
'admin.userCreateError': 'Failed to create the user.',
|
||||||
|
'admin.userUpdated': 'User details have been updated.',
|
||||||
|
'admin.userUpdateError': 'Failed to update the user.',
|
||||||
|
|
||||||
|
|
||||||
|
'nav.cashflow': 'Cashflow',
|
||||||
|
'nav.budgets': 'Budgets',
|
||||||
|
'nav.recurring': 'Recurring',
|
||||||
|
|
||||||
|
'action.saveDraft': 'Save draft',
|
||||||
|
|
||||||
|
'status.draft': 'Draft',
|
||||||
|
'status.pending': 'Pending',
|
||||||
|
'status.approved': 'Approved',
|
||||||
|
'status.rejected': 'Rejected',
|
||||||
|
|
||||||
|
'dashboard.cashflowHint': 'Overview of spend, budgets, duplicates, and upcoming recurring charges.',
|
||||||
|
'dashboard.budgetUsage': 'Budget usage',
|
||||||
|
|
||||||
|
'expenses.field.status': 'Status',
|
||||||
|
'expenses.field.tags': 'Tags',
|
||||||
|
'expenses.field.customFields': 'Custom fields',
|
||||||
|
'expenses.field.customKey': 'Field name',
|
||||||
|
'expenses.field.customValue': 'Value',
|
||||||
|
'expenses.tagPlaceholder': 'e.g. project-x, marketing',
|
||||||
|
'expenses.noCustomFields': 'No custom fields.',
|
||||||
|
'expenses.attachmentsSelected': 'Selected attachments',
|
||||||
|
'expenses.duplicatesTitle': 'Potential duplicates detected',
|
||||||
|
'expenses.potentialMatches': 'similar entries',
|
||||||
|
'expenses.duplicatesOnly': 'Show only potential duplicates',
|
||||||
|
'expenses.duplicate': 'Duplicate',
|
||||||
|
'expenses.draftSaved': 'Expense draft was saved.',
|
||||||
|
|
||||||
|
'stats.tags': 'Tag analysis',
|
||||||
|
|
||||||
|
'reports.exportTitle': 'Report export',
|
||||||
|
'reports.exportError': 'Failed to export the report.',
|
||||||
|
|
||||||
|
'budget.title': 'Monthly budgets',
|
||||||
|
'budget.subtitle': 'Monthly limits overall and per category with usage alerts.',
|
||||||
|
'budget.new': 'New budget',
|
||||||
|
'budget.edit': 'Edit budget',
|
||||||
|
'budget.month': 'Month',
|
||||||
|
'budget.name': 'Name',
|
||||||
|
'budget.amount': 'Budget amount',
|
||||||
|
'budget.category': 'Category',
|
||||||
|
'budget.overall': 'Overall budget',
|
||||||
|
'budget.thresholds': 'Alert thresholds',
|
||||||
|
'budget.total': 'Total budget',
|
||||||
|
'budget.spent': 'Spent',
|
||||||
|
'budget.usage': 'Usage',
|
||||||
|
'budget.alerts': 'Budget alerts',
|
||||||
|
'budget.saved': 'Budget was saved.',
|
||||||
|
'budget.saveError': 'Failed to save budget.',
|
||||||
|
'budget.deleted': 'Budget was deleted.',
|
||||||
|
'budget.deleteError': 'Failed to delete budget.',
|
||||||
|
|
||||||
|
'recurring.title': 'Recurring expenses',
|
||||||
|
'recurring.subtitle': 'Templates for costs generated automatically over time.',
|
||||||
|
'recurring.new': 'New schedule',
|
||||||
|
'recurring.edit': 'Edit schedule',
|
||||||
|
'recurring.frequency': 'Frequency',
|
||||||
|
'recurring.weekly': 'Weekly',
|
||||||
|
'recurring.monthly': 'Monthly',
|
||||||
|
'recurring.yearly': 'Yearly',
|
||||||
|
'recurring.interval': 'Interval',
|
||||||
|
'recurring.startDate': 'Start date',
|
||||||
|
'recurring.nextRunDate': 'Next run date',
|
||||||
|
'recurring.runNow': 'Run now',
|
||||||
|
'recurring.saved': 'Recurring schedule was saved.',
|
||||||
|
'recurring.saveError': 'Failed to save recurring schedule.',
|
||||||
|
'recurring.deleted': 'Recurring schedule was deleted.',
|
||||||
|
'recurring.deleteError': 'Failed to delete recurring schedule.',
|
||||||
|
'recurring.ran': 'Recurring expenses were processed.',
|
||||||
|
'recurring.badge': 'Recurring',
|
||||||
|
|
||||||
|
'cashflow.subtitle': 'Actual spend, budget, forecast, and upcoming recurring charges.',
|
||||||
|
'cashflow.actual': 'Actual spend',
|
||||||
|
'cashflow.budget': 'Budget',
|
||||||
|
'cashflow.forecast': 'Month forecast',
|
||||||
|
'cashflow.pending': 'Pending approval',
|
||||||
|
'cashflow.duplicates': 'Duplicates',
|
||||||
|
'cashflow.trend': 'Cashflow trend',
|
||||||
|
'cashflow.statusSummary': 'Expense statuses',
|
||||||
|
'cashflow.upcomingRecurring': 'Upcoming recurring',
|
||||||
'common.none': 'None',
|
'common.none': 'None',
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.select': 'Select',
|
||||||
'common.noData': 'No data.',
|
'common.noData': 'No data.',
|
||||||
'common.noExpenses': 'No expenses.',
|
'common.noExpenses': 'No expenses.',
|
||||||
'common.noCategories': 'No categories.'
|
'common.noCategories': 'No categories.',
|
||||||
|
'common.active': 'Active',
|
||||||
|
'common.hidden': 'Hidden',
|
||||||
|
'common.blocked': 'Blocked',
|
||||||
|
'common.selected': 'OK',
|
||||||
|
|
||||||
|
'nav.integrations': 'Integrations',
|
||||||
|
|
||||||
|
'nav.navigation': 'Navigation',
|
||||||
|
'nav.toggleMenu': 'Toggle menu',
|
||||||
|
|
||||||
|
'admin.techTitle': 'Technical details',
|
||||||
|
'admin.techSubtitle': 'Admin-only information about the app, API and runtime environment.',
|
||||||
|
'admin.appVersion': 'App version',
|
||||||
|
'admin.database': 'Database',
|
||||||
|
'admin.smtpReady': 'SMTP ready',
|
||||||
|
'admin.smtpNotReady': 'SMTP not ready',
|
||||||
|
'admin.kpi.users': 'Users',
|
||||||
|
'admin.kpi.expenses': 'Expenses',
|
||||||
|
'admin.kpi.categories': 'Categories',
|
||||||
|
'admin.kpi.merchants': 'Merchants',
|
||||||
|
'admin.kpi.budgets': 'Budgets',
|
||||||
|
'admin.kpi.recurring': 'Recurring',
|
||||||
|
'admin.kpi.integrations': 'User integrations',
|
||||||
|
|
||||||
|
'integrations.projectLink': 'Shopping list repository',
|
||||||
|
'integrations.selfHostedTitle': 'Self-hosted connection',
|
||||||
|
'integrations.selfHostedHint': 'Set the URL and token for the separate self-hosted shopping list application here.',
|
||||||
|
'integrations.importExplainTitle': 'How import works',
|
||||||
|
'integrations.importExplainBody': 'Importing from shopping lists creates a normal local expense in this app. You can import the whole list as one expense or import single entries separately.',
|
||||||
|
'integrations.importExplainBodySimple': 'You have two simple modes: import the whole month as one expense or import one selected list as one expense.',
|
||||||
|
|
||||||
|
'footer.apiOnline': 'API online',
|
||||||
|
'footer.apiOffline': 'API offline',
|
||||||
|
'footer.selfHosted': 'Self-hosted expense stack',
|
||||||
|
'footer.source': 'expense-control source',
|
||||||
|
'footer.shoppingSource': 'shopping-list source',
|
||||||
|
|
||||||
|
'action.testConnection': 'Test connection',
|
||||||
|
'action.refresh': 'Refresh',
|
||||||
|
|
||||||
|
'expenses.duplicateDismissed': 'Duplicate flag was dismissed.',
|
||||||
|
'expenses.duplicateConfirmed': 'Expense was marked as a confirmed duplicate.',
|
||||||
|
'expenses.duplicateReopened': 'Duplicate review was reopened.',
|
||||||
|
'expenses.duplicateStatus.open': 'Needs review',
|
||||||
|
'expenses.duplicateStatus.confirmed': 'Confirmed',
|
||||||
|
'expenses.duplicateStatus.dismissed': 'Dismissed',
|
||||||
|
|
||||||
|
'recurring.endDate': 'End date',
|
||||||
|
'recurring.maxOccurrences': 'Max occurrences',
|
||||||
|
'recurring.generatedCount': 'Generated',
|
||||||
|
|
||||||
|
'integrations.title': 'Integrations',
|
||||||
|
'integrations.subtitle': 'Per-user connections to external data sources with historical backfill.',
|
||||||
|
'integrations.shoppingList': 'Shopping list API',
|
||||||
|
'integrations.enabled': 'Enable integration for this user',
|
||||||
|
'integrations.baseUrl': 'API URL',
|
||||||
|
'integrations.apiToken': 'API token',
|
||||||
|
'integrations.keepToken': 'Leave blank to keep the current token.',
|
||||||
|
'integrations.authMode': 'Authorization mode',
|
||||||
|
'integrations.ownerId': 'Default owner ID',
|
||||||
|
'integrations.defaultListId': 'Default list ID',
|
||||||
|
'integrations.history': 'Historical import',
|
||||||
|
'integrations.period': 'Month / year',
|
||||||
|
'integrations.limit': 'Record limit',
|
||||||
|
'integrations.summary': 'External summary',
|
||||||
|
'integrations.latest': 'Expenses for selected period',
|
||||||
|
'integrations.lists': 'Shopping lists for period',
|
||||||
|
'integrations.externalLists': 'Lists',
|
||||||
|
'integrations.summaryLists': 'Lists',
|
||||||
|
'integrations.summarySpend': 'Amount',
|
||||||
|
'integrations.listExpenses': 'Entries for selected list',
|
||||||
|
'integrations.importTitle': 'Import into local expenses',
|
||||||
|
'integrations.importMonthTitle': 'Import whole month',
|
||||||
|
'integrations.importMonthHint': 'Adds one shopping expense for the selected month.',
|
||||||
|
'integrations.importListTitle': 'Import selected list',
|
||||||
|
'integrations.importSelectedList': 'Import selected list as 1 expense',
|
||||||
|
'integrations.selectListHint': 'Select a list on the left to preview entries and import the whole list or individual expenses.',
|
||||||
|
'integrations.selectListHintSimple': 'Select a list on the left.',
|
||||||
|
'integrations.selectedListSummary': 'Entries / total',
|
||||||
|
'integrations.tags': 'Import tags',
|
||||||
|
'integrations.tagsHint': 'Separate tags with commas.',
|
||||||
|
'integrations.externalSpend': 'External spend',
|
||||||
|
'integrations.externalCount': 'External records',
|
||||||
|
'integrations.notConfigured': 'Configure the integration and save settings to load data.',
|
||||||
|
'integrations.saveSuccess': 'Integration settings were saved.',
|
||||||
|
'integrations.saveError': 'Failed to save integration settings.',
|
||||||
|
'integrations.testSuccess': 'Connection to the external API works.',
|
||||||
|
'integrations.testError': 'Failed to connect to the external API.',
|
||||||
|
'integrations.loadError': 'Failed to load integration data.',
|
||||||
|
'integrations.importListSuccess': 'The shopping list was imported as a local expense.',
|
||||||
|
'integrations.importPeriod': 'Import month',
|
||||||
|
'integrations.importPeriodSuccess': 'Shopping month imported successfully.',
|
||||||
|
'integrations.disabledForUser': 'Integrations are disabled for this user.',
|
||||||
|
'integrations.importItemSuccess': 'The shopping list entry was imported.',
|
||||||
|
'integrations.importError': 'Failed to import data from the shopping list API.',
|
||||||
|
|
||||||
|
'dashboard.externalSpend': 'External spend',
|
||||||
|
'dashboard.externalRecords': 'External records',
|
||||||
|
|
||||||
|
'table.title': 'Title',
|
||||||
|
'table.merchant': 'Merchant',
|
||||||
|
'table.date': 'Date',
|
||||||
|
'table.amount': 'Amount',
|
||||||
|
'table.count': 'Count',
|
||||||
|
'table.category': 'Category',
|
||||||
|
'table.actions': 'Actions',
|
||||||
|
|
||||||
|
'toast.ready': 'Done',
|
||||||
|
'toast.error': 'Error',
|
||||||
|
'toast.warning': 'Warning',
|
||||||
|
'toast.info': 'Information'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,8 +965,14 @@ export class UiService {
|
|||||||
this.language.set(language);
|
this.language.set(language);
|
||||||
}
|
}
|
||||||
|
|
||||||
t(key: string) {
|
t(key: string, params?: Record<string, string | number>) {
|
||||||
return translations[this.language()][key] ?? translations.pl[key] ?? key;
|
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 {
|
private readTheme(): UiTheme {
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<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">{{ ui.t('admin.title') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('admin.subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (systemInfo()) {
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card pv-card overflow-hidden ec-accent-card ec-accent-card-info">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title mb-1">{{ ui.t('admin.techTitle') }}</h3>
|
||||||
|
<div class="text-secondary small">{{ ui.t('admin.techSubtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-info">{{ systemInfo()!.environment }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('admin.appVersion') }}</div><div class="ec-stat-value">{{ systemInfo()!.suiteVersion }}</div></div></div>
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">API</div><div class="ec-stat-value">{{ systemInfo()!.apiVersion }}</div></div></div>
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Web</div><div class="ec-stat-value">{{ systemInfo()!.webVersion }}</div></div></div>
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Node.js</div><div class="ec-stat-value">{{ systemInfo()!.nodeVersion }}</div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-vcenter mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="text-secondary">{{ ui.t('admin.database') }}</td><td class="fw-semibold">{{ systemInfo()!.database }}</td></tr>
|
||||||
|
<tr><td class="text-secondary">Upload dir</td><td class="fw-semibold text-break">{{ systemInfo()!.uploadDir }}</td></tr>
|
||||||
|
<tr><td class="text-secondary">{{ ui.t('admin.registration') }}</td><td><span class="badge" [class.bg-success]="systemInfo()!.registrationEnabled" [class.bg-secondary]="!systemInfo()!.registrationEnabled">{{ systemInfo()!.registrationEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td></tr>
|
||||||
|
<tr><td class="text-secondary">SMTP</td><td><span class="badge" [class.bg-success]="systemInfo()!.smtpConfigured" [class.bg-warning]="!systemInfo()!.smtpConfigured">{{ systemInfo()!.smtpConfigured ? ui.t('admin.smtpReady') : ui.t('admin.smtpNotReady') }}</span></td></tr>
|
||||||
|
<tr><td class="text-secondary">API base</td><td class="fw-semibold">{{ systemInfo()!.sources.apiBasePath }}</td></tr>
|
||||||
|
<tr><td class="text-secondary">{{ ui.t('table.date') }}</td><td class="fw-semibold">{{ systemInfo()!.checkedAt | date:'yyyy-MM-dd HH:mm:ss' }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.users') }}</span><strong>{{ systemInfo()!.counters.users }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.expenses') }}</span><strong>{{ systemInfo()!.counters.expenses }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.categories') }}</span><strong>{{ systemInfo()!.counters.categories }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.merchants') }}</span><strong>{{ systemInfo()!.counters.merchants }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.budgets') }}</span><strong>{{ systemInfo()!.counters.budgets }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.recurring') }}</span><strong>{{ systemInfo()!.counters.recurring }}</strong></div></div>
|
||||||
|
<div class="col-12"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.integrations') }}</span><strong>{{ systemInfo()!.counters.shoppingIntegrations }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 d-flex gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="https://git.linuxiarz.pl/gru/expense-control" target="_blank" rel="noreferrer">{{ ui.t('footer.source') }}</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('footer.shoppingSource') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<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">{{ 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">{{ 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">{{ 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">{{ ui.t('admin.registration') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<hr class="my-2" />
|
||||||
|
<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">{{ ui.t('admin.smtpEnabled') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<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">{{ ui.t('admin.secureConnection') }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="small text-secondary">{{ ui.t('admin.smtpHint') }}</div>
|
||||||
|
|
||||||
|
<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>{{ ui.t('action.save') }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-info" type="button" (click)="sendTest()">{{ ui.t('action.testSmtp') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">{{ 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>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.integrationsAccess') }}</th><th>{{ ui.t('admin.date') }}</th><th class="w-1"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (user of users(); track user.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ user.fullName }}</div>
|
||||||
|
<div class="small text-secondary">{{ user.email }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ user.role }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" [class.bg-success]="user.isActive" [class.bg-secondary]="!user.isActive">
|
||||||
|
{{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge" [class.bg-success]="user.integrationsEnabled" [class.bg-secondary]="!user.integrationsEnabled">{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td>
|
||||||
|
<td>{{ user.createdAt | date:'short' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-list flex-wrap justify-content-end">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="startEdit(user)">{{ ui.t('action.edit') }}</button>
|
||||||
|
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
|
||||||
|
{{ 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 ? ui.t('action.block') : ui.t('action.unblock') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm" [class.btn-outline-primary]="!user.integrationsEnabled" [class.btn-primary]="user.integrationsEnabled" type="button" (click)="toggleIntegrations(user)">
|
||||||
|
{{ user.integrationsEnabled ? ui.t('action.disableIntegrations') : ui.t('action.enableIntegrations') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (editingUserId() === user.id) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="bg-body-secondary">
|
||||||
|
<form [formGroup]="editUserForm" (ngSubmit)="saveUser()" class="row g-3 p-2">
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
|
||||||
|
<div class="col-12 small text-secondary">{{ ui.t('admin.passwordHint') }}</div>
|
||||||
|
<div class="col-12 d-flex gap-3 flex-wrap">
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-primary btn-sm" [disabled]="editUserForm.invalid || userSaving()">{{ ui.t('action.saveChanges') }}</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="6" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card pv-card overflow-hidden mt-3">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('admin.newUser') }}</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form [formGroup]="createUserForm" (ngSubmit)="createUser()" class="row g-3">
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></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-12 d-flex gap-3 flex-wrap">
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button class="btn btn-primary" [disabled]="createUserForm.invalid || userSaving()">{{ ui.t('action.addUser') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,123 +4,17 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|||||||
import { AdminService } from '../../core/services/admin.service';
|
import { AdminService } from '../../core/services/admin.service';
|
||||||
import { AppSettingsService } from '../../core/services/app-settings.service';
|
import { AppSettingsService } from '../../core/services/app-settings.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import type { AppSettings, User } from '../../shared/models';
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, DatePipe],
|
imports: [CommonModule, ReactiveFormsModule, DatePipe],
|
||||||
template: `
|
templateUrl: './admin.component.html'
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-body">
|
|
||||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Nazwa aplikacji</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>
|
|
||||||
|
|
||||||
<label class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" formControlName="registrationEnabled" />
|
|
||||||
<span class="form-check-label">Włącz rejestrację</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<hr class="my-2" />
|
|
||||||
<div class="fw-semibold">SMTP</div>
|
|
||||||
|
|
||||||
<label class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" formControlName="smtpEnabled" />
|
|
||||||
<span class="form-check-label">Włącz SMTP</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>
|
|
||||||
|
|
||||||
<label class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" formControlName="smtpSecure" />
|
|
||||||
<span class="form-check-label">Bezpieczne połączenie</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>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-info" type="button" (click)="sendTest()">Test SMTP</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<tbody>
|
|
||||||
@for (user of users(); track user.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-semibold">{{ user.fullName }}</div>
|
|
||||||
<div class="small text-secondary">{{ user.email }}</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ user.role }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" [class.bg-success]="user.isActive" [class.bg-secondary]="!user.isActive">
|
|
||||||
{{ user.isActive ? 'Aktywny' : 'Zablokowany' }}
|
|
||||||
</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' }}
|
|
||||||
</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' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="5" class="text-secondary">Brak użytkowników.</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class AdminComponent implements OnInit {
|
export class AdminComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly admin = inject(AdminService);
|
private readonly admin = inject(AdminService);
|
||||||
private readonly appSettings = inject(AppSettingsService);
|
private readonly appSettings = inject(AppSettingsService);
|
||||||
@@ -128,7 +22,10 @@ export class AdminComponent implements OnInit {
|
|||||||
|
|
||||||
readonly users = signal<User[]>([]);
|
readonly users = signal<User[]>([]);
|
||||||
readonly settings = signal<AppSettings | null>(null);
|
readonly settings = signal<AppSettings | null>(null);
|
||||||
|
readonly systemInfo = signal<AdminSystemInfo | null>(null);
|
||||||
readonly saving = signal(false);
|
readonly saving = signal(false);
|
||||||
|
readonly userSaving = signal(false);
|
||||||
|
readonly editingUserId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group({
|
readonly form = this.fb.nonNullable.group({
|
||||||
appName: ['', [Validators.required, Validators.minLength(2)]],
|
appName: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
@@ -145,6 +42,26 @@ export class AdminComponent implements OnInit {
|
|||||||
smtpFromEmail: ['']
|
smtpFromEmail: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly createUserForm = this.fb.nonNullable.group({
|
||||||
|
fullName: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||||
|
role: ['USER' as 'ADMIN' | 'USER', Validators.required],
|
||||||
|
defaultCurrency: ['PLN', [Validators.required, Validators.minLength(3)]],
|
||||||
|
isActive: [true],
|
||||||
|
integrationsEnabled: [false]
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly editUserForm = this.fb.nonNullable.group({
|
||||||
|
fullName: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
password: [''],
|
||||||
|
role: ['USER' as 'ADMIN' | 'USER', Validators.required],
|
||||||
|
defaultCurrency: ['PLN', [Validators.required, Validators.minLength(3)]],
|
||||||
|
isActive: [true],
|
||||||
|
integrationsEnabled: [false]
|
||||||
|
});
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
@@ -171,15 +88,23 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.loadUsers();
|
||||||
|
this.refreshSystemInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUsers() {
|
||||||
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
|
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshSystemInfo() {
|
||||||
|
this.admin.getSystemInfo().subscribe({ next: (response) => this.systemInfo.set(response.item) });
|
||||||
|
}
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
const raw = this.form.getRawValue();
|
const raw = this.form.getRawValue();
|
||||||
this.admin
|
this.admin.updateSettings({
|
||||||
.updateSettings({
|
|
||||||
appName: raw.appName,
|
appName: raw.appName,
|
||||||
defaultCurrency: raw.defaultCurrency,
|
defaultCurrency: raw.defaultCurrency,
|
||||||
registrationEnabled: raw.registrationEnabled,
|
registrationEnabled: raw.registrationEnabled,
|
||||||
@@ -193,17 +118,17 @@ export class AdminComponent implements OnInit {
|
|||||||
smtpPassword: raw.smtpPassword || null,
|
smtpPassword: raw.smtpPassword || null,
|
||||||
smtpFromName: raw.smtpFromName || null,
|
smtpFromName: raw.smtpFromName || null,
|
||||||
smtpFromEmail: raw.smtpFromEmail || null
|
smtpFromEmail: raw.smtpFromEmail || null
|
||||||
})
|
}).subscribe({
|
||||||
.subscribe({
|
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.settings.set(response.item);
|
this.settings.set(response.item);
|
||||||
this.appSettings.applySettings(response.item);
|
this.appSettings.applySettings(response.item);
|
||||||
this.toast.success('Ustawienia zapisane.');
|
this.toast.success(this.ui.t('admin.settingsSaved'));
|
||||||
|
this.refreshSystemInfo();
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.saving.set(false);
|
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,32 +136,135 @@ export class AdminComponent implements OnInit {
|
|||||||
sendTest() {
|
sendTest() {
|
||||||
const to = this.form.getRawValue().smtpFromEmail;
|
const to = this.form.getRawValue().smtpFromEmail;
|
||||||
if (!to) {
|
if (!to) {
|
||||||
this.toast.error('Uzupełnij e-mail nadawcy.');
|
this.toast.error(this.ui.t('admin.missingFromEmail'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.admin.testSmtp(to).subscribe({
|
this.admin.testSmtp(to).subscribe({
|
||||||
next: () => this.toast.success('Wiadomość testowa została wysłana.'),
|
next: (response) => this.toast.success(response.mode ? `${this.ui.t('admin.testSent')} (${response.mode})` : this.ui.t('admin.testSent')),
|
||||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się wysłać testu SMTP.')
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.testError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createUser() {
|
||||||
|
if (this.createUserForm.invalid) return;
|
||||||
|
this.userSaving.set(true);
|
||||||
|
const raw = this.createUserForm.getRawValue();
|
||||||
|
this.admin.createUser({
|
||||||
|
fullName: raw.fullName,
|
||||||
|
email: raw.email,
|
||||||
|
password: raw.password,
|
||||||
|
role: raw.role,
|
||||||
|
defaultCurrency: raw.defaultCurrency,
|
||||||
|
isActive: raw.isActive,
|
||||||
|
integrationsEnabled: raw.integrationsEnabled
|
||||||
|
}).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.userSaving.set(false);
|
||||||
|
this.users.update((items) => [response.item, ...items]);
|
||||||
|
this.createUserForm.reset({
|
||||||
|
fullName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'USER',
|
||||||
|
defaultCurrency: this.form.getRawValue().defaultCurrency || 'PLN',
|
||||||
|
isActive: true,
|
||||||
|
integrationsEnabled: false
|
||||||
|
});
|
||||||
|
this.refreshSystemInfo();
|
||||||
|
this.toast.success(this.ui.t('admin.userCreated'));
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.userSaving.set(false);
|
||||||
|
this.toast.error(error.error?.message ?? this.ui.t('admin.userCreateError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startEdit(user: User) {
|
||||||
|
this.editingUserId.set(user.id);
|
||||||
|
this.editUserForm.reset({
|
||||||
|
fullName: user.fullName,
|
||||||
|
email: user.email,
|
||||||
|
password: '',
|
||||||
|
role: user.role,
|
||||||
|
defaultCurrency: user.defaultCurrency,
|
||||||
|
isActive: user.isActive,
|
||||||
|
integrationsEnabled: Boolean(user.integrationsEnabled)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEdit() {
|
||||||
|
this.editingUserId.set(null);
|
||||||
|
this.editUserForm.reset({
|
||||||
|
fullName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'USER',
|
||||||
|
defaultCurrency: 'PLN',
|
||||||
|
isActive: true,
|
||||||
|
integrationsEnabled: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveUser() {
|
||||||
|
const userId = this.editingUserId();
|
||||||
|
if (!userId || this.editUserForm.invalid) return;
|
||||||
|
this.userSaving.set(true);
|
||||||
|
const raw = this.editUserForm.getRawValue();
|
||||||
|
this.admin.updateUser(userId, {
|
||||||
|
fullName: raw.fullName,
|
||||||
|
email: raw.email,
|
||||||
|
password: raw.password.trim() || undefined,
|
||||||
|
role: raw.role,
|
||||||
|
defaultCurrency: raw.defaultCurrency,
|
||||||
|
isActive: raw.isActive,
|
||||||
|
integrationsEnabled: raw.integrationsEnabled
|
||||||
|
}).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.userSaving.set(false);
|
||||||
|
this.replaceUser(response.item);
|
||||||
|
this.cancelEdit();
|
||||||
|
this.toast.success(this.ui.t('admin.userUpdated'));
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.userSaving.set(false);
|
||||||
|
this.toast.error(error.error?.message ?? this.ui.t('admin.userUpdateError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceUser(user: User) {
|
||||||
|
this.users.update((items) => items.map((item) => (item.id === user.id ? user : item)));
|
||||||
|
}
|
||||||
|
|
||||||
toggleRole(user: User) {
|
toggleRole(user: User) {
|
||||||
this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({
|
this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
|
this.replaceUser(response.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'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleActive(user: User) {
|
toggleActive(user: User) {
|
||||||
this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({
|
this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
|
this.replaceUser(response.item);
|
||||||
this.toast.success('Status konta został zaktualizowany.');
|
this.refreshSystemInfo();
|
||||||
|
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'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleIntegrations(user: User) {
|
||||||
|
this.admin.updateUser(user.id, { integrationsEnabled: !user.integrationsEnabled }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.replaceUser(response.item);
|
||||||
|
this.toast.success(this.ui.t('admin.integrationsUpdated'));
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<div class="page page-center login-page-shell">
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="row justify-content-center align-items-stretch g-4 login-layout">
|
||||||
|
<div class="col-12 col-md-10 col-lg-7 col-xl-5">
|
||||||
|
<div class="card card-md login-card login-card-enhanced">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-1">{{ appSettings.appName() }}</h1>
|
||||||
|
<div class="text-secondary">{{ mode() === 'login' ? loginSubtitle() : registerSubtitle() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-icon btn-ghost-secondary"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="ui.t('theme.label')"
|
||||||
|
[attr.title]="ui.t('theme.label')"
|
||||||
|
(click)="ui.toggleTheme()">
|
||||||
|
@if (ui.theme() === 'dark') {
|
||||||
|
<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 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
|
||||||
|
} @else {
|
||||||
|
<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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-icon btn-ghost-secondary"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="currentLanguageLabel()"
|
||||||
|
[attr.title]="currentLanguageLabel()"
|
||||||
|
(click)="toggleLanguage()">
|
||||||
|
<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="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="submit()" class="login-input-stack">
|
||||||
|
@if (mode() === 'register') {
|
||||||
|
<div>
|
||||||
|
<label class="form-label">{{ ui.t('login.fullName') }}</label>
|
||||||
|
<input class="form-control form-control-lg" formControlName="fullName" autocomplete="name" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">{{ ui.t('login.email') }}</label>
|
||||||
|
<input class="form-control form-control-lg" formControlName="email" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">{{ ui.t('login.password') }}</label>
|
||||||
|
<input class="form-control form-control-lg" type="password" formControlName="password" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<div class="alert alert-danger py-2 mb-0">{{ errorMessage() }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-lg w-100 login-submit-button" [disabled]="form.invalid || loading()">
|
||||||
|
<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="M15 15l6 6"/><path d="M4 11a7 7 0 1 1 14 0a7 7 0 0 1 -14 0"/></svg>
|
||||||
|
{{ loading() ? (mode() === 'login' ? ui.t('action.loggingIn') : ui.t('action.creatingAccount')) : (mode() === 'login' ? ui.t('action.login') : ui.t('action.createAccount')) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer-note">
|
||||||
|
{{ mode() === 'login' ? ui.t('login.footer') : ui.t('register.footer') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (appSettings.registrationEnabled()) {
|
||||||
|
<div class="login-footer-note d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<span>{{ mode() === 'login' ? switchToRegisterLabel() : switchToLoginLabel() }}</span>
|
||||||
|
<button class="btn btn-ghost-primary btn-sm" type="button" (click)="switchMode()">
|
||||||
|
{{ mode() === 'login' ? ui.t('action.registerMode') : ui.t('action.loginMode') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -11,91 +11,7 @@ import { UiService } from '../../core/services/ui.service';
|
|||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
template: `
|
templateUrl: './login.component.html'
|
||||||
<div class="page page-center login-page-shell">
|
|
||||||
<div class="container py-4">
|
|
||||||
<div class="row justify-content-center align-items-stretch g-4 login-layout">
|
|
||||||
<div class="col-12 col-md-10 col-lg-7 col-xl-5">
|
|
||||||
<div class="card card-md login-card login-card-enhanced">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-4 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<h1 class="h2 mb-1">{{ appSettings.appName() }}</h1>
|
|
||||||
<div class="text-secondary">{{ mode() === 'login' ? loginSubtitle() : registerSubtitle() }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-icon btn-ghost-secondary"
|
|
||||||
type="button"
|
|
||||||
[attr.aria-label]="ui.t('theme.label')"
|
|
||||||
[attr.title]="ui.t('theme.label')"
|
|
||||||
(click)="ui.toggleTheme()">
|
|
||||||
@if (ui.theme() === 'dark') {
|
|
||||||
<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 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
|
|
||||||
} @else {
|
|
||||||
<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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="btn btn-icon btn-ghost-secondary"
|
|
||||||
type="button"
|
|
||||||
[attr.aria-label]="currentLanguageLabel()"
|
|
||||||
[attr.title]="currentLanguageLabel()"
|
|
||||||
(click)="toggleLanguage()">
|
|
||||||
<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="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="login-input-stack">
|
|
||||||
@if (mode() === 'register') {
|
|
||||||
<div>
|
|
||||||
<label class="form-label">{{ ui.t('login.fullName') }}</label>
|
|
||||||
<input class="form-control form-control-lg" formControlName="fullName" autocomplete="name" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="form-label">{{ ui.t('login.email') }}</label>
|
|
||||||
<input class="form-control form-control-lg" formControlName="email" autocomplete="username" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="form-label">{{ ui.t('login.password') }}</label>
|
|
||||||
<input class="form-control form-control-lg" type="password" formControlName="password" autocomplete="current-password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (errorMessage()) {
|
|
||||||
<div class="alert alert-danger py-2 mb-0">{{ errorMessage() }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button class="btn btn-primary btn-lg w-100 login-submit-button" [disabled]="form.invalid || loading()">
|
|
||||||
<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="M15 15l6 6"/><path d="M4 11a7 7 0 1 1 14 0a7 7 0 0 1 -14 0"/></svg>
|
|
||||||
{{ loading() ? (mode() === 'login' ? ui.t('action.loggingIn') : ui.t('action.creatingAccount')) : (mode() === 'login' ? ui.t('action.login') : ui.t('action.createAccount')) }}
|
|
||||||
</button>
|
|
||||||
</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.') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (appSettings.registrationEnabled()) {
|
|
||||||
<div class="login-footer-note d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
|
||||||
<span>{{ mode() === 'login' ? switchToRegisterLabel() : switchToLoginLabel() }}</span>
|
|
||||||
<button class="btn btn-ghost-primary btn-sm" type="button" (click)="switchMode()">
|
|
||||||
{{ mode() === 'login' ? ui.t('action.registerMode') : ui.t('action.loginMode') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
@@ -116,9 +32,7 @@ export class LoginComponent {
|
|||||||
fullName: ['']
|
fullName: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {}
|
||||||
this.appSettings.loadPublic().subscribe({ error: () => undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
@@ -133,7 +47,7 @@ export class LoginComponent {
|
|||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
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.loading.set(false);
|
||||||
this.errorMessage.set(message);
|
this.errorMessage.set(message);
|
||||||
this.toast.error(message);
|
this.toast.error(message);
|
||||||
@@ -146,11 +60,11 @@ export class LoginComponent {
|
|||||||
next: () => {
|
next: () => {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.errorMessage.set(null);
|
this.errorMessage.set(null);
|
||||||
this.toast.success('Konto zostało utworzone.');
|
this.toast.success(this.ui.t('register.success'));
|
||||||
this.mode.set('login');
|
this.mode.set('login');
|
||||||
},
|
},
|
||||||
error: (error) => {
|
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.loading.set(false);
|
||||||
this.errorMessage.set(message);
|
this.errorMessage.set(message);
|
||||||
this.toast.error(message);
|
this.toast.error(message);
|
||||||
@@ -168,22 +82,18 @@ export class LoginComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loginSubtitle() {
|
loginSubtitle() {
|
||||||
return this.ui.language() === 'pl'
|
return this.ui.t('login.subtitle');
|
||||||
? 'Zaloguj się, aby zarządzać wydatkami, kontrahentami i raportami.'
|
|
||||||
: 'Sign in to manage expenses, merchants and reports.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerSubtitle() {
|
registerSubtitle() {
|
||||||
return this.ui.language() === 'pl'
|
return this.ui.t('register.subtitle');
|
||||||
? 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.'
|
|
||||||
: 'Create an account and start collecting proofs and analytics.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToRegisterLabel() {
|
switchToRegisterLabel() {
|
||||||
return this.ui.language() === 'pl' ? 'Nie masz konta?' : 'Need an account?';
|
return this.ui.t('login.needAccount');
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToLoginLabel() {
|
switchToLoginLabel() {
|
||||||
return this.ui.language() === 'pl' ? 'Masz już konto?' : 'Already registered?';
|
return this.ui.t('login.haveAccount');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<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">{{ ui.t('nav.budgets') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('budget.subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="card-title">{{ editingId() ? ui.t('budget.edit') : ui.t('budget.new') }}</h3>
|
||||||
|
@if (editingId()) {
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||||
|
<div><label class="form-label">{{ ui.t('budget.month') }}</label><input class="form-control" type="month" formControlName="month" /></div>
|
||||||
|
<div><label class="form-label">{{ ui.t('budget.name') }}</label><input class="form-control" formControlName="name" /></div>
|
||||||
|
<div><label class="form-label">{{ ui.t('budget.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">{{ ui.t('budget.category') }}</label>
|
||||||
|
<select class="form-select" formControlName="categoryId">
|
||||||
|
<option value="">{{ ui.t('budget.overall') }}</option>
|
||||||
|
@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label class="form-label">{{ ui.t('budget.thresholds') }}</label><input class="form-control" formControlName="thresholdsText" /></div>
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
|
||||||
|
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.total') }}</div><div class="display-6">{{ summary()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-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('budget.spent') }}</div><div class="display-6">{{ summary()?.totalSpent || 0 | currency:'PLN':'symbol':'1.2-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('budget.alerts') }}</div><div class="display-6">{{ summary()?.alerts?.length || 0 }}</div></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (summary()?.alerts?.length) {
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<div class="fw-semibold mb-2">{{ ui.t('budget.alerts') }}</div>
|
||||||
|
<div class="d-grid gap-1">@for (alert of summary()!.alerts; track alert.budgetId) { <div>{{ alert.message }}</div> }</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="card-title">{{ ui.t('budget.title') }}</h3>
|
||||||
|
<input class="form-control form-control-sm" style="max-width: 10rem" type="month" [value]="selectedMonth()" (change)="changeMonth($any($event.target).value)" />
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<thead><tr><th>{{ ui.t('budget.name') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('budget.usage') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of items(); track item.id) {
|
||||||
|
<tr>
|
||||||
|
<td><div class="fw-semibold">{{ item.name || item.category?.name || ui.t('budget.overall') }}</div><div class="progress progress-sm mt-2"><div class="progress-bar" [style.width.%]="item.usagePercent > 100 ? 100 : item.usagePercent"></div></div></td>
|
||||||
|
<td>{{ item.category?.name || ui.t('budget.overall') }}</td>
|
||||||
|
<td><span class="badge" [ngClass]="item.alertLevel ? 'text-bg-warning' : 'text-bg-success'">{{ item.usagePercent }}%</span></td>
|
||||||
|
<td class="text-end"><div>{{ item.spent | currency:'PLN':'symbol':'1.2-2' }}</div><div class="text-secondary small">/ {{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></td>
|
||||||
|
<td class="text-end"><div class="btn-list justify-content-end flex-nowrap"><button class="btn btn-sm btn-outline-primary" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button><button class="btn btn-sm btn-outline-danger" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button></div></td>
|
||||||
|
</tr>
|
||||||
|
} @empty { <tr><td colspan="5" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { BudgetsService } from '../../core/services/budgets.service';
|
||||||
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { Budget } from '../../shared/models';
|
||||||
|
|
||||||
|
const currentMonth = () => {
|
||||||
|
const date = new Date();
|
||||||
|
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-budgets',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe],
|
||||||
|
templateUrl: './budgets.component.html'
|
||||||
|
})
|
||||||
|
export class BudgetsComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
|
private readonly budgetsService = inject(BudgetsService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly categories = this.categoriesService.items;
|
||||||
|
readonly items = signal<Budget[]>([]);
|
||||||
|
readonly summary = signal<{ totalBudget: number; totalSpent: number; alerts: Array<{ budgetId: string; message: string; usagePercent: number; level: number }> } | null>(null);
|
||||||
|
readonly selectedMonth = signal(currentMonth());
|
||||||
|
readonly editingId = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly form = this.fb.nonNullable.group({ month: [currentMonth(), Validators.required], name: [''], amount: [0, [Validators.required, Validators.min(0.01)]], categoryId: [''], thresholdsText: ['80,100'], isActive: [true] });
|
||||||
|
|
||||||
|
ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); }
|
||||||
|
|
||||||
|
changeMonth(month: string) { this.selectedMonth.set(month || currentMonth()); this.load(); }
|
||||||
|
|
||||||
|
load() { this.budgetsService.list(this.selectedMonth()).subscribe({ next: (response) => { this.items.set(response.items); this.summary.set(response.summary); } }); }
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.form.invalid) return;
|
||||||
|
const raw = this.form.getRawValue();
|
||||||
|
const payload = { month: raw.month, name: raw.name || undefined, amount: raw.amount, categoryId: raw.categoryId || null, alertThresholds: raw.thresholdsText.split(',').map((item) => Number(item.trim())).filter((item) => Number.isFinite(item) && item > 0), isActive: raw.isActive };
|
||||||
|
const request = this.editingId() ? this.budgetsService.update(this.editingId()!, payload) : this.budgetsService.create(payload);
|
||||||
|
request.subscribe({ next: () => { this.toast.success(this.ui.t('budget.saved')); this.cancelEdit(); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('budget.saveError')) });
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(item: Budget) { this.editingId.set(item.id); this.form.reset({ month: item.month, name: item.name || '', amount: item.amount, categoryId: item.category?.id || '', thresholdsText: (item.alertThresholds || [80, 100]).join(','), isActive: item.isActive }); }
|
||||||
|
cancelEdit() { this.editingId.set(null); this.form.reset({ month: this.selectedMonth(), name: '', amount: 0, categoryId: '', thresholdsText: '80,100', isActive: true }); }
|
||||||
|
remove(item: Budget) { this.budgetsService.delete(item.id).subscribe({ next: () => { this.toast.success(this.ui.t('budget.deleted')); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('budget.deleteError')) }); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<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">{{ ui.t('nav.cashflow') }}</h2><div class="text-secondary">{{ ui.t('cashflow.subtitle') }}</div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ (data()?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ (data()?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (data()?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 d-flex align-items-stretch">
|
||||||
|
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.trend') }}</h3></div>
|
||||||
|
<div class="card-body"><div class="ec-chart-wrap"><canvas id="cashflowTrendChart"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 d-flex align-items-stretch">
|
||||||
|
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('budget.alerts') }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-2">
|
||||||
|
@for (alert of data()?.alerts || []; track alert.id) {
|
||||||
|
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.statusSummary') }}</h3></div>
|
||||||
|
<div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead><tbody>@for (item of data()?.statusSummary || []; track item.status) { <tr><td>{{ ui.t('status.' + item.status.toLowerCase()) }}</td><td class="text-end">{{ item.count }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</h3></div>
|
||||||
|
<div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (item of data()?.upcomingRecurring || []; track item.id) { <tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
|
||||||
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { CashflowResponse } from '../../shared/models';
|
||||||
|
|
||||||
|
Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-cashflow',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, CurrencyPipe, DatePipe],
|
||||||
|
templateUrl: './cashflow.component.html'
|
||||||
|
})
|
||||||
|
export class CashflowComponent implements OnInit, OnDestroy {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly statsService = inject(StatsService);
|
||||||
|
readonly data = signal<CashflowResponse | null>(null);
|
||||||
|
private chart?: Chart;
|
||||||
|
ngOnInit() {
|
||||||
|
this.statsService.cashflow().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.data.set(response);
|
||||||
|
requestAnimationFrame(() => this.renderChart());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() { this.chart?.destroy(); }
|
||||||
|
|
||||||
|
private renderChart() {
|
||||||
|
const canvas = document.getElementById('cashflowTrendChart') as HTMLCanvasElement | null;
|
||||||
|
const data = this.data();
|
||||||
|
if (!canvas || !data?.trend?.length) {
|
||||||
|
this.chart?.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chart?.destroy();
|
||||||
|
this.chart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.trend.map((item) => item.label),
|
||||||
|
datasets: [
|
||||||
|
{ label: this.ui.t('cashflow.actual'), data: data.trend.map((item) => item.actual), borderColor: '#206bc4', backgroundColor: '#206bc4', tension: 0.25 },
|
||||||
|
{ label: this.ui.t('cashflow.budget'), data: data.trend.map((item) => item.budget), borderColor: '#2fb344', backgroundColor: '#2fb344', tension: 0.25 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#9ca3af' } } }, scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { color: '#9ca3af' } } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<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">{{ ui.t('categories.title') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('categories.subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ? 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">{{ ui.t('categories.name') }}</label>
|
||||||
|
<input class="form-control" formControlName="name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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" />
|
||||||
|
<input class="form-control form-control-color" type="color" formControlName="color" style="max-width: 4.25rem;" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
@for (preset of presets; track preset) {
|
||||||
|
<button class="btn btn-sm btn-outline-primary p-1" type="button" (click)="pickColor(preset)">
|
||||||
|
<span class="ec-color-swatch" [style.background]="preset"></span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ? ui.t('action.save') : ui.t('action.addCategory') }}</span>
|
||||||
|
</button>
|
||||||
|
@if (editingId) {
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="resetForm()">{{ ui.t('action.cancel') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card pv-card overflow-hidden">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<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>{{ 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-danger btn-sm" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noCategories') }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,6 +3,7 @@ import { Component, OnInit, inject } from '@angular/core';
|
|||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { CategoriesService } from '../../core/services/categories.service';
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
import type { Category } from '../../shared/models';
|
import type { Category } from '../../shared/models';
|
||||||
|
|
||||||
const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b', '#475569'];
|
const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b', '#475569'];
|
||||||
@@ -11,103 +12,21 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
|
|||||||
selector: 'app-categories',
|
selector: 'app-categories',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
template: `
|
templateUrl: './categories.component.html'
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-body">
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="d-grid gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Nazwa</label>
|
|
||||||
<input class="form-control" formControlName="name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Kolor</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" />
|
|
||||||
<input class="form-control form-control-color" type="color" formControlName="color" style="max-width: 4.25rem;" />
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
|
||||||
@for (preset of presets; track preset) {
|
|
||||||
<button class="btn btn-sm btn-outline-primary p-1" type="button" (click)="pickColor(preset)">
|
|
||||||
<span class="ec-color-swatch" [style.background]="preset"></span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
@if (editingId) {
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="reset()">Anuluj</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<div class="btn-list flex-nowrap">
|
|
||||||
@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>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="3" class="text-secondary">Brak kategorii.</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class CategoriesComponent implements OnInit {
|
export class CategoriesComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
readonly presets = presets;
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly categories = inject(CategoriesService);
|
private readonly categories = inject(CategoriesService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
readonly items = this.categories.items;
|
readonly items = this.categories.items;
|
||||||
readonly presets = presets;
|
|
||||||
editingId: string | null = null;
|
editingId: string | null = null;
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group({
|
readonly form = this.fb.nonNullable.group({
|
||||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
color: ['#2563eb', Validators.required]
|
color: [presets[0], Validators.required]
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -118,6 +37,16 @@ export class CategoriesComponent implements OnInit {
|
|||||||
this.form.patchValue({ color });
|
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() {
|
submit() {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
const payload = this.form.getRawValue();
|
const payload = this.form.getRawValue();
|
||||||
@@ -125,27 +54,17 @@ export class CategoriesComponent implements OnInit {
|
|||||||
|
|
||||||
request.subscribe({
|
request.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toast.success(this.editingId ? 'Kategoria została zapisana.' : 'Kategoria została dodana.');
|
this.toast.success(this.editingId ? this.ui.t('categories.saved') : this.ui.t('categories.added'));
|
||||||
this.reset();
|
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) {
|
remove(item: Category) {
|
||||||
this.categories.delete(item.id).subscribe({
|
this.categories.delete(item.id).subscribe({
|
||||||
next: () => this.toast.success('Kategoria została usunięta.'),
|
next: () => this.toast.success(this.ui.t('categories.deleted')),
|
||||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kategorii.')
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('categories.deleteError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<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">{{ ui.t('nav.dashboard') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('dashboard.cashflowHint') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.total') }}</div><div class="display-6">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
|
<div class="col-md-3"><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-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.budgetUsage') }}</div><div class="display-6">{{ cashflow?.budgetUsagePercent || 0 }}%</div></div></div></div>
|
||||||
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
|
|
||||||
|
@if (canShowExternalStats()) {
|
||||||
|
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
|
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="col-12 d-flex align-items-stretch">
|
||||||
|
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="card-title mb-0">{{ ui.t('dashboard.trend') }}</h3>
|
||||||
|
<div class="btn-list">
|
||||||
|
@for (option of timelineRangeOptions; track option.value) {
|
||||||
|
<button class="btn btn-sm"
|
||||||
|
type="button"
|
||||||
|
[class.btn-primary]="selectedTimelineRange === option.value"
|
||||||
|
[class.btn-outline-secondary]="selectedTimelineRange !== option.value"
|
||||||
|
(click)="changeTimelineRange(option.value)">
|
||||||
|
{{ ui.t(option.labelKey) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (timelineStats?.timeline?.length) {
|
||||||
|
<div class="ec-chart-wrap"><canvas id="dashboardTimelineChart"></canvas></div>
|
||||||
|
} @else {
|
||||||
|
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noTrendData') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-7 d-flex align-items-stretch">
|
||||||
|
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.share') }}</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (stats?.byCategory?.length) {
|
||||||
|
<div class="ec-chart-wrap"><canvas id="dashboardCategoryChart"></canvas></div>
|
||||||
|
} @else {
|
||||||
|
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noChartData') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-5 d-flex align-items-stretch">
|
||||||
|
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('nav.cashflow') }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.actual') }}</span><strong>{{ (cashflow?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
|
||||||
|
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.budget') }}</span><strong>{{ (cashflow?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
|
||||||
|
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.pending') }}</span><strong>{{ cashflow?.pendingApproval || 0 }}</strong></div>
|
||||||
|
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.duplicates') }}</span><strong>{{ cashflow?.duplicateCount || 0 }}</strong></div>
|
||||||
|
<div>
|
||||||
|
<div class="form-label mb-2">{{ ui.t('budget.alerts') }}</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@for (alert of cashflow?.alerts || []; track alert.id) {
|
||||||
|
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of recentExpenses; track item.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ item.title }}</div>
|
||||||
|
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.category.name }}</td>
|
||||||
|
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
|
||||||
|
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noExpenses') }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</h3></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of cashflow?.upcomingRecurring || []; track item.id) {
|
||||||
|
<tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,209 +1,277 @@
|
|||||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { AfterViewChecked, Component, OnDestroy, OnInit, effect, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { Chart, ArcElement, CategoryScale, DoughnutController, Legend, LineController, LineElement, LinearScale, PointElement, Tooltip } from 'chart.js';
|
||||||
import { Chart, DoughnutController, ArcElement, Tooltip, Legend } from 'chart.js';
|
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import { UiService } from '../../core/services/ui.service';
|
|
||||||
import { ExpensesService } from '../../core/services/expenses.service';
|
import { ExpensesService } from '../../core/services/expenses.service';
|
||||||
|
import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service';
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import type { Expense, StatsResponse } from '../../shared/models';
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { CashflowResponse, Expense, ShoppingListSummary, StatsResponse } from '../../shared/models';
|
||||||
|
|
||||||
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
Chart.register(DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale);
|
||||||
|
|
||||||
|
const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v7';
|
||||||
|
const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c'];
|
||||||
|
type TimelineRangeKey = '1m' | '2m' | '3m' | 'q' | '6m' | '12m';
|
||||||
|
|
||||||
|
type TimelineRangeOption = {
|
||||||
|
value: TimelineRangeKey;
|
||||||
|
labelKey: 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 startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
|
||||||
|
const addMonths = (date: Date, amount: number) => {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setMonth(next.getMonth() + amount, 1);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMonthRange = () => {
|
||||||
|
const today = new Date();
|
||||||
|
return {
|
||||||
|
start: formatLocalDate(startOfMonth(today)),
|
||||||
|
end: formatLocalDate(today)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimelineRange = (range: TimelineRangeKey) => {
|
||||||
|
const today = new Date();
|
||||||
|
const monthsBack = ({ '1m': 0, '2m': 1, '3m': 2, q: 2, '6m': 5, '12m': 11 } as Record<TimelineRangeKey, number>)[range] ?? 0;
|
||||||
|
return {
|
||||||
|
start: formatLocalDate(startOfMonth(addMonths(today, -monthsBack))),
|
||||||
|
end: formatLocalDate(today)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, CurrencyPipe, DatePipe, RouterLink],
|
imports: [CommonModule, CurrencyPipe, DatePipe],
|
||||||
template: `
|
templateUrl: './dashboard.component.html'
|
||||||
<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">{{ 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">
|
|
||||||
<a class="btn btn-success d-inline-flex align-items-center gap-2" routerLink="/expenses">
|
|
||||||
<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>{{ ui.t('action.addExpense') }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row row-cards g-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card pv-card pv-hero-card overflow-hidden">
|
|
||||||
<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>
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
|
||||||
<div class="text-secondary small">{{ ui.t('dashboard.count') }}</div>
|
|
||||||
<div class="h2 mb-0">{{ stats?.count || 0 }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
|
||||||
<div class="text-secondary small">{{ ui.t('dashboard.top') }}</div>
|
|
||||||
<div class="h3 mb-0">{{ stats?.topCategory?.categoryName || ui.t('common.none') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-5 d-flex align-items-stretch">
|
|
||||||
<div class="card pv-card w-100 overflow-hidden">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
@if (hasCategoryData()) {
|
|
||||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
|
||||||
<canvas id="dashboardCategoryChart"></canvas>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="alert alert-info mb-0">Brak danych do pokazania wykresu kategorii.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-7 d-flex align-items-stretch">
|
|
||||||
<div class="card pv-card w-100 overflow-hidden">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of stats?.byCategory || []; track row.categoryId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.categoryName }}</td>
|
|
||||||
<td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td>
|
|
||||||
<td class="text-end">{{ row.count }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card pv-card overflow-hidden">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (item of recentExpenses; track item.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ item.title }}</td>
|
|
||||||
<td>{{ item.merchant || ui.t('common.none') }}</td>
|
|
||||||
<td>{{ item.expenseDate | date:'shortDate' }}</td>
|
|
||||||
<td class="text-end">{{ item.amount | currency:item.currency:'symbol':'1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="alert alert-warning mb-0">{{ ui.t('common.noExpenses') }}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class DashboardComponent implements OnInit {
|
export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||||
readonly auth = inject(AuthService);
|
|
||||||
readonly ui = inject(UiService);
|
readonly ui = inject(UiService);
|
||||||
|
readonly auth = inject(AuthService);
|
||||||
private readonly expensesService = inject(ExpensesService);
|
private readonly expensesService = inject(ExpensesService);
|
||||||
private readonly statsService = inject(StatsService);
|
private readonly statsService = inject(StatsService);
|
||||||
|
private readonly shoppingIntegration = inject(ShoppingListIntegrationService);
|
||||||
|
readonly timelineRangeOptions: TimelineRangeOption[] = [
|
||||||
|
{ value: '1m', labelKey: 'dashboard.range.1m' },
|
||||||
|
{ value: '2m', labelKey: 'dashboard.range.2m' },
|
||||||
|
{ value: '3m', labelKey: 'dashboard.range.3m' },
|
||||||
|
{ value: 'q', labelKey: 'dashboard.range.q' },
|
||||||
|
{ value: '6m', labelKey: 'dashboard.range.6m' },
|
||||||
|
{ value: '12m', labelKey: 'dashboard.range.12m' }
|
||||||
|
];
|
||||||
|
|
||||||
recentExpenses: Expense[] = [];
|
recentExpenses: Expense[] = [];
|
||||||
stats: StatsResponse | null = null;
|
stats: StatsResponse | null = null;
|
||||||
|
timelineStats: StatsResponse | null = null;
|
||||||
|
cashflow: CashflowResponse | null = null;
|
||||||
|
externalSummary: ShoppingListSummary | null = null;
|
||||||
|
selectedTimelineRange: TimelineRangeKey = '3m';
|
||||||
private categoryChart?: Chart;
|
private categoryChart?: Chart;
|
||||||
|
private timelineChart?: Chart;
|
||||||
|
private categoryChartPending = false;
|
||||||
|
private timelineChartPending = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.canShowExternalStats()) this.loadExternalSummary();
|
||||||
|
else {
|
||||||
|
this.externalSummary = null;
|
||||||
|
this.persistCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const now = new Date();
|
this.restoreCache();
|
||||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
|
this.loadDashboard();
|
||||||
const end = now.toISOString().slice(0, 10);
|
this.loadTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
this.expensesService.list({ startDate: start, endDate: end }).subscribe({
|
ngAfterViewChecked() {
|
||||||
next: (response) => (this.recentExpenses = response.items.slice(0, 8))
|
if (this.categoryChartPending) {
|
||||||
|
this.categoryChartPending = false;
|
||||||
|
this.renderCategoryChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.timelineChartPending) {
|
||||||
|
this.timelineChartPending = false;
|
||||||
|
this.renderTimelineChart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.categoryChart?.destroy();
|
||||||
|
this.timelineChart?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
canShowExternalStats() {
|
||||||
|
return Boolean(this.auth.currentUser()?.integrationsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
this.statsService.overview({ startDate: start, endDate: end, bucket: 'month' }).subscribe({
|
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.stats = response;
|
this.stats = response;
|
||||||
setTimeout(() => this.renderChart(), 0);
|
this.categoryChartPending = true;
|
||||||
|
this.persistCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.statsService.cashflow().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.cashflow = response;
|
||||||
|
this.persistCache();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
hasCategoryData() {
|
changeTimelineRange(range: TimelineRangeKey) {
|
||||||
return Boolean(this.stats?.byCategory?.length);
|
if (this.selectedTimelineRange === range) return;
|
||||||
|
this.selectedTimelineRange = range;
|
||||||
|
this.loadTimeline();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderChart() {
|
loadTimeline() {
|
||||||
|
const range = getTimelineRange(this.selectedTimelineRange);
|
||||||
|
this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.timelineStats = response;
|
||||||
|
this.timelineChartPending = true;
|
||||||
|
this.persistCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadExternalSummary() {
|
||||||
|
const range = getMonthRange();
|
||||||
|
this.shoppingIntegration.summary({ start_date: range.start, end_date: range.end }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.externalSummary = response;
|
||||||
|
this.persistCache();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.externalSummary = this.externalSummary ?? null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
externalAmount() { return Number(this.externalSummary?.total ?? this.externalSummary?.amount ?? this.externalSummary?.meta?.total_amount ?? 0); }
|
||||||
|
externalCount() { return Number(this.externalSummary?.count ?? this.externalSummary?.records ?? this.externalSummary?.meta?.total_count ?? 0); }
|
||||||
|
|
||||||
|
statusBadgeClass(status: string) {
|
||||||
|
return {
|
||||||
|
DRAFT: 'text-bg-secondary',
|
||||||
|
PENDING: 'text-bg-warning',
|
||||||
|
APPROVED: 'text-bg-success',
|
||||||
|
REJECTED: 'text-bg-danger'
|
||||||
|
}[status] || 'text-bg-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCategoryChart() {
|
||||||
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
|
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
|
||||||
if (!canvas || !this.stats?.byCategory.length) {
|
if (!canvas || !this.stats?.byCategory?.length) {
|
||||||
this.categoryChart?.destroy();
|
this.categoryChart?.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colors = this.stats.byCategory.map((_, index) => chartPalette[index % chartPalette.length]);
|
||||||
this.categoryChart?.destroy();
|
this.categoryChart?.destroy();
|
||||||
this.categoryChart = new Chart(canvas, {
|
this.categoryChart = new Chart(canvas, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: this.stats.byCategory.map((item) => item.categoryName),
|
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: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
cutout: '66%',
|
cutout: '66%',
|
||||||
plugins: { legend: { position: 'bottom' } }
|
plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 10, color: '#9ca3af' } } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderTimelineChart() {
|
||||||
|
const canvas = document.getElementById('dashboardTimelineChart') as HTMLCanvasElement | null;
|
||||||
|
if (!canvas || !this.timelineStats?.timeline?.length) {
|
||||||
|
this.timelineChart?.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timelineChart?.destroy();
|
||||||
|
this.timelineChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: this.timelineStats.timeline.map((item) => item.label),
|
||||||
|
datasets: [{
|
||||||
|
label: this.ui.t('stats.expensesLabel'),
|
||||||
|
data: this.timelineStats.timeline.map((item) => item.total),
|
||||||
|
borderColor: '#206bc4',
|
||||||
|
backgroundColor: '#206bc4',
|
||||||
|
tension: 0.25,
|
||||||
|
fill: false
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { 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;
|
||||||
|
timelineStats?: StatsResponse | null;
|
||||||
|
cashflow?: CashflowResponse | null;
|
||||||
|
externalSummary?: ShoppingListSummary | null;
|
||||||
|
selectedTimelineRange?: TimelineRangeKey;
|
||||||
|
};
|
||||||
|
this.recentExpenses = parsed.recentExpenses ?? [];
|
||||||
|
this.stats = parsed.stats ?? null;
|
||||||
|
this.timelineStats = parsed.timelineStats ?? null;
|
||||||
|
this.cashflow = parsed.cashflow ?? null;
|
||||||
|
this.externalSummary = parsed.externalSummary ?? null;
|
||||||
|
this.selectedTimelineRange = parsed.selectedTimelineRange ?? '3m';
|
||||||
|
this.categoryChartPending = Boolean(this.stats?.byCategory?.length);
|
||||||
|
this.timelineChartPending = Boolean(this.timelineStats?.timeline?.length);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistCache() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({
|
||||||
|
recentExpenses: this.recentExpenses,
|
||||||
|
stats: this.stats,
|
||||||
|
timelineStats: this.timelineStats,
|
||||||
|
cashflow: this.cashflow,
|
||||||
|
externalSummary: this.externalSummary,
|
||||||
|
selectedTimelineRange: this.selectedTimelineRange
|
||||||
|
}));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<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">{{ ui.t('expenses.detailTitle') }}</h2>
|
||||||
|
<div class="text-secondary">{{ expense()?.title || ui.t('expenses.title') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto">
|
||||||
|
<div class="btn-list">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="goBack()">{{ ui.t('action.backToList') }}</button>
|
||||||
|
@if (expense()) {
|
||||||
|
<button class="btn btn-primary" type="button" (click)="editExpense()">{{ ui.t('action.edit') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loadError()) {
|
||||||
|
<div class="alert alert-danger">{{ loadError() }}</div>
|
||||||
|
} @else if (loading()) {
|
||||||
|
<div class="card"><div class="card-body text-secondary">{{ ui.t('common.loading') }}</div></div>
|
||||||
|
} @else if (expense(); as item) {
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-12 col-xl-8">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title mb-0">{{ item.title }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.date') }}</div><div class="fw-semibold">{{ item.expenseDate | date:'yyyy-MM-dd' }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.amount') }}</div><div class="fw-semibold">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.status') }}</div><div><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.category') }}</div><div class="fw-semibold">{{ item.category.name }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.payment') }}</div><div>{{ paymentLabel(item.paymentMethod) }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.merchantName') }}</div><div>{{ item.merchant || ui.t('expenses.noMerchant') }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (item.description) {
|
||||||
|
<div>
|
||||||
|
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.description') }}</div>
|
||||||
|
<div>{{ item.description }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.tags.length) {
|
||||||
|
<div>
|
||||||
|
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.tags') }}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (customFieldEntries(item).length) {
|
||||||
|
<div>
|
||||||
|
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.customFields') }}</div>
|
||||||
|
<div class="row g-2">@for (field of customFieldEntries(item); track field[0]) { <div class="col-sm-6"><div class="border rounded-3 p-2 h-100"><div class="text-secondary small">{{ field[0] }}</div><div class="fw-semibold">{{ field[1] }}</div></div></div> }</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-4">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.existingProofs') }}</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (item.proofs.length) {
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@for (proof of item.proofs; track proof.id) {
|
||||||
|
<button class="btn btn-outline-secondary text-start" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="text-secondary">{{ ui.t('expenses.noProofs') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden mt-3">
|
||||||
|
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.meta') }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-2 small">
|
||||||
|
<div><span class="text-secondary">ID:</span> {{ item.id }}</div>
|
||||||
|
<div><span class="text-secondary">{{ ui.t('table.createdAt') || 'Utworzono' }}:</span> {{ item.createdAt | date:'yyyy-MM-dd HH:mm' }}</div>
|
||||||
|
<div><span class="text-secondary">{{ ui.t('table.updatedAt') || 'Zmieniono' }}:</span> {{ item.updatedAt | date:'yyyy-MM-dd HH:mm' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (proofPreview()) {
|
||||||
|
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
||||||
|
<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 || ui.t('expenses.proof') }}</h5>
|
||||||
|
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body ec-proof-modal-body">
|
||||||
|
@if (isPdf(proofPreview()!)) {
|
||||||
|
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
|
||||||
|
} @else {
|
||||||
|
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { ExpensesService } from '../../core/services/expenses.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { Expense, Proof } from '../../shared/models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, CurrencyPipe, DatePipe],
|
||||||
|
templateUrl: './expense-detail.component.html'
|
||||||
|
})
|
||||||
|
export class ExpenseDetailComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly location = inject(Location);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly expensesService = inject(ExpensesService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly expense = signal<Expense | null>(null);
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly loadError = signal('');
|
||||||
|
readonly proofPreview = signal<Proof | null>(null);
|
||||||
|
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
|
const proof = this.proofPreview();
|
||||||
|
if (!proof || !this.isPdf(proof)) return null;
|
||||||
|
const previewUrl = proof.previewUrl || proof.fileUrl;
|
||||||
|
if (!previewUrl) return null;
|
||||||
|
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.route.paramMap.subscribe((params) => {
|
||||||
|
const id = params.get('id');
|
||||||
|
if (!id) return;
|
||||||
|
this.loading.set(true);
|
||||||
|
this.loadError.set('');
|
||||||
|
this.expense.set(null);
|
||||||
|
this.expensesService.getById(id).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.expense.set(response.item);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
const message = error.error?.message ?? this.ui.t('expenses.loadError');
|
||||||
|
this.loadError.set(message);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.toast.error(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
|
||||||
|
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
||||||
|
closeProofPreview() { this.proofPreview.set(null); }
|
||||||
|
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
|
||||||
|
goBack() { this.location.back(); }
|
||||||
|
editExpense() {
|
||||||
|
const item = this.expense();
|
||||||
|
if (!item) return;
|
||||||
|
this.router.navigate(['/expenses/add'], { queryParams: { edit: item.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentLabel(value: Expense['paymentMethod']) {
|
||||||
|
if (!value) return this.ui.t('expenses.payment.none');
|
||||||
|
return ({
|
||||||
|
CARD: this.ui.t('expenses.payment.card'),
|
||||||
|
CASH: this.ui.t('expenses.payment.cash'),
|
||||||
|
TRANSFER: this.ui.t('expenses.payment.transfer'),
|
||||||
|
BLIK: 'BLIK',
|
||||||
|
OTHER: this.ui.t('expenses.payment.other')
|
||||||
|
} as Record<string, string>)[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusBadgeClass(status: string) {
|
||||||
|
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<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">{{ ui.t('expenses.title') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('expenses.listSubtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<nav class="nav nav-pills gap-2">
|
||||||
|
<a class="nav-link" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
|
||||||
|
<a class="nav-link active" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (duplicateGroups().length) {
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
|
||||||
|
<div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card overflow-hidden mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="card-title mb-0">{{ ui.t('expenses.filters') }}</h3>
|
||||||
|
@if (hasActiveFilters()) {
|
||||||
|
<span class="badge text-bg-primary">{{ ui.t('action.filter') }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()" class="row g-3 align-items-end">
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><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-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
|
||||||
|
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
|
||||||
|
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title mb-0">{{ ui.t('expenses.listTitle') }}</h3>
|
||||||
|
<div class="small text-secondary">{{ ui.t('expenses.totalItems') }}: {{ pagination().total }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<select class="form-select form-select-sm w-auto" [value]="pagination().pageSize" (change)="changePageSize($any($event.target).value)">
|
||||||
|
@for (size of pageSizeOptions; track size) {
|
||||||
|
<option [value]="size">{{ size }} / {{ ui.t('expenses.perPage') }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedIds().length) {
|
||||||
|
<div class="card-body border-bottom bg-body-tertiary py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="fw-semibold">{{ ui.t('expenses.selectedCount') }}: {{ selectedIds().length }}</div>
|
||||||
|
<div class="btn-list flex-wrap">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="clearSelection()">{{ ui.t('action.clearSelection') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" type="button" (click)="bulkUpdateStatus('PENDING')">{{ ui.t('status.pending') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-success" type="button" (click)="bulkUpdateStatus('APPROVED')">{{ ui.t('status.approved') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="bulkDelete()">{{ ui.t('action.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-1">
|
||||||
|
<input class="form-check-input" type="checkbox" [checked]="allVisibleSelected()" [indeterminate]="someVisibleSelected()" (change)="toggleAllVisible($any($event.target).checked)" />
|
||||||
|
</th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('expenseDate')">{{ ui.t('expenses.field.date') }} {{ sortIndicator('expenseDate') }}</button></th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('title')">{{ ui.t('table.title') }} {{ sortIndicator('title') }}</button></th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('category')">{{ ui.t('expenses.field.category') }} {{ sortIndicator('category') }}</button></th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('status')">{{ ui.t('expenses.field.status') }} {{ sortIndicator('status') }}</button></th>
|
||||||
|
<th class="text-end"><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('amount')">{{ ui.t('table.amount') }} {{ sortIndicator('amount') }}</button></th>
|
||||||
|
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of expenses(); track item.id) {
|
||||||
|
<tr>
|
||||||
|
<td><input class="form-check-input" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleSelection(item.id, $any($event.target).checked)" /></td>
|
||||||
|
<td>{{ item.expenseDate | date:'yyyy-MM-dd' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<a class="link-body-emphasis text-decoration-none" [routerLink]="['/expenses', item.id]">{{ item.title }}</a>
|
||||||
|
@if (item.possibleDuplicate || item.duplicateStatus) {
|
||||||
|
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
|
||||||
|
}
|
||||||
|
@if (item.recurringSourceId) {
|
||||||
|
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||||
|
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
|
||||||
|
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
|
||||||
|
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
|
||||||
|
</td>
|
||||||
|
<td>{{ item.category.name }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<span class="badge d-inline-flex align-items-center justify-content-center" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span>
|
||||||
|
<select class="form-select form-select-sm" [value]="item.status" [disabled]="statusSavingId() === item.id" (change)="quickChangeStatus(item, $any($event.target).value)">
|
||||||
|
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
|
||||||
|
<option value="PENDING">{{ ui.t('status.pending') }}</option>
|
||||||
|
<option value="APPROVED">{{ ui.t('status.approved') }}</option>
|
||||||
|
<option value="REJECTED">{{ ui.t('status.rejected') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-list justify-content-end flex-wrap">
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" [routerLink]="['/expenses', item.id]">{{ ui.t('action.view') }}</a>
|
||||||
|
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
|
||||||
|
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
|
||||||
|
}
|
||||||
|
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
|
||||||
|
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
|
||||||
|
}
|
||||||
|
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty { <tr><td colspan="7" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="small text-secondary">{{ pageStart() }}-{{ pageEnd() }} / {{ pagination().total }}</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(1)">«</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(pagination().page - 1)">‹</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" disabled>{{ pagination().page }} / {{ pagination().totalPages }}</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().page + 1)">›</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().totalPages)">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (proofPreview()) {
|
||||||
|
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
||||||
|
<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 || ui.t('expenses.proof') }}</h5>
|
||||||
|
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body ec-proof-modal-body">
|
||||||
|
@if (isPdf(proofPreview()!)) {
|
||||||
|
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
|
||||||
|
} @else {
|
||||||
|
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
|
import { ExpensesService } from '../../core/services/expenses.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { DuplicateGroup, Expense, PaginationMeta, Proof } from '../../shared/models';
|
||||||
|
|
||||||
|
type SortColumn = 'expenseDate' | 'title' | 'amount' | 'status' | 'category';
|
||||||
|
|
||||||
|
type ListState = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
categoryId: string;
|
||||||
|
search: string;
|
||||||
|
status: string;
|
||||||
|
tags: string;
|
||||||
|
duplicatesOnly: boolean;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy: SortColumn;
|
||||||
|
sortDir: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultState: ListState = {
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
categoryId: '',
|
||||||
|
search: '',
|
||||||
|
status: '',
|
||||||
|
tags: '',
|
||||||
|
duplicatesOnly: false,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
sortBy: 'expenseDate',
|
||||||
|
sortDir: 'desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe],
|
||||||
|
templateUrl: './expense-list.component.html'
|
||||||
|
})
|
||||||
|
export class ExpenseListComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
|
private readonly expensesService = inject(ExpensesService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly categories = this.categoriesService.items;
|
||||||
|
readonly expenses = signal<Expense[]>([]);
|
||||||
|
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
|
||||||
|
readonly proofPreview = signal<Proof | null>(null);
|
||||||
|
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
|
const proof = this.proofPreview();
|
||||||
|
if (!proof || !this.isPdf(proof)) return null;
|
||||||
|
const previewUrl = proof.previewUrl || proof.fileUrl;
|
||||||
|
if (!previewUrl) return null;
|
||||||
|
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
|
||||||
|
});
|
||||||
|
readonly statusSavingId = signal<string | null>(null);
|
||||||
|
readonly pagination = signal<PaginationMeta>({ page: 1, pageSize: 20, total: 0, totalPages: 1, hasPrev: false, hasNext: false });
|
||||||
|
readonly pageSizeOptions = [10, 20, 50];
|
||||||
|
readonly sortBy = signal<SortColumn>('expenseDate');
|
||||||
|
readonly sortDir = signal<'asc' | 'desc'>('desc');
|
||||||
|
readonly selectedIds = signal<string[]>([]);
|
||||||
|
readonly visibleIds = computed(() => this.expenses().map((item) => item.id));
|
||||||
|
readonly allVisibleSelected = computed(() => this.visibleIds().length > 0 && this.visibleIds().every((id) => this.selectedIds().includes(id)));
|
||||||
|
readonly someVisibleSelected = computed(() => !this.allVisibleSelected() && this.visibleIds().some((id) => this.selectedIds().includes(id)));
|
||||||
|
|
||||||
|
readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] });
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.categoriesService.ensureLoaded(true);
|
||||||
|
this.route.queryParamMap.subscribe((params) => {
|
||||||
|
const state: ListState = {
|
||||||
|
startDate: params.get('startDate') ?? defaultState.startDate,
|
||||||
|
endDate: params.get('endDate') ?? defaultState.endDate,
|
||||||
|
categoryId: params.get('categoryId') ?? defaultState.categoryId,
|
||||||
|
search: params.get('search') ?? defaultState.search,
|
||||||
|
status: params.get('status') ?? defaultState.status,
|
||||||
|
tags: params.get('tags') ?? defaultState.tags,
|
||||||
|
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
|
||||||
|
page: this.parsePositiveInt(params.get('page'), defaultState.page),
|
||||||
|
pageSize: this.parsePositiveInt(params.get('pageSize'), defaultState.pageSize),
|
||||||
|
sortBy: this.parseSortColumn(params.get('sortBy')),
|
||||||
|
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
|
||||||
|
};
|
||||||
|
this.filterForm.patchValue({
|
||||||
|
startDate: state.startDate,
|
||||||
|
endDate: state.endDate,
|
||||||
|
categoryId: state.categoryId,
|
||||||
|
search: state.search,
|
||||||
|
status: state.status,
|
||||||
|
tags: state.tags,
|
||||||
|
duplicatesOnly: state.duplicatesOnly
|
||||||
|
}, { emitEvent: false });
|
||||||
|
this.sortBy.set(state.sortBy);
|
||||||
|
this.sortDir.set(state.sortDir);
|
||||||
|
this.pagination.update((current) => ({ ...current, page: state.page, pageSize: state.pageSize }));
|
||||||
|
this.loadExpenses(state);
|
||||||
|
this.loadDuplicates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
|
||||||
|
hasActiveFilters() {
|
||||||
|
const raw = this.filterForm.getRawValue();
|
||||||
|
return Boolean(raw.startDate || raw.endDate || raw.categoryId || raw.search || raw.status || raw.tags || raw.duplicatesOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadExpenses(state: ListState) {
|
||||||
|
this.expensesService.list({
|
||||||
|
startDate: state.startDate || undefined,
|
||||||
|
endDate: state.endDate || undefined,
|
||||||
|
categoryId: state.categoryId || undefined,
|
||||||
|
search: state.search || undefined,
|
||||||
|
status: state.status || undefined,
|
||||||
|
tags: state.tags || undefined,
|
||||||
|
duplicatesOnly: state.duplicatesOnly || undefined,
|
||||||
|
page: state.page,
|
||||||
|
pageSize: state.pageSize,
|
||||||
|
sortBy: state.sortBy,
|
||||||
|
sortDir: state.sortDir
|
||||||
|
}).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.expenses.set(response.items);
|
||||||
|
this.pagination.set(response.pagination ?? { page: state.page, pageSize: state.pageSize, total: response.items.length, totalPages: 1, hasPrev: false, hasNext: false });
|
||||||
|
this.selectedIds.update((ids) => ids.filter((id) => response.items.some((item) => item.id === id)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadDuplicates() {
|
||||||
|
this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQueryParams(overrides: Partial<ListState> = {}) {
|
||||||
|
const raw = this.filterForm.getRawValue();
|
||||||
|
const state: ListState = {
|
||||||
|
startDate: raw.startDate,
|
||||||
|
endDate: raw.endDate,
|
||||||
|
categoryId: raw.categoryId,
|
||||||
|
search: raw.search,
|
||||||
|
status: raw.status,
|
||||||
|
tags: raw.tags,
|
||||||
|
duplicatesOnly: raw.duplicatesOnly,
|
||||||
|
page: this.pagination().page,
|
||||||
|
pageSize: this.pagination().pageSize,
|
||||||
|
sortBy: this.sortBy(),
|
||||||
|
sortDir: this.sortDir(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: state.startDate || null,
|
||||||
|
endDate: state.endDate || null,
|
||||||
|
categoryId: state.categoryId || null,
|
||||||
|
search: state.search || null,
|
||||||
|
status: state.status || null,
|
||||||
|
tags: state.tags || null,
|
||||||
|
duplicatesOnly: state.duplicatesOnly ? '1' : null,
|
||||||
|
page: state.page !== defaultState.page ? state.page : null,
|
||||||
|
pageSize: state.pageSize !== defaultState.pageSize ? state.pageSize : null,
|
||||||
|
sortBy: state.sortBy !== defaultState.sortBy ? state.sortBy : null,
|
||||||
|
sortDir: state.sortDir !== defaultState.sortDir ? state.sortDir : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateUrl(overrides: Partial<ListState> = {}) {
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: this.buildQueryParams(overrides),
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ page: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ ...defaultState });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSort(column: SortColumn) {
|
||||||
|
if (this.sortBy() === column) this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
|
||||||
|
else {
|
||||||
|
this.sortBy.set(column);
|
||||||
|
this.sortDir.set(column === 'amount' || column === 'title' || column === 'category' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ page: 1, sortBy: this.sortBy(), sortDir: this.sortDir() });
|
||||||
|
}
|
||||||
|
|
||||||
|
sortIndicator(column: SortColumn) {
|
||||||
|
if (this.sortBy() !== column) return '';
|
||||||
|
return this.sortDir() === 'asc' ? '↑' : '↓';
|
||||||
|
}
|
||||||
|
|
||||||
|
changePage(page: number) {
|
||||||
|
if (page < 1 || page > this.pagination().totalPages) return;
|
||||||
|
this.updateUrl({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
changePageSize(value: string | number) {
|
||||||
|
const pageSize = Number(value);
|
||||||
|
if (!Number.isFinite(pageSize) || pageSize <= 0) return;
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ page: 1, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
pageStart() {
|
||||||
|
if (!this.pagination().total) return 0;
|
||||||
|
return (this.pagination().page - 1) * this.pagination().pageSize + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageEnd() {
|
||||||
|
if (!this.pagination().total) return 0;
|
||||||
|
return Math.min(this.pagination().page * this.pagination().pageSize, this.pagination().total);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(id: string) { return this.selectedIds().includes(id); }
|
||||||
|
|
||||||
|
toggleSelection(id: string, checked: boolean) {
|
||||||
|
this.selectedIds.update((ids) => checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAllVisible(checked: boolean) {
|
||||||
|
const visibleIds = this.visibleIds();
|
||||||
|
this.selectedIds.update((ids) => {
|
||||||
|
if (checked) return Array.from(new Set([...ids, ...visibleIds]));
|
||||||
|
return ids.filter((id) => !visibleIds.includes(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection() { this.selectedIds.set([]); }
|
||||||
|
|
||||||
|
startEdit(item: Expense) {
|
||||||
|
this.router.navigate(['/expenses/add'], { queryParams: { edit: item.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExpense(item: Expense) {
|
||||||
|
this.expensesService.delete(item.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.deleted'));
|
||||||
|
this.clearSelection();
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
quickChangeStatus(item: Expense, nextStatus: string) {
|
||||||
|
if (!nextStatus || nextStatus === item.status) return;
|
||||||
|
this.statusSavingId.set(item.id);
|
||||||
|
this.expensesService.updateStatus(item.id, nextStatus as Expense['status']).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.statusUpdated'));
|
||||||
|
this.statusSavingId.set(null);
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.statusSavingId.set(null);
|
||||||
|
this.toast.error(error.error?.message ?? this.ui.t('expenses.statusUpdateError'));
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUpdateStatus(status: Expense['status']) {
|
||||||
|
if (!this.selectedIds().length) return;
|
||||||
|
this.expensesService.bulkUpdateStatus(this.selectedIds(), status).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.bulkUpdated'));
|
||||||
|
this.clearSelection();
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDelete() {
|
||||||
|
if (!this.selectedIds().length) return;
|
||||||
|
if (!globalThis.confirm(this.ui.t('expenses.bulkDeleteConfirm'))) return;
|
||||||
|
this.expensesService.bulkDelete(this.selectedIds()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.bulkDeleted'));
|
||||||
|
this.clearSelection();
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewDuplicate(item: Expense, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
||||||
|
this.expensesService.reviewDuplicate(item.id, action).subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (action === 'CONFIRM') this.toast.success(this.ui.t('expenses.duplicateConfirmed'));
|
||||||
|
if (action === 'DISMISS') this.toast.success(this.ui.t('expenses.duplicateDismissed'));
|
||||||
|
if (action === 'REOPEN') this.toast.success(this.ui.t('expenses.duplicateReopened'));
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
||||||
|
closeProofPreview() { this.proofPreview.set(null); }
|
||||||
|
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
|
||||||
|
|
||||||
|
statusBadgeClass(status: string) {
|
||||||
|
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateBadgeClass(item: Expense) {
|
||||||
|
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
||||||
|
return ({ OPEN: 'text-bg-warning', CONFIRMED: 'text-bg-danger', DISMISSED: 'text-bg-secondary' } as Record<string, string>)[state || 'OPEN'] || 'text-bg-warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateLabel(item: Expense) {
|
||||||
|
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
||||||
|
if (state === 'CONFIRMED') return this.ui.t('expenses.duplicateStatus.confirmed');
|
||||||
|
if (state === 'DISMISSED') return this.ui.t('expenses.duplicateStatus.dismissed');
|
||||||
|
return this.ui.t('expenses.duplicateStatus.open');
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadExpensesFromCurrentRoute() {
|
||||||
|
const params = this.route.snapshot.queryParamMap;
|
||||||
|
this.loadExpenses({
|
||||||
|
startDate: params.get('startDate') ?? defaultState.startDate,
|
||||||
|
endDate: params.get('endDate') ?? defaultState.endDate,
|
||||||
|
categoryId: params.get('categoryId') ?? defaultState.categoryId,
|
||||||
|
search: params.get('search') ?? defaultState.search,
|
||||||
|
status: params.get('status') ?? defaultState.status,
|
||||||
|
tags: params.get('tags') ?? defaultState.tags,
|
||||||
|
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
|
||||||
|
page: this.parsePositiveInt(params.get('page'), this.pagination().page),
|
||||||
|
pageSize: this.parsePositiveInt(params.get('pageSize'), this.pagination().pageSize),
|
||||||
|
sortBy: this.parseSortColumn(params.get('sortBy')),
|
||||||
|
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePositiveInt(value: string | null, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSortColumn(value: string | null): SortColumn {
|
||||||
|
return (['expenseDate', 'title', 'amount', 'status', 'category'] as const).includes((value ?? '') as SortColumn)
|
||||||
|
? (value as SortColumn)
|
||||||
|
: defaultState.sortBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<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">{{ ui.t('expenses.title') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<nav class="nav nav-pills gap-2">
|
||||||
|
<a class="nav-link active" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
|
||||||
|
<a class="nav-link" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards align-items-start">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="card-title mb-0">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
|
||||||
|
@if (editingExpenseId()) {
|
||||||
|
<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" 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">{{ 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())" /></div>
|
||||||
|
<div class="col-md-5"><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())" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label><input class="form-control" type="date" formControlName="expenseDate" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
||||||
|
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.payment') }}</label><select class="form-select" formControlName="paymentMethod"><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">{{ ui.t('expenses.payment.other') }}</option></select></div>
|
||||||
|
<div class="col-md-7"><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="">{{ 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()">{{ ui.t('action.add') }}</button></div></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="3" formControlName="description"></textarea></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
|
||||||
|
<div formArrayName="customFields" class="d-grid gap-2">
|
||||||
|
@for (group of customFields.controls; track $index) {
|
||||||
|
<div [formGroupName]="$index" class="row g-2">
|
||||||
|
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
|
||||||
|
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
|
||||||
|
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><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">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editingExpenseId() && editingProofs().length) {
|
||||||
|
<div>
|
||||||
|
<div class="form-label">{{ ui.t('expenses.existingProofs') }}</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@for (proof of editingProofs(); track proof.id) {
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 border rounded-3 p-2 bg-white">
|
||||||
|
<button class="btn btn-link text-start p-0 text-decoration-none flex-grow-1" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="markProofForRemoval(proof)">{{ ui.t('action.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showCropper()) {
|
||||||
|
<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">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
|
||||||
|
}
|
||||||
|
@if (selectedFiles().length) {
|
||||||
|
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
|
||||||
|
}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="btn-list flex-wrap">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
|
||||||
|
<button class="btn btn-success" [disabled]="saving()">{{ saving() ? ui.t('expenses.saving') : ui.t('action.save') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (merchantModalOpen()) {
|
||||||
|
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close ec-modal-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">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><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">{{ 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()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (proofPreview()) {
|
||||||
|
<div class="modal modal-blur fade show d-block" tabindex="-1"><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 || ui.t('expenses.proof') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body ec-proof-modal-body">@if (isPdf(proofPreview()!)) { <iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe> } @else { <img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
|
||||||
|
}
|
||||||
@@ -1,337 +1,108 @@
|
|||||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
|
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
|
||||||
import { CategoriesService } from '../../core/services/categories.service';
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
import { ExpensesService } from '../../core/services/expenses.service';
|
import { ExpensesService } from '../../core/services/expenses.service';
|
||||||
import { MerchantsService } from '../../core/services/merchants.service';
|
import { MerchantsService } from '../../core/services/merchants.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
import type { Expense, Merchant, Proof } from '../../shared/models';
|
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({
|
@Component({
|
||||||
selector: 'app-expenses',
|
selector: 'app-expenses',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent],
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, ImageCropperComponent],
|
||||||
template: `
|
templateUrl: './expenses.component.html'
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row row-cards align-items-start">
|
|
||||||
<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>
|
|
||||||
@if (editingExpenseId()) {
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">Anuluj edycję</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-7">
|
|
||||||
<label class="form-label">Tytuł</label>
|
|
||||||
<input class="form-control" formControlName="title" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5">
|
|
||||||
<label class="form-label">Kwota</label>
|
|
||||||
<input class="form-control" type="number" step="0.01" formControlName="amount" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Data</label>
|
|
||||||
<input class="form-control" type="date" formControlName="expenseDate" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Kategoria</label>
|
|
||||||
<select class="form-select" formControlName="categoryId">
|
|
||||||
<option value="">Wybierz</option>
|
|
||||||
@for (category of categories(); track category.id) {
|
|
||||||
<option [value]="category.id">{{ category.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Płatność</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="BLIK">BLIK</option>
|
|
||||||
<option value="OTHER">Inne</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-9">
|
|
||||||
<label class="form-label">Kontrahent</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)">
|
|
||||||
<option value="">Własny wpis</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Nazwa w wydatku</label>
|
|
||||||
<input class="form-control" formControlName="merchant" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Opis</label>
|
|
||||||
<textarea class="form-control" rows="3" formControlName="description"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!editingExpenseId()) {
|
|
||||||
<div class="card bg-body-tertiary overflow-hidden">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Etykieta</label>
|
|
||||||
<input class="form-control" formControlName="proofLabel" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Plik</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>
|
|
||||||
<textarea class="form-control" rows="2" formControlName="proofNote"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (showCropper()) {
|
|
||||||
<div>
|
|
||||||
<div class="form-label">Kadrowanie</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>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button class="btn btn-success d-inline-flex align-items-center justify-content-center gap-2" [disabled]="expenseForm.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>{{ saving() ? 'Zapisywanie...' : (editingExpenseId() ? 'Zapisz zmiany' : 'Dodaj wydatek') }}</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-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>
|
|
||||||
@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 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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@if (expenses().length) {
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
@for (expense of expenses(); track expense.id) {
|
|
||||||
<div class="list-group-item px-0">
|
|
||||||
<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.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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (expense.proofs.length) {
|
|
||||||
<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' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="alert alert-warning mb-0">Brak wydatków do wyświetlenia.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (merchantModalOpen()) {
|
|
||||||
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Nowy kontrahent</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>
|
|
||||||
<input class="form-control" formControlName="name" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Typ</label>
|
|
||||||
<select class="form-select" formControlName="kind">
|
|
||||||
<option value="MERCHANT">Sprzedawca</option>
|
|
||||||
<option value="SERVICE_PROVIDER">Usługodawca</option>
|
|
||||||
<option value="OTHER">Inny</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Notatki</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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop fade show"></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (proofPreview()) {
|
|
||||||
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
|
||||||
<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>
|
|
||||||
<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" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop fade show"></div>
|
|
||||||
}
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class ExpensesComponent implements OnInit {
|
export class ExpensesComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly categoriesService = inject(CategoriesService);
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
private readonly merchantsService = inject(MerchantsService);
|
private readonly merchantsService = inject(MerchantsService);
|
||||||
private readonly expensesService = inject(ExpensesService);
|
private readonly expensesService = inject(ExpensesService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
readonly categories = this.categoriesService.items;
|
readonly categories = this.categoriesService.items;
|
||||||
readonly merchants = this.merchantsService.items;
|
readonly merchants = this.merchantsService.items;
|
||||||
readonly expenses = signal<Expense[]>([]);
|
|
||||||
readonly selectedMerchantId = signal('');
|
readonly selectedMerchantId = signal('');
|
||||||
readonly editingExpenseId = signal<string | null>(null);
|
readonly editingExpenseId = signal<string | null>(null);
|
||||||
readonly saving = signal(false);
|
readonly saving = signal(false);
|
||||||
|
readonly submitted = signal(false);
|
||||||
readonly merchantModalOpen = signal(false);
|
readonly merchantModalOpen = signal(false);
|
||||||
readonly proofPreview = signal<Proof | null>(null);
|
readonly proofPreview = signal<Proof | null>(null);
|
||||||
|
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
|
const proof = this.proofPreview();
|
||||||
|
if (!proof || !this.isPdf(proof)) return null;
|
||||||
|
const previewUrl = proof.previewUrl || proof.fileUrl;
|
||||||
|
if (!previewUrl) return null;
|
||||||
|
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
|
||||||
|
});
|
||||||
|
readonly selectedFiles = signal<File[]>([]);
|
||||||
readonly imageChangedEvent = signal<Event | null>(null);
|
readonly imageChangedEvent = signal<Event | null>(null);
|
||||||
readonly croppedFile = signal<File | null>(null);
|
readonly croppedFile = signal<File | null>(null);
|
||||||
readonly croppedPreview = signal<string | null>(null);
|
readonly croppedPreview = signal<string | null>(null);
|
||||||
readonly showCropper = signal(false);
|
readonly showCropper = signal(false);
|
||||||
|
readonly editingProofs = signal<Proof[]>([]);
|
||||||
|
readonly removedProofIds = signal<string[]>([]);
|
||||||
|
|
||||||
readonly expenseForm = this.fb.nonNullable.group({
|
readonly expenseForm = this.fb.nonNullable.group({
|
||||||
title: ['', [Validators.required, Validators.minLength(2)]],
|
title: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
amount: [0],
|
amount: [0, [Validators.required, Validators.min(0.01)]],
|
||||||
expenseDate: [today, Validators.required],
|
expenseDate: [today, Validators.required],
|
||||||
categoryId: ['', Validators.required],
|
categoryId: ['', Validators.required],
|
||||||
merchant: [''],
|
merchant: [''],
|
||||||
paymentMethod: [''],
|
paymentMethod: [''],
|
||||||
description: [''],
|
description: [''],
|
||||||
|
status: ['PENDING'],
|
||||||
|
tagsText: [''],
|
||||||
proofType: ['RECEIPT'],
|
proofType: ['RECEIPT'],
|
||||||
proofLabel: [''],
|
proofLabel: [''],
|
||||||
proofNote: ['']
|
proofNote: [''],
|
||||||
|
customFields: this.fb.array([])
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly filterForm = this.fb.nonNullable.group({
|
readonly merchantForm = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], kind: ['MERCHANT' as Merchant['kind'], Validators.required], notes: [''] });
|
||||||
startDate: [''],
|
|
||||||
endDate: [''],
|
|
||||||
categoryId: [''],
|
|
||||||
search: ['']
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly merchantForm = this.fb.nonNullable.group({
|
get customFields() { return this.expenseForm.controls.customFields as FormArray; }
|
||||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
readonly activeMerchants = computed(() => this.merchants().filter((item) => item.isActive));
|
||||||
kind: ['MERCHANT' as Merchant['kind'], Validators.required],
|
|
||||||
notes: ['']
|
|
||||||
});
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.categoriesService.ensureLoaded(true);
|
this.categoriesService.ensureLoaded(true);
|
||||||
this.merchantsService.ensureLoaded(true);
|
this.merchantsService.ensureLoaded(true);
|
||||||
this.loadExpenses();
|
this.route.queryParamMap.subscribe((params) => {
|
||||||
}
|
const editId = params.get('edit');
|
||||||
|
if (editId) this.loadExpenseForEdit(editId);
|
||||||
activeMerchants() {
|
else this.cancelEdit(false);
|
||||||
return this.merchants().filter((item) => item.isActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadExpenses() {
|
|
||||||
this.expensesService.list(this.filterForm.getRawValue()).subscribe({
|
|
||||||
next: (response) => this.expenses.set(response.items)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFilters() {
|
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
|
||||||
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '' });
|
removeCustomField(index: number) { this.customFields.removeAt(index); }
|
||||||
this.loadExpenses();
|
|
||||||
|
private loadExpenseForEdit(id: string) {
|
||||||
|
this.expensesService.getById(id).subscribe({
|
||||||
|
next: (response) => this.startEdit(response.item),
|
||||||
|
error: (error) => {
|
||||||
|
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
||||||
|
this.cancelEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
selectMerchant(id: string) {
|
selectMerchant(id: string) {
|
||||||
@@ -349,27 +120,23 @@ export class ExpensesComponent implements OnInit {
|
|||||||
if (this.merchantForm.invalid) return;
|
if (this.merchantForm.invalid) return;
|
||||||
this.merchantsService.create({ ...this.merchantForm.getRawValue(), isActive: true }).subscribe({
|
this.merchantsService.create({ ...this.merchantForm.getRawValue(), isActive: true }).subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.toast.success('Kontrahent został dodany.');
|
this.toast.success(this.ui.t('merchant.added'));
|
||||||
this.merchantModalOpen.set(false);
|
this.merchantModalOpen.set(false);
|
||||||
this.selectedMerchantId.set(response.item.id);
|
this.selectedMerchantId.set(response.item.id);
|
||||||
this.expenseForm.patchValue({ merchant: response.item.name });
|
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'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onProofSelected(event: Event) {
|
onProofSelected(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0] ?? null;
|
const files = Array.from(input.files ?? []);
|
||||||
this.croppedFile.set(file);
|
this.selectedFiles.set(files);
|
||||||
|
this.croppedFile.set(null);
|
||||||
this.croppedPreview.set(null);
|
this.croppedPreview.set(null);
|
||||||
this.imageChangedEvent.set(event);
|
this.imageChangedEvent.set(event);
|
||||||
|
this.showCropper.set(files.length === 1 && files[0].type.startsWith('image/'));
|
||||||
if (file && file.type.startsWith('image/')) {
|
|
||||||
this.showCropper.set(true);
|
|
||||||
} else {
|
|
||||||
this.showCropper.set(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onImageCropped(event: ImageCroppedEvent) {
|
onImageCropped(event: ImageCroppedEvent) {
|
||||||
@@ -379,37 +146,22 @@ export class ExpensesComponent implements OnInit {
|
|||||||
this.croppedPreview.set(event.objectUrl ?? null);
|
this.croppedPreview.set(event.objectUrl ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
submitExpense() {
|
markProofForRemoval(proof: Proof) {
|
||||||
if (this.expenseForm.invalid) return;
|
this.removedProofIds.update((ids) => Array.from(new Set([...ids, proof.id])));
|
||||||
const raw = this.expenseForm.getRawValue();
|
this.editingProofs.update((items) => items.filter((item) => item.id !== proof.id));
|
||||||
this.saving.set(true);
|
}
|
||||||
|
|
||||||
if (this.editingExpenseId()) {
|
submitExpense(forcedStatus?: Expense['status']) {
|
||||||
this.expensesService
|
this.submitted.set(true);
|
||||||
.update(this.editingExpenseId()!, {
|
this.expenseForm.markAllAsTouched();
|
||||||
title: raw.title,
|
if (this.expenseForm.invalid) return;
|
||||||
amount: raw.amount,
|
|
||||||
expenseDate: raw.expenseDate,
|
const raw = this.expenseForm.getRawValue();
|
||||||
categoryId: raw.categoryId,
|
const customFieldEntries = this.customFields.getRawValue().map((item: { key: string; value: string }) => [item.key, item.value] as [string, string]).filter(([key, value]) => Boolean(key && value));
|
||||||
merchant: raw.merchant,
|
const customFields = Object.fromEntries(customFieldEntries);
|
||||||
paymentMethod: raw.paymentMethod as Expense['paymentMethod'],
|
const tags = raw.tagsText.split(',').map((item) => item.trim()).filter(Boolean);
|
||||||
description: raw.description,
|
const status = forcedStatus ?? (raw.status as Expense['status']);
|
||||||
currency: 'PLN'
|
this.saving.set(true);
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.saving.set(false);
|
|
||||||
this.toast.success('Wydatek został zapisany.');
|
|
||||||
this.cancelEdit();
|
|
||||||
this.loadExpenses();
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
this.saving.set(false);
|
|
||||||
this.toast.error(error.error?.message ?? 'Nie udało się zapisać wydatku.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set('title', raw.title);
|
formData.set('title', raw.title);
|
||||||
@@ -420,88 +172,75 @@ export class ExpensesComponent implements OnInit {
|
|||||||
formData.set('paymentMethod', raw.paymentMethod);
|
formData.set('paymentMethod', raw.paymentMethod);
|
||||||
formData.set('description', raw.description);
|
formData.set('description', raw.description);
|
||||||
formData.set('currency', 'PLN');
|
formData.set('currency', 'PLN');
|
||||||
|
formData.set('status', status);
|
||||||
|
formData.set('tags', JSON.stringify(tags));
|
||||||
|
formData.set('customFields', JSON.stringify(customFields));
|
||||||
formData.set('proofType', raw.proofType);
|
formData.set('proofType', raw.proofType);
|
||||||
formData.set('proofLabel', raw.proofLabel);
|
formData.set('proofLabel', raw.proofLabel);
|
||||||
formData.set('proofNote', raw.proofNote);
|
formData.set('proofNote', raw.proofNote);
|
||||||
if (this.croppedFile()) formData.set('proofFile', this.croppedFile()!);
|
formData.set('removeProofIds', JSON.stringify(this.removedProofIds()));
|
||||||
|
|
||||||
this.expensesService.create(formData).subscribe({
|
const selected = this.selectedFiles();
|
||||||
next: () => {
|
if (this.croppedFile()) {
|
||||||
|
formData.append('proofFiles', this.croppedFile()!);
|
||||||
|
selected.slice(1).forEach((file) => formData.append('proofFiles', file));
|
||||||
|
} else {
|
||||||
|
selected.forEach((file) => formData.append('proofFiles', file));
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.editingExpenseId()
|
||||||
|
? this.expensesService.update(this.editingExpenseId()!, formData)
|
||||||
|
: this.expensesService.create(formData);
|
||||||
|
|
||||||
|
request.subscribe({
|
||||||
|
next: (response) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.toast.success('Wydatek został dodany.');
|
this.submitted.set(false);
|
||||||
this.expenseForm.reset({
|
response.warnings?.forEach((warning) => this.toast.warning(warning));
|
||||||
title: '',
|
const wasEditing = Boolean(this.editingExpenseId());
|
||||||
amount: 0,
|
this.toast.success(wasEditing ? this.ui.t('expenses.saved') : status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added'));
|
||||||
expenseDate: today,
|
this.resetForm();
|
||||||
categoryId: '',
|
if (wasEditing) this.router.navigate(['/expenses/add']);
|
||||||
merchant: '',
|
|
||||||
paymentMethod: '',
|
|
||||||
description: '',
|
|
||||||
proofType: 'RECEIPT',
|
|
||||||
proofLabel: '',
|
|
||||||
proofNote: ''
|
|
||||||
});
|
|
||||||
this.selectedMerchantId.set('');
|
|
||||||
this.croppedFile.set(null);
|
|
||||||
this.croppedPreview.set(null);
|
|
||||||
this.showCropper.set(false);
|
|
||||||
this.loadExpenses();
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.toast.error(error.error?.message ?? 'Nie udało się dodać wydatku.');
|
this.toast.error(error.error?.message ?? (this.editingExpenseId() ? this.ui.t('expenses.saveError') : this.ui.t('expenses.addError')));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startEdit(item: Expense) {
|
startEdit(item: Expense) {
|
||||||
this.editingExpenseId.set(item.id);
|
this.editingExpenseId.set(item.id);
|
||||||
this.expenseForm.patchValue({
|
this.editingProofs.set(item.proofs || []);
|
||||||
title: item.title,
|
this.removedProofIds.set([]);
|
||||||
amount: item.amount,
|
this.submitted.set(false);
|
||||||
expenseDate: item.expenseDate,
|
this.customFields.clear();
|
||||||
categoryId: item.category.id,
|
Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value));
|
||||||
merchant: item.merchant ?? '',
|
this.expenseForm.patchValue({ title: item.title, amount: item.amount, expenseDate: item.expenseDate, categoryId: item.category.id, merchant: item.merchant ?? '', paymentMethod: item.paymentMethod ?? '', description: item.description ?? '', status: item.status, tagsText: (item.tags || []).join(', '), proofType: 'RECEIPT', proofLabel: '', proofNote: '' });
|
||||||
paymentMethod: item.paymentMethod ?? '',
|
|
||||||
description: item.description ?? ''
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit(navigate = true) {
|
||||||
this.editingExpenseId.set(null);
|
this.editingExpenseId.set(null);
|
||||||
this.expenseForm.reset({
|
this.submitted.set(false);
|
||||||
title: '',
|
this.resetForm();
|
||||||
amount: 0,
|
if (navigate) this.router.navigate(['/expenses/add']);
|
||||||
expenseDate: today,
|
|
||||||
categoryId: '',
|
|
||||||
merchant: '',
|
|
||||||
paymentMethod: '',
|
|
||||||
description: '',
|
|
||||||
proofType: 'RECEIPT',
|
|
||||||
proofLabel: '',
|
|
||||||
proofNote: ''
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeExpense(item: Expense) {
|
private resetForm() {
|
||||||
this.expensesService.delete(item.id).subscribe({
|
this.customFields.clear();
|
||||||
next: () => {
|
this.expenseForm.reset({ title: '', amount: 0, expenseDate: today, categoryId: '', merchant: '', paymentMethod: '', description: '', status: 'PENDING', tagsText: '', proofType: 'RECEIPT', proofLabel: '', proofNote: '', customFields: [] as never[] });
|
||||||
this.toast.success('Wydatek został usunięty.');
|
this.selectedMerchantId.set('');
|
||||||
this.loadExpenses();
|
this.selectedFiles.set([]);
|
||||||
},
|
this.croppedFile.set(null);
|
||||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć wydatku.')
|
this.croppedPreview.set(null);
|
||||||
});
|
this.imageChangedEvent.set(null);
|
||||||
|
this.showCropper.set(false);
|
||||||
|
this.editingProofs.set([]);
|
||||||
|
this.removedProofIds.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
openProof(proof: Proof) {
|
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
||||||
this.proofPreview.set(proof);
|
closeMerchantModal() { this.merchantModalOpen.set(false); }
|
||||||
}
|
closeProofPreview() { this.proofPreview.set(null); }
|
||||||
|
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
|
||||||
closeMerchantModal() {
|
|
||||||
this.merchantModalOpen.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeProofPreview() {
|
|
||||||
this.proofPreview.set(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<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">{{ ui.t('integrations.title') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('integrations.subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card overflow-hidden ec-accent-card ec-accent-card-primary h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<h3 class="card-title mb-0">{{ ui.t('integrations.shoppingList') }}</h3>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('integrations.projectLink') }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.selfHostedTitle') }}</div>
|
||||||
|
<div>{{ ui.t('integrations.selfHostedHint') }}</div>
|
||||||
|
</div>
|
||||||
|
<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">{{ ui.t('integrations.enabled') }}</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">{{ ui.t('integrations.baseUrl') }}</label>
|
||||||
|
<input class="form-control" formControlName="baseUrl" placeholder="https://host.example.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">{{ ui.t('integrations.apiToken') }}</label>
|
||||||
|
<input class="form-control" formControlName="apiToken" type="password" />
|
||||||
|
<div class="form-hint">{{ ui.t('integrations.keepToken') }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">{{ ui.t('integrations.authMode') }}</label>
|
||||||
|
<select class="form-select" formControlName="authMode">
|
||||||
|
<option value="both">Bearer + X-API-Token</option>
|
||||||
|
<option value="bearer">Bearer</option>
|
||||||
|
<option value="x-api-token">X-API-Token</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">{{ ui.t('integrations.ownerId') }}</label>
|
||||||
|
<input class="form-control" formControlName="ownerId" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">{{ ui.t('integrations.defaultListId') }}</label>
|
||||||
|
<input class="form-control" formControlName="defaultListId" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-list flex-wrap">
|
||||||
|
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
|
||||||
|
<button class="btn btn-outline-info" type="button" (click)="test()">{{ ui.t('action.testConnection') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card overflow-hidden h-100">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.history') }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-3">
|
||||||
|
<form [formGroup]="historyForm" class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">{{ ui.t('integrations.period') }}</label>
|
||||||
|
<input class="form-control" type="month" formControlName="period" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">{{ ui.t('integrations.limit') }}</label>
|
||||||
|
<input class="form-control" type="number" min="1" max="300" formControlName="limit" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="btn-list justify-content-md-end">
|
||||||
|
<button class="btn btn-primary" type="button" (click)="refresh()">{{ ui.t('action.refresh') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalLists') }}</div><div class="ec-stat-value">{{ summaryListCount() }}</div></div></div>
|
||||||
|
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
|
||||||
|
<div class="col-md-4"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||||
|
@if (configured()) {
|
||||||
|
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ ui.t('integrations.summary') }}</div>
|
||||||
|
<div class="text-secondary small">{{ historyForm.controls.period.value }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end small text-secondary">
|
||||||
|
{{ ui.t('integrations.summaryLists') }}: <strong>{{ summaryListCount() }}</strong> ·
|
||||||
|
{{ ui.t('integrations.summarySpend') }}: <strong>{{ summaryAmount() | number:'1.2-2' }} PLN</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<h3 class="card-title mb-0">{{ ui.t('integrations.lists') }}</h3>
|
||||||
|
<span class="badge text-bg-secondary">{{ visibleLists().length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush ec-scroll-list">
|
||||||
|
@for (item of visibleLists(); track item.id) {
|
||||||
|
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
|
||||||
|
<div class="fw-semibold">{{ listTitle(item) }}</div>
|
||||||
|
<div class="small text-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }} · {{ listOwner(item) || ui.t('common.none') }}</div>
|
||||||
|
</button>
|
||||||
|
} @empty {
|
||||||
|
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card overflow-hidden ec-accent-card ec-accent-card-success">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
|
||||||
|
<div>{{ ui.t('integrations.importExplainBodySimple') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="importForm" class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">{{ ui.t('expenses.field.category') }}</label>
|
||||||
|
<select class="form-select" formControlName="categoryId">
|
||||||
|
<option value="">{{ ui.t('common.select') }}</option>
|
||||||
|
@for (item of categories(); track item.id) {
|
||||||
|
<option [value]="item.id">{{ item.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">{{ ui.t('expenses.field.status') }}</label>
|
||||||
|
<select class="form-select" formControlName="status">
|
||||||
|
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
|
||||||
|
<option value="PENDING">{{ ui.t('status.pending') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">{{ ui.t('table.merchant') }}</label>
|
||||||
|
<input class="form-control" formControlName="merchant" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.importMonthTitle') }}</div>
|
||||||
|
<div class="text-secondary small mb-3">{{ ui.t('integrations.importMonthHint') }}</div>
|
||||||
|
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || !configured()" (click)="importPeriod()">{{ ui.t('integrations.importPeriod') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.importListTitle') }}</div>
|
||||||
|
<div class="text-secondary small mb-3">{{ selectedList() ? listTitle(selectedList()) : ui.t('integrations.selectListHintSimple') }}</div>
|
||||||
|
<button class="btn btn-success" type="button" [disabled]="importForm.invalid || !selectedList()" (click)="importSelectedList()">{{ ui.t('integrations.importSelectedList') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedList()) {
|
||||||
|
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||||
|
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ listTitle(selectedList()) }}</div>
|
||||||
|
<div class="small text-secondary">{{ listCreatedAt(selectedList()) | date:'yyyy-MM-dd' }} · {{ listOwner(selectedList()) || ui.t('common.none') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="small text-secondary">{{ ui.t('integrations.selectedListSummary') }}</div>
|
||||||
|
<div class="fw-semibold">{{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-vcenter mb-0">
|
||||||
|
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of selectedListExpenses(); track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ itemTitle(item) }}</td>
|
||||||
|
<td>{{ itemDate(item) }}</td>
|
||||||
|
<td class="text-end">{{ itemAmount(item) | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
|
import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { Category, ShoppingListExpenseItem, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
|
||||||
|
|
||||||
|
const currentMonth = () => new Date().toISOString().slice(0, 7);
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10);
|
||||||
|
const monthRange = (period: string) => {
|
||||||
|
const safe = /^\d{4}-\d{2}$/.test(period) ? period : currentMonth();
|
||||||
|
const [yearText, monthText] = safe.split('-');
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText);
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
return { start: `${safe}-01`, end: `${safe}-${String(lastDay).padStart(2, '0')}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-integrations',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe],
|
||||||
|
templateUrl: './integrations.component.html'
|
||||||
|
})
|
||||||
|
export class IntegrationsComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly integration = inject(ShoppingListIntegrationService);
|
||||||
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly categories = this.categoriesService.items;
|
||||||
|
readonly configured = signal(false);
|
||||||
|
readonly summary = signal<ShoppingListSummary | null>(null);
|
||||||
|
readonly allLists = signal<ShoppingListRef[]>([]);
|
||||||
|
readonly selectedList = signal<ShoppingListRef | null>(null);
|
||||||
|
readonly selectedListExpenses = signal<ShoppingListExpenseItem[]>([]);
|
||||||
|
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
enabled: [false],
|
||||||
|
baseUrl: ['', Validators.required],
|
||||||
|
apiToken: [''],
|
||||||
|
authMode: ['both' as 'bearer' | 'x-api-token' | 'both', Validators.required],
|
||||||
|
ownerId: [''],
|
||||||
|
defaultListId: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly historyForm = this.fb.nonNullable.group({
|
||||||
|
period: [currentMonth(), Validators.required],
|
||||||
|
limit: [80, [Validators.required, Validators.min(1), Validators.max(300)]]
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly importForm = this.fb.nonNullable.group({
|
||||||
|
categoryId: ['', Validators.required],
|
||||||
|
status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required],
|
||||||
|
merchant: ['Zakupy']
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0));
|
||||||
|
readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0));
|
||||||
|
readonly summaryListCount = computed(() => {
|
||||||
|
const summary = this.summary();
|
||||||
|
const groups = [summary?.lists, summary?.totals, summary?.aggregates].find((value) => Array.isArray(value));
|
||||||
|
return Array.isArray(groups) ? groups.length : this.visibleLists().length;
|
||||||
|
});
|
||||||
|
readonly selectedListTotal = computed(() => this.selectedListExpenses().reduce((sum: number, item: ShoppingListExpenseItem) => sum + this.itemAmount(item), 0));
|
||||||
|
readonly selectedListCount = computed(() => this.selectedListExpenses().length);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.categoriesService.list().subscribe({
|
||||||
|
next: (response: { items: Category[] }) => {
|
||||||
|
const first = response.items[0];
|
||||||
|
if (first && !this.importForm.controls.categoryId.value) this.importForm.controls.categoryId.setValue(first.id);
|
||||||
|
},
|
||||||
|
error: () => undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
this.integration.getSettings().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
const item = response.item;
|
||||||
|
this.form.reset({ enabled: item.enabled, baseUrl: item.baseUrl || '', apiToken: '', authMode: item.authMode, ownerId: item.ownerId || '', defaultListId: item.defaultListId || '' });
|
||||||
|
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
|
||||||
|
if (this.configured()) this.refresh();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.form.invalid) return;
|
||||||
|
const raw = this.form.getRawValue();
|
||||||
|
this.integration.updateSettings({ enabled: raw.enabled, baseUrl: raw.baseUrl || null, apiToken: raw.apiToken || undefined, authMode: raw.authMode, ownerId: raw.ownerId || null, defaultListId: raw.defaultListId || null }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
|
||||||
|
this.toast.success(this.ui.t('integrations.saveSuccess'));
|
||||||
|
if (this.configured()) this.refresh();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.saveError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test() {
|
||||||
|
this.integration.test().subscribe({
|
||||||
|
next: () => this.toast.success(this.ui.t('integrations.testSuccess')),
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.testError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
if (!this.configured()) return;
|
||||||
|
const raw = this.form.getRawValue();
|
||||||
|
const history = this.historyForm.getRawValue();
|
||||||
|
const range = monthRange(history.period);
|
||||||
|
const filters = { start_date: range.start, end_date: range.end, owner_id: raw.ownerId || undefined, list_id: raw.defaultListId || undefined };
|
||||||
|
|
||||||
|
this.integration.summary(filters).subscribe({ next: (response) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) });
|
||||||
|
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
const items = this.pickItems<ShoppingListRef>(response);
|
||||||
|
this.allLists.set(items);
|
||||||
|
const visible = this.visibleLists(items);
|
||||||
|
const currentId = String(this.selectedList()?.id ?? '');
|
||||||
|
const nextSelected = visible.find((item) => String(item.id) === currentId) ?? visible[0] ?? null;
|
||||||
|
this.selectedList.set(nextSelected);
|
||||||
|
if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.allLists.set([]);
|
||||||
|
this.selectedList.set(null);
|
||||||
|
this.selectedListExpenses.set([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleLists(source?: ShoppingListRef[]) {
|
||||||
|
const period = this.historyForm.controls.period.value;
|
||||||
|
const items = source ?? this.allLists();
|
||||||
|
return items.filter((item) => {
|
||||||
|
const createdAt = this.listCreatedAt(item);
|
||||||
|
return createdAt ? createdAt.slice(0, 7) === period : true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectList(item: ShoppingListRef) { this.selectedList.set(item); this.loadListExpenses(item); }
|
||||||
|
|
||||||
|
importPeriod() {
|
||||||
|
if (this.importForm.invalid) return;
|
||||||
|
const raw = this.importForm.getRawValue();
|
||||||
|
this.integration.importPeriod({ period: this.historyForm.controls.period.value, categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || 'Zakupy' }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.toast.success(this.ui.t('integrations.importPeriodSuccess'));
|
||||||
|
this.emitWarnings(response.warnings);
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
importSelectedList() {
|
||||||
|
const list = this.selectedList();
|
||||||
|
if (!list || this.importForm.invalid) return;
|
||||||
|
const raw = this.importForm.getRawValue();
|
||||||
|
this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: ['shopping-list'] }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.toast.success(this.ui.t('integrations.importListSuccess'));
|
||||||
|
this.emitWarnings(response.warnings);
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelectedList(item: ShoppingListRef) { return String(this.selectedList()?.id ?? '') === String(item.id); }
|
||||||
|
listTitle(item?: ShoppingListRef | null) { return item?.title || item?.name || (item?.id !== undefined ? String(item.id) : '-'); }
|
||||||
|
listOwner(item?: ShoppingListRef | null) { return item?.owner?.username || item?.owner?.fullName || item?.owner?.name || item?.owner?.email || null; }
|
||||||
|
listCreatedAt(item?: ShoppingListRef | null) { return item?.created_at || null; }
|
||||||
|
itemTitle(item: ShoppingListExpenseItem) { return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`; }
|
||||||
|
itemDate(item: ShoppingListExpenseItem) { return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10); }
|
||||||
|
itemAmount(item: ShoppingListExpenseItem) { return Number(item.amount ?? item.total ?? 0); }
|
||||||
|
|
||||||
|
private loadListExpenses(item: ShoppingListRef) {
|
||||||
|
const limit = this.historyForm.controls.limit.value;
|
||||||
|
this.integration.listExpenses(item.id, limit).subscribe({ next: (response) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.selectedListExpenses.set([]) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitWarnings(warnings?: string[]) { (warnings ?? []).forEach((message) => this.toast.warning(message)); }
|
||||||
|
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<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">{{ 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>{{ ui.t('action.addMerchant') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card pv-card overflow-hidden">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<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 ? 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)">{{ 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">{{ ui.t('merchant.noneSaved') }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (modalOpen()) {
|
||||||
|
<div class="modal modal-blur fade show d-block" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<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">{{ ui.t('merchant.name') }}</label>
|
||||||
|
<input class="form-control" formControlName="name" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">{{ ui.t('merchant.type') }}</label>
|
||||||
|
<select class="form-select" formControlName="kind">
|
||||||
|
<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">{{ 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">{{ ui.t('merchant.showOnLists') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
}
|
||||||
@@ -3,106 +3,17 @@ import { Component, OnInit, inject, signal } from '@angular/core';
|
|||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { MerchantsService } from '../../core/services/merchants.service';
|
import { MerchantsService } from '../../core/services/merchants.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
import type { Merchant } from '../../shared/models';
|
import type { Merchant } from '../../shared/models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-merchants',
|
selector: 'app-merchants',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
template: `
|
templateUrl: './merchants.component.html'
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card pv-card overflow-hidden">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="5" class="text-secondary">Brak zapisanych kontrahentów.</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (modalOpen()) {
|
|
||||||
<div class="modal modal-blur fade show d-block" tabindex="-1" role="dialog">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<input class="form-control" formControlName="name" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Typ</label>
|
|
||||||
<select class="form-select" formControlName="kind">
|
|
||||||
<option value="MERCHANT">Sprzedawca</option>
|
|
||||||
<option value="SERVICE_PROVIDER">Usługodawca</option>
|
|
||||||
<option value="OTHER">Inny</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Notatki</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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop fade show"></div>
|
|
||||||
}
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class MerchantsComponent implements OnInit {
|
export class MerchantsComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly merchants = inject(MerchantsService);
|
private readonly merchants = inject(MerchantsService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
@@ -123,7 +34,11 @@ export class MerchantsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
labelKind(kind: Merchant['kind']) {
|
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() {
|
openCreate() {
|
||||||
@@ -134,12 +49,7 @@ export class MerchantsComponent implements OnInit {
|
|||||||
|
|
||||||
openEdit(item: Merchant) {
|
openEdit(item: Merchant) {
|
||||||
this.editingId.set(item.id);
|
this.editingId.set(item.id);
|
||||||
this.form.reset({
|
this.form.reset({ name: item.name, kind: item.kind, notes: item.notes ?? '', isActive: item.isActive });
|
||||||
name: item.name,
|
|
||||||
kind: item.kind,
|
|
||||||
notes: item.notes ?? '',
|
|
||||||
isActive: item.isActive
|
|
||||||
});
|
|
||||||
this.modalOpen.set(true);
|
this.modalOpen.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,17 +64,17 @@ export class MerchantsComponent implements OnInit {
|
|||||||
|
|
||||||
request.subscribe({
|
request.subscribe({
|
||||||
next: () => {
|
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();
|
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) {
|
remove(item: Merchant) {
|
||||||
this.merchants.delete(item.id).subscribe({
|
this.merchants.delete(item.id).subscribe({
|
||||||
next: () => this.toast.success('Kontrahent został usunięty.'),
|
next: () => this.toast.success(this.ui.t('merchant.deleted')),
|
||||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kontrahenta.')
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('merchant.deleteError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<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">{{ ui.t('nav.recurring') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('recurring.subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="card-title">{{ editingId() ? ui.t('recurring.edit') : ui.t('recurring.new') }}</h3>
|
||||||
|
<div class="btn-list">
|
||||||
|
@if (editingId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="button" (click)="runNow()">{{ ui.t('recurring.runNow') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-8"><label class="form-label">{{ ui.t('expenses.field.title') }}</label><input class="form-control" formControlName="title" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="defaultStatus"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option></select></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.frequency') }}</label><select class="form-select" formControlName="frequency"><option value="WEEKLY">{{ ui.t('recurring.weekly') }}</option><option value="MONTHLY">{{ ui.t('recurring.monthly') }}</option><option value="YEARLY">{{ ui.t('recurring.yearly') }}</option></select></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.interval') }}</label><input class="form-control" type="number" min="1" formControlName="intervalValue" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.startDate') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.nextRunDate') }}</label><input class="form-control" type="date" formControlName="nextRunDate" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.endDate') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.maxOccurrences') }}</label><input class="form-control" type="number" min="1" formControlName="maxOccurrences" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="2" formControlName="description"></textarea></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div formArrayName="customFields" class="d-grid gap-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
|
||||||
|
@for (group of customFields.controls; track $index) {
|
||||||
|
<div [formGroupName]="$index" class="row g-2">
|
||||||
|
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
|
||||||
|
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
|
||||||
|
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-check"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
|
||||||
|
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('recurring.title') }}</h3></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('recurring.frequency') }}</th><th>{{ ui.t('recurring.nextRunDate') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of items(); track item.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ item.title }}</div>
|
||||||
|
<div class="text-secondary small">{{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||||
|
<div class="text-secondary small">{{ ui.t('recurring.generatedCount') }}: {{ item.generatedCount }} · {{ ui.t('recurring.endDate') }}: {{ item.endDate || ui.t('common.none') }}</div>
|
||||||
|
<div class="mt-1 d-flex gap-1 flex-wrap">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ ui.t('recurring.' + item.frequency.toLowerCase()) }}</td>
|
||||||
|
<td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td>
|
||||||
|
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||||
|
<td class="text-end"><div class="btn-list justify-content-end flex-nowrap"><button class="btn btn-sm btn-outline-primary" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button><button class="btn btn-sm btn-outline-danger" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button></div></td>
|
||||||
|
</tr>
|
||||||
|
} @empty { <tr><td colspan="5" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
|
import { RecurringExpensesService } from '../../core/services/recurring-expenses.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { RecurringExpense } from '../../shared/models';
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recurring',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe],
|
||||||
|
templateUrl: './recurring.component.html'
|
||||||
|
})
|
||||||
|
export class RecurringComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
|
private readonly recurringService = inject(RecurringExpensesService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly categories = this.categoriesService.items;
|
||||||
|
readonly items = signal<RecurringExpense[]>([]);
|
||||||
|
readonly editingId = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
title: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
|
amount: [0, [Validators.required, Validators.min(0.01)]],
|
||||||
|
categoryId: ['', Validators.required],
|
||||||
|
defaultStatus: ['PENDING' as RecurringExpense['defaultStatus']],
|
||||||
|
frequency: ['MONTHLY' as RecurringExpense['frequency']],
|
||||||
|
intervalValue: [1, [Validators.required, Validators.min(1)]],
|
||||||
|
startDate: [today(), Validators.required],
|
||||||
|
nextRunDate: [today(), Validators.required],
|
||||||
|
endDate: [''],
|
||||||
|
maxOccurrences: [null as number | null],
|
||||||
|
merchant: [''],
|
||||||
|
description: [''],
|
||||||
|
tagsText: [''],
|
||||||
|
isActive: [true],
|
||||||
|
customFields: this.fb.array([])
|
||||||
|
});
|
||||||
|
|
||||||
|
get customFields() { return this.form.controls.customFields as FormArray; }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.categoriesService.ensureLoaded(true);
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.recurringService.list().subscribe({ next: (response) => this.items.set(response.items) });
|
||||||
|
}
|
||||||
|
|
||||||
|
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
|
||||||
|
removeCustomField(index: number) { this.customFields.removeAt(index); }
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.form.invalid) return;
|
||||||
|
const raw = this.form.getRawValue();
|
||||||
|
const customEntries = this.customFields.getRawValue().map((item: { key: string; value: string }) => [item.key, item.value] as [string, string]).filter(([key, value]) => Boolean(key && value));
|
||||||
|
const payload = {
|
||||||
|
...raw,
|
||||||
|
categoryId: raw.categoryId,
|
||||||
|
endDate: raw.endDate || null,
|
||||||
|
maxOccurrences: raw.maxOccurrences ? Number(raw.maxOccurrences) : null,
|
||||||
|
tags: raw.tagsText.split(',').map((item) => item.trim()).filter(Boolean),
|
||||||
|
customFields: Object.fromEntries(customEntries)
|
||||||
|
};
|
||||||
|
const request = this.editingId() ? this.recurringService.update(this.editingId()!, payload) : this.recurringService.create(payload);
|
||||||
|
request.subscribe({ next: () => { this.toast.success(this.ui.t('recurring.saved')); this.cancelEdit(); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('recurring.saveError')) });
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(item: RecurringExpense) {
|
||||||
|
this.editingId.set(item.id);
|
||||||
|
this.customFields.clear();
|
||||||
|
Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value));
|
||||||
|
this.form.reset({ title: item.title, amount: item.amount, categoryId: item.category.id, defaultStatus: item.defaultStatus, frequency: item.frequency, intervalValue: item.intervalValue, startDate: item.startDate, nextRunDate: item.nextRunDate, endDate: item.endDate || '', maxOccurrences: item.maxOccurrences, merchant: item.merchant || '', description: item.description || '', tagsText: item.tags.join(', '), isActive: item.isActive, customFields: [] as never[] });
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEdit() {
|
||||||
|
this.editingId.set(null);
|
||||||
|
this.customFields.clear();
|
||||||
|
this.form.reset({ title: '', amount: 0, categoryId: '', defaultStatus: 'PENDING', frequency: 'MONTHLY', intervalValue: 1, startDate: today(), nextRunDate: today(), endDate: '', maxOccurrences: null, merchant: '', description: '', tagsText: '', isActive: true, customFields: [] as never[] });
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(item: RecurringExpense) {
|
||||||
|
this.recurringService.delete(item.id).subscribe({ next: () => { this.toast.success(this.ui.t('recurring.deleted')); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('recurring.deleteError')) });
|
||||||
|
}
|
||||||
|
|
||||||
|
runNow() {
|
||||||
|
this.recurringService.runNow().subscribe({ next: () => { this.toast.success(this.ui.t('recurring.ran')); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error')) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<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">{{ 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-visible mb-3">
|
||||||
|
<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">{{ ui.t('reports.enable') }}</span></label>
|
||||||
|
<div><label class="form-label">{{ ui.t('reports.frequency') }}</label><select class="form-select" formControlName="frequency"><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">{{ ui.t('reports.targetEmail') }}</label><input class="form-control" formControlName="sendToEmail" /></div>
|
||||||
|
<div><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">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
|
||||||
|
<div class="btn-list flex-wrap"><button class="btn btn-success" [disabled]="form.invalid" type="submit">{{ ui.t('action.save') }}</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card pv-card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.exportTitle') }}</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form [formGroup]="exportForm" class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="exportForm.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setExportCategoryIds($event)"></app-category-picker></div>
|
||||||
|
<div class="col-12"><div class="btn-list flex-wrap"><button class="btn btn-outline-primary" type="button" (click)="download('csv')">CSV</button><button class="btn btn-outline-primary" type="button" (click)="download('json')">JSON</button><button class="btn btn-outline-primary" type="button" (click)="download('html')">HTML</button><button class="btn btn-outline-primary" type="button" (click)="download('pdf')">PDF</button></div></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card pv-card overflow-hidden">
|
||||||
|
<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">{{ 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"><div class="card-body"><div [innerHTML]="html()"></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|||||||
import { CategoriesService } from '../../core/services/categories.service';
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
import { ReportsService } from '../../core/services/reports.service';
|
import { ReportsService } from '../../core/services/reports.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
||||||
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
||||||
|
|
||||||
@@ -11,150 +12,46 @@ import { CategoryPickerComponent } from '../../shared/ui/category-picker.compone
|
|||||||
selector: 'app-reports',
|
selector: 'app-reports',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, CategoryPickerComponent],
|
imports: [CommonModule, ReactiveFormsModule, CategoryPickerComponent],
|
||||||
template: `
|
templateUrl: './reports.component.html'
|
||||||
<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>
|
|
||||||
</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-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>
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Częstotliwość</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>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Adres docelowy</label>
|
|
||||||
<input class="form-control" formControlName="sendToEmail" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Próg kwotowy</label>
|
|
||||||
<input class="form-control" type="number" step="0.01" formControlName="thresholdAmount" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Kategorie raportu</label>
|
|
||||||
<app-category-picker
|
|
||||||
[items]="categories()"
|
|
||||||
[selectedIds]="form.getRawValue().categoryIds"
|
|
||||||
placeholder="Wszystkie kategorie"
|
|
||||||
(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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-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>
|
|
||||||
}
|
|
||||||
<div class="card bg-body-tertiary overflow-hidden">
|
|
||||||
<div class="card-body">
|
|
||||||
<div [innerHTML]="html()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class ReportsComponent implements OnInit {
|
export class ReportsComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly categoriesService = inject(CategoriesService);
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
private readonly reports = inject(ReportsService);
|
private readonly reports = inject(ReportsService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
readonly categories = this.categoriesService.items;
|
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 summary = signal<StatsResponse | null>(null);
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group({
|
readonly form = this.fb.nonNullable.group({ enabled: [false], frequency: ['monthly' as ReportPreferences['frequency'], Validators.required], sendToEmail: ['', Validators.required], thresholdAmount: [0], categoryIds: [[] as string[]] });
|
||||||
enabled: [false],
|
readonly exportForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], status: [''], tag: [''], categoryIds: [[] as string[]] });
|
||||||
frequency: ['monthly' as ReportPreferences['frequency'], Validators.required],
|
|
||||||
sendToEmail: ['', Validators.required],
|
|
||||||
thresholdAmount: [0],
|
|
||||||
categoryIds: [[] as string[]]
|
|
||||||
});
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.categoriesService.ensureLoaded(true);
|
this.categoriesService.ensureLoaded(true);
|
||||||
this.reports.getPreferences().subscribe({
|
this.reports.getPreferences().subscribe({ next: (response) => { this.form.reset({ enabled: response.item.enabled, frequency: response.item.frequency, sendToEmail: response.item.sendToEmail ?? '', thresholdAmount: response.item.thresholdAmount, categoryIds: response.item.categoryIds ?? [] }); this.preview(); } });
|
||||||
next: (response) => {
|
|
||||||
this.form.reset({
|
|
||||||
enabled: response.item.enabled,
|
|
||||||
frequency: response.item.frequency,
|
|
||||||
sendToEmail: response.item.sendToEmail ?? '',
|
|
||||||
thresholdAmount: response.item.thresholdAmount,
|
|
||||||
categoryIds: response.item.categoryIds ?? []
|
|
||||||
});
|
|
||||||
this.preview();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCategoryIds(categoryIds: string[]) {
|
setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); }
|
||||||
this.form.patchValue({ categoryIds });
|
setExportCategoryIds(categoryIds: string[]) { this.exportForm.patchValue({ categoryIds }); }
|
||||||
}
|
|
||||||
|
|
||||||
save() {
|
save() { if (this.form.invalid) return; this.reports.updatePreferences(this.form.getRawValue()).subscribe({ next: () => { this.toast.success(this.ui.t('reports.saved')); this.preview(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.saveError')) }); }
|
||||||
if (this.form.invalid) return;
|
preview() { this.reports.preview(this.form.getRawValue()).subscribe({ next: (response) => { 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 ?? this.ui.t('reports.previewError')) }); }
|
||||||
const payload: ReportPreferences = this.form.getRawValue();
|
send() { this.reports.send().subscribe({ 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')) }); }
|
||||||
this.reports.updatePreferences(payload).subscribe({
|
|
||||||
next: () => {
|
download(format: 'csv' | 'json' | 'html' | 'pdf') {
|
||||||
this.toast.success('Ustawienia raportów zapisane.');
|
const raw = this.exportForm.getRawValue();
|
||||||
this.preview();
|
this.reports.export({ format, startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, status: raw.status || undefined, tag: raw.tag || undefined, categoryIds: raw.categoryIds.join(',') || undefined }).subscribe({
|
||||||
|
next: (blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `expense-report.${format}`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
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.exportError'))
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
preview() {
|
|
||||||
const payload: ReportPreferences = this.form.getRawValue();
|
|
||||||
this.reports.preview(payload).subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.html.set(response.html);
|
|
||||||
this.summary.set(response.summary);
|
|
||||||
},
|
|
||||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się pobrać podglądu.')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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.')
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<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">{{ 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-visible"><div class="card-body"><form [formGroup]="form" (ngSubmit)="load()" class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.period') }}</label><select class="form-select" formControlName="bucket"><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-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||||
|
<div class="col-md-2"><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">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
|
||||||
|
<div class="col-md-1"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
|
||||||
|
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-success" type="submit">{{ ui.t('action.show') }}</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">{{ 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">{{ 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">{{ ui.t('stats.noCategoryChart') }}</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">{{ 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">{{ ui.t('stats.noTrendChart') }}</div> }</div></div></div>
|
||||||
|
|
||||||
|
<div class="col-lg-6"><div class="card overflow-hidden"><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>{{ 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><td>{{ row.categoryName }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td><td class="text-end">{{ row.count }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
|
||||||
|
<div class="col-lg-6"><div class="card overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.tags') }}</h3></div><div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.tags') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (row of stats()?.byTag || []; track row.tag) { <tr><td>#{{ row.tag }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
|
||||||
|
</div>
|
||||||
@@ -1,143 +1,24 @@
|
|||||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
import {
|
import { Chart, DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js';
|
||||||
Chart,
|
|
||||||
DoughnutController,
|
|
||||||
ArcElement,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
LineController,
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale
|
|
||||||
} from 'chart.js';
|
|
||||||
import { CategoriesService } from '../../core/services/categories.service';
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
import type { StatsResponse } from '../../shared/models';
|
import type { StatsResponse } from '../../shared/models';
|
||||||
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
||||||
|
|
||||||
Chart.register(
|
Chart.register(DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale);
|
||||||
DoughnutController,
|
const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c'];
|
||||||
ArcElement,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
LineController,
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale
|
|
||||||
);
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-stats',
|
selector: 'app-stats',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, CategoryPickerComponent],
|
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, CategoryPickerComponent],
|
||||||
template: `
|
templateUrl: './stats.component.html'
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row row-cards">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<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>
|
|
||||||
<select class="form-select" formControlName="bucket">
|
|
||||||
<option value="month">Miesięczny</option>
|
|
||||||
<option value="quarter">Kwartalny</option>
|
|
||||||
<option value="year">Roczny</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">Kategorie</label>
|
|
||||||
<app-category-picker
|
|
||||||
[items]="categories()"
|
|
||||||
[selectedIds]="form.getRawValue().categoryIds"
|
|
||||||
placeholder="Wszystkie kategorie"
|
|
||||||
(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>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="reset()">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-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-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>
|
|
||||||
</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">Trend wydatków</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<div class="card-header"><h3 class="card-title">Podział kategorii</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>
|
|
||||||
<tbody>
|
|
||||||
@for (row of stats()?.byCategory || []; track row.categoryId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.categoryName }}</td>
|
|
||||||
<td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td>
|
|
||||||
<td class="text-end">{{ row.count }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="3" class="text-secondary">Brak danych.</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class StatsComponent implements OnInit {
|
export class StatsComponent implements OnInit, OnDestroy {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly categoriesService = inject(CategoriesService);
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
private readonly statsService = inject(StatsService);
|
private readonly statsService = inject(StatsService);
|
||||||
@@ -146,51 +27,28 @@ export class StatsComponent implements OnInit {
|
|||||||
readonly stats = signal<StatsResponse | null>(null);
|
readonly stats = signal<StatsResponse | null>(null);
|
||||||
private categoryChart?: Chart;
|
private categoryChart?: Chart;
|
||||||
private lineChart?: Chart;
|
private lineChart?: Chart;
|
||||||
|
readonly form = this.fb.nonNullable.group({ bucket: ['month' as 'month' | 'quarter' | 'year'], startDate: [''], endDate: [''], categoryIds: [[] as string[]], status: [''], tag: [''] });
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group({
|
ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); }
|
||||||
bucket: ['month' as 'month' | 'quarter' | 'year'],
|
ngOnDestroy() { this.categoryChart?.destroy(); this.lineChart?.destroy(); }
|
||||||
startDate: [''],
|
setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); }
|
||||||
endDate: [''],
|
hasCategoryData() { return Boolean(this.stats()?.byCategory?.length); }
|
||||||
categoryIds: [[] as string[]]
|
hasTimelineData() { return Boolean(this.stats()?.timeline?.length); }
|
||||||
});
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.categoriesService.ensureLoaded(true);
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
setCategoryIds(categoryIds: string[]) {
|
|
||||||
this.form.patchValue({ categoryIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
const raw = this.form.getRawValue();
|
const raw = this.form.getRawValue();
|
||||||
this.statsService
|
this.statsService.overview({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryIds: raw.categoryIds.join(',') || undefined, bucket: raw.bucket, status: raw.status || undefined, tag: raw.tag || undefined }).subscribe({
|
||||||
.overview({
|
|
||||||
startDate: raw.startDate || undefined,
|
|
||||||
endDate: raw.endDate || undefined,
|
|
||||||
categoryIds: raw.categoryIds.join(',') || undefined,
|
|
||||||
bucket: raw.bucket
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.stats.set(response);
|
this.stats.set(response);
|
||||||
setTimeout(() => this.renderCharts(), 0);
|
this.scheduleChartRender();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() { this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [], status: '', tag: '' }); this.load(); }
|
||||||
this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [] });
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCategoryData() {
|
private scheduleChartRender() {
|
||||||
return Boolean(this.stats()?.byCategory?.length);
|
requestAnimationFrame(() => this.renderCharts());
|
||||||
}
|
|
||||||
|
|
||||||
hasTimelineData() {
|
|
||||||
return Boolean(this.stats()?.timeline?.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderCharts() {
|
private renderCharts() {
|
||||||
@@ -199,35 +57,14 @@ export class StatsComponent implements OnInit {
|
|||||||
const lineCanvas = document.getElementById('statsLineChart') as HTMLCanvasElement | null;
|
const lineCanvas = document.getElementById('statsLineChart') as HTMLCanvasElement | null;
|
||||||
|
|
||||||
if (categoryCanvas && current?.byCategory?.length) {
|
if (categoryCanvas && current?.byCategory?.length) {
|
||||||
|
const colors = current.byCategory.map((_, index) => chartPalette[index % chartPalette.length]);
|
||||||
this.categoryChart?.destroy();
|
this.categoryChart?.destroy();
|
||||||
this.categoryChart = new Chart(categoryCanvas, {
|
this.categoryChart = new Chart(categoryCanvas, { type: 'doughnut', data: { labels: current.byCategory.map((item) => item.categoryName), datasets: [{ data: current.byCategory.map((item) => item.total), backgroundColor: colors, borderColor: '#ffffff', hoverOffset: 10 }] }, options: { maintainAspectRatio: false, cutout: '64%', plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 10, color: '#9ca3af' } } } } });
|
||||||
type: 'doughnut',
|
} else this.categoryChart?.destroy();
|
||||||
data: {
|
|
||||||
labels: current.byCategory.map((item) => item.categoryName),
|
|
||||||
datasets: [{ data: current.byCategory.map((item) => item.total) }]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
cutout: '64%',
|
|
||||||
plugins: { legend: { position: 'bottom' } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.categoryChart?.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineCanvas && current?.timeline?.length) {
|
if (lineCanvas && current?.timeline?.length) {
|
||||||
this.lineChart?.destroy();
|
this.lineChart?.destroy();
|
||||||
this.lineChart = new Chart(lineCanvas, {
|
this.lineChart = new Chart(lineCanvas, { type: 'line', data: { labels: current.timeline.map((item) => item.label), datasets: [{ label: this.ui.t('stats.expensesLabel'), data: current.timeline.map((item) => item.total), borderColor: '#206bc4', backgroundColor: '#206bc4', tension: 0.3 }] }, options: { maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { color: '#9ca3af' } } } } });
|
||||||
type: 'line',
|
} else this.lineChart?.destroy();
|
||||||
data: {
|
|
||||||
labels: current.timeline.map((item) => item.label),
|
|
||||||
datasets: [{ label: 'Wydatki', data: current.timeline.map((item) => item.total), tension: 0.35 }]
|
|
||||||
},
|
|
||||||
options: { maintainAspectRatio: false }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.lineChart?.destroy();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<div class="page">
|
||||||
|
<header class="navbar navbar-expand-md d-print-none pv-navbar">
|
||||||
|
<div class="container-xl d-flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||||
|
<div class="d-flex align-items-center gap-3 flex-grow-1 min-w-0">
|
||||||
|
<button class="btn btn-icon btn-outline-secondary d-md-none" type="button" (click)="toggleMenu()" [attr.aria-label]="ui.t('nav.toggleMenu')">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="navbar-brand navbar-brand-autodark fw-bold text-truncate">{{ appSettings.appName() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||||
|
<div class="ec-toolbar-toggle d-inline-flex align-items-center">
|
||||||
|
<button class="btn btn-icon btn-ghost-secondary"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="ui.t('theme.label')"
|
||||||
|
[attr.title]="ui.t('theme.label')"
|
||||||
|
(click)="ui.toggleTheme()">
|
||||||
|
@if (ui.theme() === 'dark') {
|
||||||
|
<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 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
|
||||||
|
} @else {
|
||||||
|
<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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon btn-ghost-secondary"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="currentLanguageLabel()"
|
||||||
|
[attr.title]="currentLanguageLabel()"
|
||||||
|
(click)="toggleLanguage()">
|
||||||
|
<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="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div>
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" (click)="logout()" [attr.aria-label]="ui.t('action.logout')" [attr.title]="ui.t('action.logout')">
|
||||||
|
<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="M13 12v.01"/><path d="M3 21h18"/><path d="M5 21v-14a2 2 0 0 1 2 -2h5m4 0h1a2 2 0 0 1 2 2v14"/><path d="M14 7l3 -3l3 3"/></svg>
|
||||||
|
<span>{{ ui.t('action.logout') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pv-subnav" [class.is-open]="menuOpen()">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="pv-subnav-shell">
|
||||||
|
<div class="pv-subnav-main">
|
||||||
|
<div class="ec-nav-caption d-md-none">{{ ui.t('nav.navigation') }}</div>
|
||||||
|
<nav class="pv-subnav-tabs nav nav-pills gap-1">
|
||||||
|
@for (item of navItems(); track item.path) {
|
||||||
|
<a class="nav-link" [routerLink]="item.path" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: !!item.exact }" (click)="closeMenu()">{{ item.label }}</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl"><router-outlet></router-outlet></div>
|
||||||
|
</div>
|
||||||
|
<footer class="ec-footer border-top py-3 mt-auto">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="d-flex flex-column flex-md-row align-items-center justify-content-between gap-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
[class.bg-success]="apiStatus.state() === 'online'"
|
||||||
|
[class.bg-danger]="apiStatus.state() === 'offline'">
|
||||||
|
{{ apiStatus.state() === 'online' ? ui.t('footer.apiOnline') : ui.t('footer.apiOffline') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap text-center text-md-end justify-content-center justify-content-md-end">
|
||||||
|
<span class="text-secondary small">Created by</span>
|
||||||
|
<span class="fw-bold">Mateusz Gruszczyński</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
href="https://www.linuxiarz.pl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-sm me-1" width="24" height="24" 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="M3 12h18"/>
|
||||||
|
<path d="M12 3a9 9 0 0 1 0 18"/>
|
||||||
|
<path d="M12 3a9 9 0 0 0 0 18"/>
|
||||||
|
</svg>
|
||||||
|
linuxiarz.pl
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,112 +1,65 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, inject } from '@angular/core';
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { NavigationEnd } from '@angular/router';
|
||||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
import { AuthService } from '../core/services/auth.service';
|
import { filter } from 'rxjs/operators';
|
||||||
|
import { ApiStatusService } from '../core/services/api-status.service';
|
||||||
import { AppSettingsService } from '../core/services/app-settings.service';
|
import { AppSettingsService } from '../core/services/app-settings.service';
|
||||||
|
import { AuthService } from '../core/services/auth.service';
|
||||||
import { UiService } from '../core/services/ui.service';
|
import { UiService } from '../core/services/ui.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shell',
|
selector: 'app-shell',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||||
template: `
|
templateUrl: './shell.component.html'
|
||||||
<div class="page">
|
|
||||||
<header class="navbar navbar-expand-md d-print-none pv-navbar">
|
|
||||||
<div class="container-xl gap-3">
|
|
||||||
<div class="navbar-brand navbar-brand-autodark fw-bold">{{ appSettings.appName() }}</div>
|
|
||||||
|
|
||||||
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
|
||||||
<nav class="nav nav-segmented ec-segmented-control" role="tablist" [attr.aria-label]="ui.t('lang.label')">
|
|
||||||
<button class="nav-link"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
[class.active]="ui.language() === 'pl'"
|
|
||||||
[attr.aria-selected]="ui.language() === 'pl'"
|
|
||||||
[attr.aria-current]="ui.language() === 'pl' ? 'page' : null"
|
|
||||||
(click)="ui.setLanguage('pl')">PL</button>
|
|
||||||
<button class="nav-link"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
[class.active]="ui.language() === 'en'"
|
|
||||||
[attr.aria-selected]="ui.language() === 'en'"
|
|
||||||
[attr.aria-current]="ui.language() === 'en' ? 'page' : null"
|
|
||||||
(click)="ui.setLanguage('en')">EN</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<nav class="nav nav-segmented ec-segmented-control" role="tablist" [attr.aria-label]="ui.t('theme.label')">
|
|
||||||
<button class="nav-link d-inline-flex align-items-center gap-2"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
[class.active]="ui.theme() === 'dark'"
|
|
||||||
[attr.aria-selected]="ui.theme() === 'dark'"
|
|
||||||
[attr.aria-current]="ui.theme() === 'dark' ? 'page' : null"
|
|
||||||
(click)="ui.setTheme('dark')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-sm" width="16" height="16" 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 3c.132 0 .263 0 .393 .007a8.5 8.5 0 0 0 0 16.986a9 9 0 1 1 -.393 -17z"/></svg>
|
|
||||||
<span>{{ ui.t('theme.dark') }}</span>
|
|
||||||
</button>
|
|
||||||
<button class="nav-link d-inline-flex align-items-center gap-2"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
[class.active]="ui.theme() === 'light'"
|
|
||||||
[attr.aria-selected]="ui.theme() === 'light'"
|
|
||||||
[attr.aria-current]="ui.theme() === 'light' ? 'page' : null"
|
|
||||||
(click)="ui.setTheme('light')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-sm" width="16" height="16" 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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 0 -.393 -17.993z"/><path d="M12 3v1"/><path d="M12 20v1"/><path d="M3 12h1"/><path d="M20 12h1"/><path d="M5.6 5.6l.7 .7"/><path d="M17.7 17.7l.7 .7"/><path d="M17.7 6.3l.7 -.7"/><path d="M6.3 17.7l-.7 .7"/></svg>
|
|
||||||
<span>{{ ui.t('theme.light') }}</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="pv-navbar-user text-end me-1">
|
|
||||||
<div class="fw-semibold">{{ auth.currentUser()?.fullName }}</div>
|
|
||||||
<div class="small text-secondary">{{ auth.currentUser()?.email }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-danger btn-sm d-inline-flex align-items-center gap-2 px-3 flex-shrink-0" type="button" (click)="logout()">
|
|
||||||
<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="M14 8v-2a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2 -2v-2"/><path d="M9 12h12l-3 -3"/><path d="M18 15l3 -3"/></svg>
|
|
||||||
<span>{{ ui.t('action.logout') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="pv-subnav">
|
|
||||||
<div class="container-xl">
|
|
||||||
<div class="pv-subnav-shell">
|
|
||||||
<div class="pv-subnav-main">
|
|
||||||
<nav class="pv-subnav-tabs nav nav-pills">
|
|
||||||
<a class="nav-link" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">{{ ui.t('nav.dashboard') }}</a>
|
|
||||||
<a class="nav-link" routerLink="/expenses" routerLinkActive="active">{{ ui.t('nav.expenses') }}</a>
|
|
||||||
<a class="nav-link" routerLink="/stats" routerLinkActive="active">{{ ui.t('nav.stats') }}</a>
|
|
||||||
<a class="nav-link" routerLink="/merchants" routerLinkActive="active">{{ ui.t('nav.merchants') }}</a>
|
|
||||||
<a class="nav-link" routerLink="/reports" routerLinkActive="active">{{ ui.t('nav.reports') }}</a>
|
|
||||||
<a class="nav-link" routerLink="/categories" routerLinkActive="active">{{ ui.t('nav.categories') }}</a>
|
|
||||||
@if (auth.isAdmin()) {
|
|
||||||
<a class="nav-link" routerLink="/admin" routerLinkActive="active">{{ ui.t('nav.admin') }}</a>
|
|
||||||
}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="page-body">
|
|
||||||
<div class="container-xl">
|
|
||||||
<router-outlet></router-outlet>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class ShellComponent {
|
export class ShellComponent {
|
||||||
readonly auth = inject(AuthService);
|
readonly auth = inject(AuthService);
|
||||||
readonly ui = inject(UiService);
|
readonly ui = inject(UiService);
|
||||||
readonly appSettings = inject(AppSettingsService);
|
readonly appSettings = inject(AppSettingsService);
|
||||||
|
readonly apiStatus = inject(ApiStatusService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
readonly menuOpen = signal(false);
|
||||||
|
readonly currentLanguageLabel = computed(() => this.ui.language() === 'pl' ? 'Polski' : 'English');
|
||||||
|
readonly canAccessIntegrations = computed(() => Boolean(this.auth.currentUser()?.integrationsEnabled));
|
||||||
|
readonly navItems = computed(() => [
|
||||||
|
{ path: '/', label: this.ui.t('nav.dashboard'), exact: true },
|
||||||
|
{ path: '/expenses', label: this.ui.t('nav.expenses') },
|
||||||
|
{ path: '/stats', label: this.ui.t('nav.stats') },
|
||||||
|
{ path: '/cashflow', label: this.ui.t('nav.cashflow') },
|
||||||
|
{ path: '/budgets', label: this.ui.t('nav.budgets') },
|
||||||
|
{ path: '/recurring', label: this.ui.t('nav.recurring') },
|
||||||
|
{ path: '/merchants', label: this.ui.t('nav.merchants') },
|
||||||
|
{ path: '/reports', label: this.ui.t('nav.reports') },
|
||||||
|
{ path: '/categories', label: this.ui.t('nav.categories') },
|
||||||
|
...(this.canAccessIntegrations() ? [{ path: '/integrations', label: this.ui.t('nav.integrations') }] : []),
|
||||||
|
...(this.auth.isAdmin() ? [{ path: '/admin', label: this.ui.t('nav.admin') }] : [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (!this.canAccessIntegrations() && this.router.url.startsWith('/integrations')) {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.auth.isAuthenticated()) {
|
||||||
|
this.auth.fetchMe().subscribe({ error: () => undefined });
|
||||||
|
this.router.events.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)).subscribe(() => {
|
||||||
|
this.auth.fetchMe().subscribe({ error: () => undefined });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLanguage() { this.ui.setLanguage(this.ui.language() === 'pl' ? 'en' : 'pl'); }
|
||||||
|
toggleMenu() { this.menuOpen.update((value) => !value); }
|
||||||
|
closeMenu() { this.menuOpen.set(false); }
|
||||||
logout() {
|
logout() {
|
||||||
|
this.closeMenu();
|
||||||
this.auth.logout();
|
this.auth.logout();
|
||||||
this.router.navigate(['/login']);
|
void this.router.navigateByUrl('/login', { replaceUrl: true }).catch(() => {
|
||||||
|
globalThis.location.href = '/login';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface User {
|
|||||||
role: 'ADMIN' | 'USER';
|
role: 'ADMIN' | 'USER';
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
defaultCurrency: string;
|
defaultCurrency: string;
|
||||||
|
integrationsEnabled?: boolean;
|
||||||
reportPreferences?: ReportPreferences;
|
reportPreferences?: ReportPreferences;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -36,9 +37,13 @@ export interface Proof {
|
|||||||
mimeType: string | null;
|
mimeType: string | null;
|
||||||
fileSize: number | null;
|
fileSize: number | null;
|
||||||
fileUrl: string | null;
|
fileUrl: string | null;
|
||||||
|
previewUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExpenseStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||||
|
export type DuplicateStatus = 'OPEN' | 'CONFIRMED' | 'DISMISSED';
|
||||||
|
|
||||||
export interface Expense {
|
export interface Expense {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,13 +53,38 @@ export interface Expense {
|
|||||||
merchant: string | null;
|
merchant: string | null;
|
||||||
paymentMethod: 'CARD' | 'CASH' | 'TRANSFER' | 'BLIK' | 'OTHER' | null;
|
paymentMethod: 'CARD' | 'CASH' | 'TRANSFER' | 'BLIK' | 'OTHER' | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
status: ExpenseStatus;
|
||||||
|
tags: string[];
|
||||||
|
customFields: Record<string, string>;
|
||||||
possibleDuplicate: boolean;
|
possibleDuplicate: boolean;
|
||||||
|
duplicateStatus: DuplicateStatus | null;
|
||||||
|
duplicateReviewedAt: string | null;
|
||||||
|
recurringSourceId: string | null;
|
||||||
category: Category;
|
category: Category;
|
||||||
proofs: Proof[];
|
proofs: Proof[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
source: Expense;
|
||||||
|
matches: Expense[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationMeta {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasPrev: boolean;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseListResponse {
|
||||||
|
items: Expense[];
|
||||||
|
pagination?: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatsResponse {
|
export interface StatsResponse {
|
||||||
total: number;
|
total: number;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -62,12 +92,79 @@ export interface StatsResponse {
|
|||||||
topCategory: { categoryId: string; categoryName: string; total: number; count: number } | null;
|
topCategory: { categoryId: string; categoryName: string; total: number; count: number } | null;
|
||||||
byCategory: Array<{ categoryId: string; categoryName: string; total: number; count: number }>;
|
byCategory: Array<{ categoryId: string; categoryName: string; total: number; count: number }>;
|
||||||
timeline: Array<{ label: string; total: number }>;
|
timeline: Array<{ label: string; total: number }>;
|
||||||
|
byTag?: Array<{ tag: string; total: number }>;
|
||||||
|
byStatus?: Array<{ status: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashflowResponse {
|
||||||
|
currentMonth: string;
|
||||||
|
actualCurrent: number;
|
||||||
|
totalBudget: number;
|
||||||
|
budgetUsagePercent: number;
|
||||||
|
duplicateCount: number;
|
||||||
|
pendingApproval: number;
|
||||||
|
forecastCurrentMonth: number;
|
||||||
|
trend: Array<{ label: string; actual: number; budget: number }>;
|
||||||
|
alerts: Array<{ id: string; name: string; usagePercent: number; spent: number; amount: number }>;
|
||||||
|
upcomingRecurring: Array<{ id: string; title: string; amount: number; nextRunDate: string; frequency: string }>;
|
||||||
|
statusSummary: Array<{ status: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Budget {
|
||||||
|
id: string;
|
||||||
|
month: string;
|
||||||
|
name: string | null;
|
||||||
|
amount: number;
|
||||||
|
spent: number;
|
||||||
|
usagePercent: number;
|
||||||
|
alertLevel: number | null;
|
||||||
|
alertThresholds: number[];
|
||||||
|
isActive: boolean;
|
||||||
|
category: Category | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetListResponse {
|
||||||
|
month: string;
|
||||||
|
items: Budget[];
|
||||||
|
summary: {
|
||||||
|
totalBudget: number;
|
||||||
|
totalSpent: number;
|
||||||
|
alerts: Array<{ budgetId: string; message: string; usagePercent: number; level: number }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringExpense {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
amount: number;
|
||||||
|
merchant: string | null;
|
||||||
|
paymentMethod: 'CARD' | 'CASH' | 'TRANSFER' | 'BLIK' | 'OTHER' | null;
|
||||||
|
currency: string;
|
||||||
|
frequency: 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
||||||
|
intervalValue: number;
|
||||||
|
startDate: string;
|
||||||
|
nextRunDate: string;
|
||||||
|
lastRunDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
maxOccurrences: number | null;
|
||||||
|
generatedCount: number;
|
||||||
|
defaultStatus: 'DRAFT' | 'PENDING';
|
||||||
|
tags: string[];
|
||||||
|
customFields: Record<string, string>;
|
||||||
|
isActive: boolean;
|
||||||
|
category: Category;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
id: string;
|
id: string;
|
||||||
appName: string;
|
appName: string;
|
||||||
defaultCurrency: string;
|
defaultCurrency: string;
|
||||||
|
integrationsEnabled?: boolean;
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
allowedProofTypes: string[];
|
allowedProofTypes: string[];
|
||||||
uiPreferences: Record<string, string | number | boolean>;
|
uiPreferences: Record<string, string | number | boolean>;
|
||||||
@@ -90,3 +187,96 @@ export interface ReportPreferences {
|
|||||||
sendToEmail: string | null;
|
sendToEmail: string | null;
|
||||||
categoryIds: string[];
|
categoryIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListIntegrationSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
hasToken: boolean;
|
||||||
|
authMode: 'bearer' | 'x-api-token' | 'both';
|
||||||
|
ownerId: string | null;
|
||||||
|
defaultListId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListSummary {
|
||||||
|
period?: string;
|
||||||
|
total?: number;
|
||||||
|
amount?: number;
|
||||||
|
count?: number;
|
||||||
|
records?: number;
|
||||||
|
meta?: { total_amount?: number; total_count?: number; returned_count?: number; [key: string]: unknown };
|
||||||
|
lists?: Array<{ id?: string | number; title?: string; name?: string; total_amount?: number; total?: number; expense_count?: number; count?: number }>;
|
||||||
|
totals?: Array<{ list_id?: string | number; listId?: string | number; name?: string; total?: number; amount?: number; count?: number }>;
|
||||||
|
aggregates?: Array<{ list_id?: string | number; listId?: string | number; name?: string; total?: number; amount?: number; count?: number }>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListRef {
|
||||||
|
id: string | number;
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
created_at?: string;
|
||||||
|
categories?: string[];
|
||||||
|
owner_id?: string | number;
|
||||||
|
ownerId?: string | number;
|
||||||
|
owner?: { id?: string | number; username?: string; name?: string; fullName?: string; email?: string };
|
||||||
|
is_active?: boolean;
|
||||||
|
is_archived?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListExpenseItem {
|
||||||
|
id?: string | number;
|
||||||
|
expense_id?: string | number;
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
amount?: number;
|
||||||
|
total?: number;
|
||||||
|
created_at?: string;
|
||||||
|
added_at?: string;
|
||||||
|
expense_date?: string;
|
||||||
|
receipt_filename?: string;
|
||||||
|
list?: ShoppingListRef;
|
||||||
|
owner?: { id?: string | number; username?: string; name?: string; fullName?: string; email?: string };
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface AdminSystemInfo {
|
||||||
|
appName: string;
|
||||||
|
suiteVersion: string;
|
||||||
|
apiVersion: string;
|
||||||
|
webVersion: string;
|
||||||
|
nodeVersion: string;
|
||||||
|
environment: string;
|
||||||
|
database: string;
|
||||||
|
uploadDir: string;
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
smtpConfigured: boolean;
|
||||||
|
counters: {
|
||||||
|
users: number;
|
||||||
|
expenses: number;
|
||||||
|
categories: number;
|
||||||
|
merchants: number;
|
||||||
|
budgets: number;
|
||||||
|
recurring: number;
|
||||||
|
shoppingIntegrations: number;
|
||||||
|
};
|
||||||
|
sources: {
|
||||||
|
appRepository: string;
|
||||||
|
shoppingListRepository: string;
|
||||||
|
apiBasePath: string;
|
||||||
|
};
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListTemplate {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListPeriodImportResponse {
|
||||||
|
item: Expense;
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<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 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>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="text-secondary">{{ placeholder() }}</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="text-secondary small">{{ selectedItems().length ? selectedItems().length : '' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (open()) {
|
||||||
|
<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 ec-picker-dot" [style.background]="item.color"> </span>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</span>
|
||||||
|
@if (isSelected(item.id)) {
|
||||||
|
<span class="badge text-bg-success">{{ ui.t('common.selected') }}</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
} @empty {
|
||||||
|
<div class="dropdown-item text-secondary">{{ ui.t('common.noCategories') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -1,56 +1,19 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
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';
|
import type { Category } from '../models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-category-picker',
|
selector: 'app-category-picker',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
templateUrl: './category-picker.component.html'
|
||||||
<div class="dropdown w-100">
|
|
||||||
<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>
|
|
||||||
{{ item.name }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<span class="text-secondary">{{ placeholder() }}</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span class="text-secondary small">{{ selectedItems().length ? selectedItems().length : '' }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (open()) {
|
|
||||||
<div class="dropdown-menu show w-100 p-2 shadow-sm">
|
|
||||||
<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>{{ item.name }}</span>
|
|
||||||
</span>
|
|
||||||
@if (isSelected(item.id)) {
|
|
||||||
<span class="badge text-bg-success">OK</span>
|
|
||||||
}
|
|
||||||
</label>
|
|
||||||
} @empty {
|
|
||||||
<div class="dropdown-item text-secondary">Brak kategorii.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class CategoryPickerComponent {
|
export class CategoryPickerComponent {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
readonly items = input<Category[]>([]);
|
readonly items = input<Category[]>([]);
|
||||||
readonly selectedIds = input<string[]>([]);
|
readonly selectedIds = input<string[]>([]);
|
||||||
readonly placeholder = input('Wybierz kategorie');
|
readonly placeholder = input('');
|
||||||
readonly changed = output<string[]>();
|
readonly changed = output<string[]>();
|
||||||
|
|
||||||
readonly open = signal(false);
|
readonly open = signal(false);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user