first commit
This commit is contained in:
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
APP_PORT=4000
|
||||
WEB_PORT=4200
|
||||
JWT_SECRET=super-secret-change-me
|
||||
JWT_EXPIRES_IN=7d
|
||||
DB_TYPE=postgres
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=expense_control
|
||||
DB_USER=expense_app
|
||||
DB_PASSWORD=expense_app
|
||||
DB_SYNC=true
|
||||
DB_LOGGING=false
|
||||
APP_NAME=Expense Control
|
||||
DEFAULT_CURRENCY=PLN
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_UPLOAD_SIZE_MB=10
|
||||
API_CORS_ORIGIN=http://localhost:4200
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.angular/
|
||||
*.log
|
||||
.env
|
||||
api/uploads/
|
||||
api/data/
|
||||
api/dist/
|
||||
web/dist/
|
||||
web/.angular/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Expense Control
|
||||
|
||||
Aplikacja do kontroli wydatkow z interfejsem Angular + Tabler UI i API Express + TypeORM.
|
||||
|
||||
## Zakres
|
||||
- logowanie i rejestracja z mozliwoscia wylaczenia rejestracji przez admina
|
||||
- wydatki z potwierdzeniami: paragon, faktura, notatka, wyciag, inne
|
||||
- zapisani kontrahenci do wyboru przy dodawaniu wydatku
|
||||
- kategorie systemowe i prywatne
|
||||
- dashboard i statystyki z filtrowaniem po datach i kategoriach
|
||||
- raporty email z preferencjami uzytkownika
|
||||
- ustawienia SMTP i test SMTP
|
||||
- panel admina z zarzadzaniem uzytkownikami
|
||||
- Docker Compose dla produkcji i start lokalny bez Dockera
|
||||
|
||||
## Produkcja w Dockerze z reverse proxy
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Architektura produkcyjna:
|
||||
- `reverse-proxy` - publiczny punkt wejscia HTTP
|
||||
- `web` - statyczny frontend Angular za proxy
|
||||
- `api` - backend Express dostepny tylko wewnatrz sieci Dockera
|
||||
- `postgres` - baza danych
|
||||
|
||||
Publiczny adres aplikacji:
|
||||
- frontend + API przez proxy: `http://localhost:8080`
|
||||
- API pod proxy: `http://localhost:8080/api`
|
||||
|
||||
Najwazniejsze zasady:
|
||||
- Docker Compose korzysta z pliku `.env`
|
||||
- wartosci nie sa wpisane na sztywno w `docker-compose.yml`
|
||||
- `api` i `postgres` dostaja konfiguracje przez `env_file`
|
||||
- tylko `reverse-proxy` wystawia port na hosta
|
||||
|
||||
Przykladowe klucze w `.env`:
|
||||
- `PUBLIC_HTTP_PORT` - port reverse proxy na hoście
|
||||
- `API_CORS_ORIGIN` - publiczny adres aplikacji, np. `https://twoja-domena.pl`
|
||||
- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`
|
||||
- `JWT_SECRET`
|
||||
|
||||
## Dewelopersko lokalnie bez reverse proxy
|
||||
```bash
|
||||
chmod +x start_dev.sh
|
||||
./start_dev.sh
|
||||
```
|
||||
|
||||
Tryb dev:
|
||||
- backend uruchamia sie lokalnie
|
||||
- Angular dev server uruchamia sie lokalnie
|
||||
- `/api` i `/uploads` ida przez proxy Angulara z `web/proxy.conf.json`
|
||||
- reverse proxy z Dockera nie jest potrzebne
|
||||
|
||||
Domyslne dane dev:
|
||||
- email: `admin@local.dev`
|
||||
- haslo: `Admin123!`
|
||||
|
||||
## Reczne scenariusze testowe
|
||||
1. Zaloguj sie jako admin.
|
||||
2. Dodaj kontrahenta w `Kontrahenci`.
|
||||
3. Dodaj wydatek i wybierz kontrahenta z listy.
|
||||
4. Dodaj obraz lub PDF jako potwierdzenie.
|
||||
5. Otworz `Statystyki` i zmien okres.
|
||||
6. Otworz `Raporty`, zapisz preferencje i sprawdz podglad.
|
||||
7. Otworz `Admin`, skonfiguruj SMTP i wykonaj test SMTP.
|
||||
8. Zmien role lub status jednego z uzytkownikow.
|
||||
|
||||
## Struktura
|
||||
- `api/` - backend Express + TypeORM
|
||||
- `web/` - frontend Angular + Tabler UI
|
||||
- `reverse-proxy/` - konfiguracja publicznego reverse proxy dla produkcji
|
||||
- `docker-compose.yml` - stack produkcyjny
|
||||
- `start_dev.sh` - lokalny start bez Dockera
|
||||
19
api/.env.example
Normal file
19
api/.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
NODE_ENV=production
|
||||
APP_PORT=4000
|
||||
JWT_SECRET=super-secret-change-me
|
||||
JWT_EXPIRES_IN=7d
|
||||
DB_TYPE=postgres
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=expense_control
|
||||
DB_USER=expense_app
|
||||
DB_PASSWORD=expense_app
|
||||
DB_SYNC=true
|
||||
DB_LOGGING=false
|
||||
APP_NAME=Expense Control
|
||||
DEFAULT_CURRENCY=PLN
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
UPLOAD_DIR=/app/uploads
|
||||
MAX_UPLOAD_SIZE_MB=10
|
||||
API_CORS_ORIGIN=http://localhost:8080
|
||||
17
api/Dockerfile
Normal file
17
api/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --workspaces=false
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --workspaces=false
|
||||
COPY --from=build /app/dist ./dist
|
||||
RUN mkdir -p /app/uploads
|
||||
EXPOSE 4000
|
||||
CMD ["node", "dist/server.js"]
|
||||
4363
api/package-lock.json
generated
Normal file
4363
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
api/package.json
Normal file
40
api/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "expense-control-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch --import tsx src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.1",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sql.js": "^1.13.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.8.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
24
api/src/app.ts
Normal file
24
api/src/app.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import path from 'node:path';
|
||||
import { env } from './config/env.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { apiRouter } from './routes/index.js';
|
||||
export const createApp = () => {
|
||||
const app = express();
|
||||
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
app.use(helmet({ crossOriginResourcePolicy: false }));
|
||||
app.use(cors({ origin: env.API_CORS_ORIGIN, credentials: true }));
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
app.use('/api', apiRouter);
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
};
|
||||
37
api/src/config/data-source.ts
Normal file
37
api/src/config/data-source.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'reflect-metadata';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { env } from './env.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { Merchant } from '../entities/Merchant.js';
|
||||
import { Proof } from '../entities/Proof.js';
|
||||
import { User } from '../entities/User.js';
|
||||
|
||||
const entities = [User, Category, Expense, Proof, AppSetting, Merchant];
|
||||
const baseOptions = { entities, synchronize: env.DB_SYNC, logging: env.DB_LOGGING };
|
||||
|
||||
if (env.DB_TYPE === 'sqlite') {
|
||||
const file = path.resolve(env.DB_PATH);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
}
|
||||
|
||||
export const AppDataSource =
|
||||
env.DB_TYPE === 'postgres'
|
||||
? new DataSource({
|
||||
type: 'postgres',
|
||||
host: env.DB_HOST,
|
||||
port: env.DB_PORT,
|
||||
username: env.DB_USER,
|
||||
password: env.DB_PASSWORD,
|
||||
database: env.DB_NAME,
|
||||
...baseOptions
|
||||
})
|
||||
: new DataSource({
|
||||
type: 'sqljs',
|
||||
location: path.resolve(env.DB_PATH),
|
||||
autoSave: true,
|
||||
...baseOptions
|
||||
});
|
||||
33
api/src/config/env.ts
Normal file
33
api/src/config/env.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
type DbType = 'postgres' | 'sqlite';
|
||||
const toNumber = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
const toBoolean = (value: string | undefined, fallback: boolean) => {
|
||||
if (value === undefined) return fallback;
|
||||
return value === 'true' || value === '1';
|
||||
};
|
||||
export const env = {
|
||||
NODE_ENV: process.env.NODE_ENV ?? 'development',
|
||||
APP_PORT: toNumber(process.env.APP_PORT, 4000),
|
||||
JWT_SECRET: process.env.JWT_SECRET ?? 'dev-secret-key',
|
||||
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||
DB_TYPE: (process.env.DB_TYPE as DbType | undefined) ?? 'sqlite',
|
||||
DB_HOST: process.env.DB_HOST ?? 'localhost',
|
||||
DB_PORT: toNumber(process.env.DB_PORT, 5432),
|
||||
DB_NAME: process.env.DB_NAME ?? 'expense_control',
|
||||
DB_USER: process.env.DB_USER ?? 'expense_app',
|
||||
DB_PASSWORD: process.env.DB_PASSWORD ?? 'expense_app',
|
||||
DB_PATH: process.env.DB_PATH ?? './data/dev.sqlite',
|
||||
DB_SYNC: toBoolean(process.env.DB_SYNC, true),
|
||||
DB_LOGGING: toBoolean(process.env.DB_LOGGING, false),
|
||||
APP_NAME: process.env.APP_NAME ?? 'Expense Control',
|
||||
DEFAULT_CURRENCY: process.env.DEFAULT_CURRENCY ?? 'PLN',
|
||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD ?? 'Admin123!',
|
||||
UPLOAD_DIR: process.env.UPLOAD_DIR ?? './uploads',
|
||||
MAX_UPLOAD_SIZE_MB: toNumber(process.env.MAX_UPLOAD_SIZE_MB, 10),
|
||||
API_CORS_ORIGIN: process.env.API_CORS_ORIGIN ?? 'http://localhost:4200'
|
||||
};
|
||||
147
api/src/controllers/admin.controller.ts
Normal file
147
api/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Response } from 'express';
|
||||
import { createRequire } from 'node:module';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import { sanitizeUser } from '../services/auth.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const settingsSchema = z.object({
|
||||
appName: z.string().min(2).max(120),
|
||||
defaultCurrency: z.string().min(3).max(8),
|
||||
registrationEnabled: z.boolean(),
|
||||
allowedProofTypes: z.array(z.string().min(2).max(30)).min(1),
|
||||
uiPreferences: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
||||
smtpEnabled: z.boolean(),
|
||||
smtpHost: z.string().max(120).nullable().optional(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpSecure: z.boolean(),
|
||||
smtpUser: z.string().max(160).nullable().optional(),
|
||||
smtpPassword: z.string().max(255).nullable().optional(),
|
||||
smtpFromName: z.string().max(120).nullable().optional(),
|
||||
smtpFromEmail: z.string().max(160).nullable().optional()
|
||||
});
|
||||
|
||||
const userUpdateSchema = z.object({
|
||||
role: z.enum(['ADMIN', 'USER']).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
defaultCurrency: z.string().min(3).max(8).optional()
|
||||
});
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
|
||||
const sanitizeSettings = (item: AppSetting) => ({
|
||||
id: item.id,
|
||||
appName: item.appName,
|
||||
defaultCurrency: item.defaultCurrency,
|
||||
registrationEnabled: item.registrationEnabled,
|
||||
allowedProofTypes: item.allowedProofTypes,
|
||||
uiPreferences: item.uiPreferences,
|
||||
smtpEnabled: item.smtpEnabled,
|
||||
smtpHost: item.smtpHost,
|
||||
smtpPort: item.smtpPort,
|
||||
smtpSecure: item.smtpSecure,
|
||||
smtpUser: item.smtpUser,
|
||||
smtpPassword: item.smtpPassword,
|
||||
smtpFromName: item.smtpFromName,
|
||||
smtpFromEmail: item.smtpFromEmail,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
});
|
||||
|
||||
const getSettingsEntity = async () => {
|
||||
const [item] = await settingsRepo().find({ take: 1, order: { createdAt: 'ASC' } });
|
||||
return item ?? null;
|
||||
};
|
||||
|
||||
export const getSettings = async (_req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await getSettingsEntity();
|
||||
if (!item) return res.status(404).json({ message: 'Settings were not initialized' });
|
||||
return res.json({ item: sanitizeSettings(item) });
|
||||
};
|
||||
|
||||
export const updateSettings = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = settingsSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid settings payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const item = await getSettingsEntity();
|
||||
if (!item) return res.status(404).json({ message: 'Settings were not initialized' });
|
||||
|
||||
Object.assign(item, {
|
||||
appName: parsed.data.appName,
|
||||
defaultCurrency: parsed.data.defaultCurrency,
|
||||
registrationEnabled: parsed.data.registrationEnabled,
|
||||
allowedProofTypes: parsed.data.allowedProofTypes,
|
||||
uiPreferences: parsed.data.uiPreferences,
|
||||
smtpEnabled: parsed.data.smtpEnabled,
|
||||
smtpHost: parsed.data.smtpHost ?? null,
|
||||
smtpPort: parsed.data.smtpPort,
|
||||
smtpSecure: parsed.data.smtpSecure,
|
||||
smtpUser: parsed.data.smtpUser ?? null,
|
||||
smtpPassword: parsed.data.smtpPassword ?? null,
|
||||
smtpFromName: parsed.data.smtpFromName ?? null,
|
||||
smtpFromEmail: parsed.data.smtpFromEmail ?? null
|
||||
});
|
||||
|
||||
await settingsRepo().save(item);
|
||||
return res.json({ item: sanitizeSettings(item) });
|
||||
};
|
||||
|
||||
export const listUsers = async (_req: AuthenticatedRequest, res: Response) => {
|
||||
const items = await userRepo().find({ order: { createdAt: 'DESC' } });
|
||||
return res.json({ items: items.map(sanitizeUser) });
|
||||
};
|
||||
|
||||
export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = userUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid user update payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const itemId = String(req.params.id);
|
||||
const item = await userRepo().findOne({ where: { id: itemId } });
|
||||
if (!item) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
if (parsed.data.role) item.role = parsed.data.role;
|
||||
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
||||
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
|
||||
|
||||
await userRepo().save(item);
|
||||
return res.json({ item: sanitizeUser(item) });
|
||||
};
|
||||
|
||||
export const testSmtp = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = z.object({ to: z.email() }).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid test email payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const settings = await getSettingsEntity();
|
||||
if (!settings || !settings.smtpEnabled || !settings.smtpHost || !settings.smtpFromEmail) {
|
||||
return res.status(400).json({ message: 'SMTP is not configured' });
|
||||
}
|
||||
|
||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
||||
const transport = nodemailer.createTransport({
|
||||
host: settings.smtpHost,
|
||||
port: settings.smtpPort,
|
||||
secure: settings.smtpSecure,
|
||||
auth: settings.smtpUser ? { user: settings.smtpUser, pass: settings.smtpPassword ?? '' } : undefined
|
||||
});
|
||||
|
||||
await transport.sendMail({
|
||||
from: settings.smtpFromName
|
||||
? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>`
|
||||
: settings.smtpFromEmail,
|
||||
to: parsed.data.to,
|
||||
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>`
|
||||
});
|
||||
|
||||
return res.json({ message: 'SMTP test message was sent' });
|
||||
};
|
||||
88
api/src/controllers/auth.controller.ts
Normal file
88
api/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import {
|
||||
comparePassword,
|
||||
createUser,
|
||||
findUserByEmail,
|
||||
sanitizeUser,
|
||||
signToken
|
||||
} from '../services/auth.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const registerSchema = z.object({
|
||||
fullName: z.string().min(2).max(120),
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(100)
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(100)
|
||||
});
|
||||
|
||||
|
||||
export const publicConfig = async (_req: Request, res: Response) => {
|
||||
const settings = await AppDataSource.getRepository(AppSetting).find({
|
||||
take: 1,
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
appName: settings[0]?.appName ?? 'Expense Control',
|
||||
registrationEnabled: settings[0]?.registrationEnabled ?? true
|
||||
});
|
||||
};
|
||||
|
||||
export const register = async (req: Request, res: Response) => {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid registration payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const settings = await AppDataSource.getRepository(AppSetting).find({
|
||||
take: 1,
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
|
||||
if (settings[0] && !settings[0].registrationEnabled) {
|
||||
return res.status(403).json({ message: 'Registration is disabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await createUser(parsed.data);
|
||||
const token = signToken({ id: user.id, email: user.email, role: user.role });
|
||||
return res.status(201).json({ token, user: sanitizeUser(user) });
|
||||
} catch (error) {
|
||||
return res.status(409).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (req: Request, res: Response) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid login payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await findUserByEmail(parsed.data.email);
|
||||
if (!user || !(await comparePassword(parsed.data.password, user.passwordHash))) {
|
||||
return res.status(401).json({ message: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.status(403).json({ message: 'Your account is inactive' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
token: signToken({ id: user.id, email: user.email, role: user.role }),
|
||||
user: sanitizeUser(user)
|
||||
});
|
||||
};
|
||||
|
||||
export const me = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await AppDataSource.getRepository(User).findOne({ where: { id: req.user?.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
return res.json({ user: sanitizeUser(user) });
|
||||
};
|
||||
76
api/src/controllers/category.controller.ts
Normal file
76
api/src/controllers/category.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2).max(80),
|
||||
color: z.string().min(4).max(32).default('#111827')
|
||||
});
|
||||
|
||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
|
||||
const serialize = (category: Category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
color: category.color,
|
||||
isSystem: category.isSystem,
|
||||
ownerId: category.user?.id ?? null
|
||||
});
|
||||
|
||||
export const listCategories = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const items = await categoryRepo()
|
||||
.createQueryBuilder('category')
|
||||
.leftJoinAndSelect('category.user', 'user')
|
||||
.where('category.isSystem = :isSystem', { isSystem: true })
|
||||
.orWhere('user.id = :userId', { userId: req.user?.id })
|
||||
.orderBy('category.name', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return res.json({ items: items.map(serialize) });
|
||||
};
|
||||
|
||||
export const createCategory = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid category payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOneOrFail({ where: { id: req.user!.id } });
|
||||
const item = await categoryRepo().save(categoryRepo().create({ ...parsed.data, isSystem: false, user }));
|
||||
return res.status(201).json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const updateCategory = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid category payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const itemId = String(req.params.id);
|
||||
const item = await categoryRepo().findOne({ where: { id: itemId }, relations: { user: true } });
|
||||
if (!item) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
const canEdit = req.user?.role === 'ADMIN' || (!item.isSystem && item.user?.id === req.user?.id);
|
||||
if (!canEdit) return res.status(403).json({ message: 'You cannot edit this category' });
|
||||
|
||||
item.name = parsed.data.name;
|
||||
item.color = parsed.data.color;
|
||||
await categoryRepo().save(item);
|
||||
return res.json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const deleteCategory = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const itemId = String(req.params.id);
|
||||
const item = await categoryRepo().findOne({ where: { id: itemId }, relations: { user: true } });
|
||||
if (!item) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
const canDelete = req.user?.role === 'ADMIN' || (!item.isSystem && item.user?.id === req.user?.id);
|
||||
if (!canDelete) return res.status(403).json({ message: 'You cannot delete this category' });
|
||||
|
||||
await categoryRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
263
api/src/controllers/expense.controller.ts
Normal file
263
api/src/controllers/expense.controller.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { Proof } from '../entities/Proof.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
import { serializeProof } from '../utils/http.js';
|
||||
|
||||
const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']).nullable().optional();
|
||||
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
|
||||
|
||||
const createExpenseSchema = z.object({
|
||||
title: z.string().min(2).max(140),
|
||||
description: z.string().max(1000).nullable().optional(),
|
||||
amount: z.coerce.number().positive(),
|
||||
expenseDate: z.string().min(10).max(10),
|
||||
categoryId: z.string().uuid(),
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
paymentMethod: paymentMethodSchema,
|
||||
currency: z.string().min(3).max(8).default('PLN'),
|
||||
proofType: proofTypeSchema.optional(),
|
||||
proofLabel: z.string().max(150).nullable().optional(),
|
||||
proofNote: z.string().max(1000).nullable().optional()
|
||||
});
|
||||
|
||||
const updateExpenseSchema = z.object({
|
||||
title: z.string().min(2).max(140),
|
||||
description: z.string().max(1000).nullable().optional(),
|
||||
amount: z.coerce.number().positive(),
|
||||
expenseDate: z.string().min(10).max(10),
|
||||
categoryId: z.string().uuid(),
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
paymentMethod: paymentMethodSchema,
|
||||
currency: z.string().min(3).max(8).default('PLN')
|
||||
});
|
||||
|
||||
const addProofSchema = z.object({
|
||||
type: proofTypeSchema,
|
||||
label: z.string().max(150).nullable().optional(),
|
||||
note: z.string().max(1000).nullable().optional()
|
||||
});
|
||||
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const proofRepo = () => AppDataSource.getRepository(Proof);
|
||||
|
||||
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,
|
||||
possibleDuplicate: expense.possibleDuplicate,
|
||||
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?.map(serializeProof) ?? [],
|
||||
createdAt: expense.createdAt,
|
||||
updatedAt: expense.updatedAt
|
||||
});
|
||||
|
||||
const removeUploadedFile = (filename?: string) => {
|
||||
if (!filename) return;
|
||||
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 items = await expenseRepo().find({
|
||||
where: { user: { id: userId }, expenseDate },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 5
|
||||
});
|
||||
|
||||
const merchantKey = merchant?.trim().toLowerCase();
|
||||
return items.some(
|
||||
(item) =>
|
||||
Math.abs(item.amount - amount) < 0.001 &&
|
||||
((merchantKey && item.merchant?.trim().toLowerCase() === merchantKey) || !merchantKey)
|
||||
);
|
||||
};
|
||||
|
||||
export const listExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const startDate = typeof req.query.startDate === 'string' ? req.query.startDate : undefined;
|
||||
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : 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 items = await expenseRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
relations: { category: { user: true }, proofs: true, user: true },
|
||||
order: { expenseDate: 'DESC', createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
if (startDate && item.expenseDate < startDate) return false;
|
||||
if (endDate && item.expenseDate > endDate) return false;
|
||||
if (categoryId && item.category.id !== categoryId) return false;
|
||||
if (search) {
|
||||
const haystack = [item.title, item.description ?? '', item.merchant ?? ''].join(' ').toLowerCase();
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return res.json({ items: filtered.map(serializeExpense) });
|
||||
};
|
||||
|
||||
export const createExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = createExpenseSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
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 (!user || !category) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(404).json({ message: 'Category not found' });
|
||||
}
|
||||
|
||||
const proofs: Proof[] = [];
|
||||
if (req.file || parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType) {
|
||||
proofs.push(
|
||||
proofRepo().create({
|
||||
type: parsed.data.proofType ?? 'OTHER',
|
||||
label: parsed.data.proofLabel ?? req.file?.originalname ?? 'Attachment',
|
||||
note: parsed.data.proofNote ?? null,
|
||||
originalName: req.file?.originalname ?? null,
|
||||
storedName: req.file?.filename ?? null,
|
||||
mimeType: req.file?.mimetype ?? null,
|
||||
fileSize: req.file?.size ?? null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const item = await expenseRepo().save(
|
||||
expenseRepo().create({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
amount: parsed.data.amount,
|
||||
expenseDate: parsed.data.expenseDate,
|
||||
merchant: parsed.data.merchant ?? null,
|
||||
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||
currency: parsed.data.currency,
|
||||
possibleDuplicate: await isDuplicate(req.user!.id, parsed.data.amount, parsed.data.expenseDate, parsed.data.merchant),
|
||||
user,
|
||||
category,
|
||||
proofs
|
||||
})
|
||||
);
|
||||
|
||||
const fullItem = await expenseRepo().findOneOrFail({
|
||||
where: { id: item.id },
|
||||
relations: { category: { user: true }, proofs: true, user: true }
|
||||
});
|
||||
|
||||
return res.status(201).json({ item: serializeExpense(fullItem) });
|
||||
};
|
||||
|
||||
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = updateExpenseSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
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) 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 edit this expense' });
|
||||
}
|
||||
|
||||
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' });
|
||||
|
||||
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.category = category;
|
||||
|
||||
await expenseRepo().save(item);
|
||||
return res.json({ item: serializeExpense(item) });
|
||||
};
|
||||
|
||||
export const deleteExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await expenseRepo().findOne({
|
||||
where: { id: String(req.params.id) },
|
||||
relations: { 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 delete this expense' });
|
||||
}
|
||||
|
||||
for (const proof of item.proofs ?? []) removeUploadedFile(proof.storedName ?? undefined);
|
||||
await expenseRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = addProofSchema.safeParse(req.body);
|
||||
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) },
|
||||
relations: { user: true, proofs: true, category: { user: true } }
|
||||
});
|
||||
if (!item) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(404).json({ message: 'Expense not found' });
|
||||
}
|
||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||
}
|
||||
|
||||
const proof = await proofRepo().save(
|
||||
proofRepo().create({
|
||||
type: parsed.data.type,
|
||||
label: parsed.data.label ?? req.file?.originalname ?? 'Attachment',
|
||||
note: parsed.data.note ?? null,
|
||||
originalName: req.file?.originalname ?? null,
|
||||
storedName: req.file?.filename ?? null,
|
||||
mimeType: req.file?.mimetype ?? null,
|
||||
fileSize: req.file?.size ?? null,
|
||||
expense: item
|
||||
})
|
||||
);
|
||||
|
||||
item.proofs = [...(item.proofs ?? []), proof];
|
||||
return res.status(201).json({ proof: serializeProof(proof), expense: serializeExpense(item) });
|
||||
};
|
||||
75
api/src/controllers/merchant.controller.ts
Normal file
75
api/src/controllers/merchant.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Merchant } from '../entities/Merchant.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2).max(120),
|
||||
kind: z.enum(['MERCHANT', 'SERVICE_PROVIDER', 'OTHER']).default('MERCHANT'),
|
||||
notes: z.string().max(1000).nullable().optional(),
|
||||
isActive: z.boolean().default(true)
|
||||
});
|
||||
|
||||
const merchantRepo = () => AppDataSource.getRepository(Merchant);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
|
||||
const serialize = (item: Merchant) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind,
|
||||
notes: item.notes,
|
||||
isActive: item.isActive,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
});
|
||||
|
||||
export const listMerchants = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const items = await merchantRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
order: { name: 'ASC' }
|
||||
});
|
||||
return res.json({ items: items.map(serialize) });
|
||||
};
|
||||
|
||||
export const createMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid partner payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOneOrFail({ where: { id: req.user!.id } });
|
||||
const item = await merchantRepo().save(merchantRepo().create({ ...parsed.data, user }));
|
||||
return res.status(201).json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const updateMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid partner payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const item = await merchantRepo().findOne({
|
||||
where: { id: String(req.params.id), user: { id: req.user!.id } }
|
||||
});
|
||||
if (!item) return res.status(404).json({ message: 'Partner not found' });
|
||||
|
||||
item.name = parsed.data.name;
|
||||
item.kind = parsed.data.kind;
|
||||
item.notes = parsed.data.notes ?? null;
|
||||
item.isActive = parsed.data.isActive;
|
||||
await merchantRepo().save(item);
|
||||
|
||||
return res.json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const deleteMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await merchantRepo().findOne({
|
||||
where: { id: String(req.params.id), user: { id: req.user!.id } }
|
||||
});
|
||||
if (!item) return res.status(404).json({ message: 'Partner not found' });
|
||||
|
||||
await merchantRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
156
api/src/controllers/report.controller.ts
Normal file
156
api/src/controllers/report.controller.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Response } from 'express';
|
||||
import { createRequire } from 'node:module';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
frequency: z.enum(['monthly', 'yearly', 'threshold']),
|
||||
thresholdAmount: z.number().min(0).default(0),
|
||||
sendToEmail: z.email().nullable().optional(),
|
||||
categoryIds: z.array(z.string().uuid()).default([])
|
||||
});
|
||||
|
||||
const previewOverrideSchema = preferencesSchema.partial();
|
||||
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const require = createRequire(import.meta.url);
|
||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||
|
||||
const defaultPrefs = (email: string) => ({
|
||||
enabled: false,
|
||||
frequency: 'monthly' as const,
|
||||
thresholdAmount: 0,
|
||||
sendToEmail: email,
|
||||
categoryIds: [] as string[]
|
||||
});
|
||||
|
||||
const periodRange = (frequency: 'monthly' | 'yearly' | 'threshold') => {
|
||||
const now = new Date();
|
||||
const endDate = now.toISOString().slice(0, 10);
|
||||
|
||||
if (frequency === 'yearly') {
|
||||
return { startDate: `${now.getUTCFullYear()}-01-01`, endDate, bucket: 'month' as const, label: 'Year to date' };
|
||||
}
|
||||
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
return { startDate: start.toISOString().slice(0, 10), endDate, bucket: 'month' as const, label: 'Current month' };
|
||||
};
|
||||
|
||||
const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>) => {
|
||||
const categoryRows = summary.byCategory
|
||||
.slice(0, 6)
|
||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.categoryName}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
const timelineRows = summary.timeline
|
||||
.slice(-6)
|
||||
.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('');
|
||||
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;color:#111827;max-width:760px;margin:0 auto">
|
||||
<h1 style="margin-bottom:8px">${title}</h1>
|
||||
<p style="color:#4b5563;margin-top:0">Total: <strong>${summary.total.toFixed(2)}</strong> | Count: <strong>${summary.count}</strong> | Average: <strong>${summary.average.toFixed(2)}</strong></p>
|
||||
<h2 style="margin-top:32px">Top categories</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${categoryRows || '<tr><td>No data</td></tr>'}</table>
|
||||
<h2 style="margin-top:32px">Timeline</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${timelineRows || '<tr><td>No data</td></tr>'}</table>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export const getPreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
return res.json({ item: user.reportPreferences ?? defaultPrefs(user.email) });
|
||||
};
|
||||
|
||||
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = preferencesSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
user.reportPreferences = parsed.data;
|
||||
await userRepo().save(user);
|
||||
return res.json({ item: user.reportPreferences });
|
||||
};
|
||||
|
||||
export const previewReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
const overridesParsed = previewOverrideSchema.safeParse(req.body ?? {});
|
||||
if (!overridesParsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid report preview payload', issues: overridesParsed.error.issues });
|
||||
}
|
||||
|
||||
const prefs = { ...defaultPrefs(user.email), ...(user.reportPreferences ?? {}), ...overridesParsed.data };
|
||||
const range = periodRange(prefs.frequency ?? 'monthly');
|
||||
const summary = await getStatistics(
|
||||
{
|
||||
userId: user.id,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
categoryIds: prefs.categoryIds?.length ? prefs.categoryIds : undefined
|
||||
},
|
||||
range.bucket
|
||||
);
|
||||
|
||||
return res.json({
|
||||
range,
|
||||
summary,
|
||||
html: buildReportHtml(`${range.label} report`, summary)
|
||||
});
|
||||
};
|
||||
|
||||
export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
const settings = await settingsRepo().find({ take: 1, order: { createdAt: 'ASC' } });
|
||||
const appSettings = settings[0];
|
||||
if (!appSettings || !appSettings.smtpEnabled || !appSettings.smtpHost || !appSettings.smtpFromEmail) {
|
||||
return res.status(400).json({ message: 'SMTP is not configured' });
|
||||
}
|
||||
|
||||
const prefs = user.reportPreferences ?? defaultPrefs(user.email);
|
||||
const range = periodRange(prefs.frequency ?? 'monthly');
|
||||
const summary = await getStatistics(
|
||||
{
|
||||
userId: user.id,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
categoryIds: prefs.categoryIds?.length ? prefs.categoryIds : undefined
|
||||
},
|
||||
range.bucket
|
||||
);
|
||||
|
||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
||||
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;
|
||||
await transport.sendMail({
|
||||
from: appSettings.smtpFromName
|
||||
? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>`
|
||||
: appSettings.smtpFromEmail,
|
||||
to,
|
||||
subject: `${appSettings.appName} - ${range.label} report`,
|
||||
html: buildReportHtml(`${range.label} report`, summary)
|
||||
});
|
||||
|
||||
return res.json({ message: 'Report email was sent', sentTo: to });
|
||||
};
|
||||
31
api/src/controllers/statistics.controller.ts
Normal file
31
api/src/controllers/statistics.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const querySchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
categoryIds: z.string().optional(),
|
||||
bucket: z.enum(['month', 'quarter', 'year']).optional()
|
||||
});
|
||||
|
||||
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
|
||||
return res.json(
|
||||
await getStatistics(
|
||||
{
|
||||
userId: req.user!.id,
|
||||
startDate: parsed.data.startDate,
|
||||
endDate: parsed.data.endDate,
|
||||
categoryIds
|
||||
},
|
||||
parsed.data.bucket ?? 'month'
|
||||
)
|
||||
);
|
||||
};
|
||||
52
api/src/entities/AppSetting.ts
Normal file
52
api/src/entities/AppSetting.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('app_settings')
|
||||
export class AppSetting {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, default: 'Expense Control' })
|
||||
appName!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
||||
defaultCurrency!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
registrationEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
allowedProofTypes!: string[];
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
uiPreferences!: Record<string, string | number | boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
smtpEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
smtpHost!: string | null;
|
||||
|
||||
@Column({ type: 'int', default: 587 })
|
||||
smtpPort!: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
smtpSecure!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||
smtpUser!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
smtpPassword!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
smtpFromName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||
smtpFromEmail!: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
27
api/src/entities/Category.ts
Normal file
27
api/src/entities/Category.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Expense } from './Expense.js';
|
||||
import { User } from './User.js';
|
||||
|
||||
@Entity('categories')
|
||||
export class Category {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: '#0d6efd' })
|
||||
color!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isSystem!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.categories, { nullable: true, onDelete: 'CASCADE' })
|
||||
user!: User | null;
|
||||
|
||||
@OneToMany(() => Expense, (expense) => expense.category)
|
||||
expenses!: Expense[];
|
||||
}
|
||||
50
api/src/entities/Expense.ts
Normal file
50
api/src/entities/Expense.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Category } from './Category.js';
|
||||
import { Proof } from './Proof.js';
|
||||
import { User } from './User.js';
|
||||
import { decimalTransformer } from '../utils/decimal.js';
|
||||
|
||||
@Entity('expenses')
|
||||
export class Expense {
|
||||
@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: 'date' })
|
||||
expenseDate!: string;
|
||||
|
||||
@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: 'boolean', default: false })
|
||||
possibleDuplicate!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.expenses, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Category, (category) => category.expenses, { eager: true, onDelete: 'RESTRICT' })
|
||||
category!: Category;
|
||||
|
||||
@OneToMany(() => Proof, (proof) => proof.expense, { eager: true, cascade: true })
|
||||
proofs!: Proof[];
|
||||
}
|
||||
29
api/src/entities/Merchant.ts
Normal file
29
api/src/entities/Merchant.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { User } from './User.js';
|
||||
|
||||
@Entity('merchants')
|
||||
export class Merchant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, default: 'MERCHANT' })
|
||||
kind!: 'MERCHANT' | 'SERVICE_PROVIDER' | 'OTHER';
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes!: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
}
|
||||
37
api/src/entities/Proof.ts
Normal file
37
api/src/entities/Proof.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Expense } from './Expense.js';
|
||||
|
||||
export type ProofType = 'RECEIPT' | 'INVOICE' | 'NOTE' | 'BANK_STATEMENT' | 'OTHER';
|
||||
|
||||
@Entity('proofs')
|
||||
export class Proof {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, default: 'OTHER' })
|
||||
type!: ProofType;
|
||||
|
||||
@Column({ type: 'varchar', length: 150, nullable: true })
|
||||
label!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
note!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
originalName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
storedName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
mimeType!: string | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
fileSize!: number | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => Expense, (expense) => expense.proofs, { onDelete: 'CASCADE' })
|
||||
expense!: Expense;
|
||||
}
|
||||
47
api/src/entities/User.ts
Normal file
47
api/src/entities/User.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Category } from './Category.js';
|
||||
import { Expense } from './Expense.js';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'USER';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
fullName!: string;
|
||||
|
||||
@Column({ type: 'varchar', unique: true, length: 160 })
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
passwordHash!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'USER' })
|
||||
role!: UserRole;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
||||
defaultCurrency!: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
reportPreferences!: {
|
||||
enabled?: boolean;
|
||||
frequency?: 'monthly' | 'yearly' | 'threshold';
|
||||
thresholdAmount?: number;
|
||||
sendToEmail?: string | null;
|
||||
categoryIds?: string[];
|
||||
} | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@OneToMany(() => Category, (category) => category.user)
|
||||
categories!: Category[];
|
||||
|
||||
@OneToMany(() => Expense, (expense) => expense.user)
|
||||
expenses!: Expense[];
|
||||
}
|
||||
18
api/src/middleware/auth.ts
Normal file
18
api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { env } from '../config/env.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) return res.status(401).json({ message: 'Brak tokenu autoryzacji' });
|
||||
try {
|
||||
req.user = jwt.verify(header.replace('Bearer ', ''), env.JWT_SECRET) as { id: string; email: string; role: 'ADMIN' | 'USER' };
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ message: 'Nieprawidlowy token' });
|
||||
}
|
||||
};
|
||||
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' });
|
||||
return next();
|
||||
};
|
||||
20
api/src/middleware/error-handler.ts
Normal file
20
api/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
|
||||
export const notFoundHandler = (_req: Request, res: Response) =>
|
||||
res.status(404).json({ message: 'Resource not found' });
|
||||
|
||||
export const errorHandler = (
|
||||
error: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) => {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ message: 'Attachment is too large' });
|
||||
}
|
||||
|
||||
return res.status(500).json({ message: error.message || 'Internal server error' });
|
||||
};
|
||||
9
api/src/middleware/upload.ts
Normal file
9
api/src/middleware/upload.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import multer from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { env } from '../config/env.js';
|
||||
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
||||
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');
|
||||
18
api/src/routes/admin.routes.ts
Normal file
18
api/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getSettings,
|
||||
listUsers,
|
||||
testSmtp,
|
||||
updateSettings,
|
||||
updateUser
|
||||
} from '../controllers/admin.controller.js';
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
adminRouter.use(requireAuth, requireAdmin);
|
||||
adminRouter.get('/settings', getSettings);
|
||||
adminRouter.put('/settings', updateSettings);
|
||||
adminRouter.get('/users', listUsers);
|
||||
adminRouter.patch('/users/:id', updateUser);
|
||||
adminRouter.post('/test-smtp', testSmtp);
|
||||
8
api/src/routes/auth.routes.ts
Normal file
8
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { login, me, publicConfig, register } from '../controllers/auth.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
export const authRouter = Router();
|
||||
authRouter.post('/register', register);
|
||||
authRouter.post('/login', login);
|
||||
authRouter.get('/config', publicConfig);
|
||||
authRouter.get('/me', requireAuth, me);
|
||||
9
api/src/routes/category.routes.ts
Normal file
9
api/src/routes/category.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { createCategory, deleteCategory, listCategories, updateCategory } from '../controllers/category.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
export const categoryRouter = Router();
|
||||
categoryRouter.use(requireAuth);
|
||||
categoryRouter.get('/', listCategories);
|
||||
categoryRouter.post('/', createCategory);
|
||||
categoryRouter.put('/:id', updateCategory);
|
||||
categoryRouter.delete('/:id', deleteCategory);
|
||||
11
api/src/routes/expense.routes.ts
Normal file
11
api/src/routes/expense.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { addProof, createExpense, deleteExpense, listExpenses, updateExpense } from '../controllers/expense.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { uploadSingleProof } from '../middleware/upload.js';
|
||||
export const expenseRouter = Router();
|
||||
expenseRouter.use(requireAuth);
|
||||
expenseRouter.get('/', listExpenses);
|
||||
expenseRouter.post('/', uploadSingleProof, createExpense);
|
||||
expenseRouter.put('/:id', updateExpense);
|
||||
expenseRouter.delete('/:id', deleteExpense);
|
||||
expenseRouter.post('/:id/proofs', uploadSingleProof, addProof);
|
||||
19
api/src/routes/index.ts
Normal file
19
api/src/routes/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { adminRouter } from './admin.routes.js';
|
||||
import { authRouter } from './auth.routes.js';
|
||||
import { categoryRouter } from './category.routes.js';
|
||||
import { expenseRouter } from './expense.routes.js';
|
||||
import { merchantRouter } from './merchant.routes.js';
|
||||
import { reportRouter } from './report.routes.js';
|
||||
import { statisticsRouter } from './statistics.routes.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
|
||||
apiRouter.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
apiRouter.use('/auth', authRouter);
|
||||
apiRouter.use('/categories', categoryRouter);
|
||||
apiRouter.use('/expenses', expenseRouter);
|
||||
apiRouter.use('/statistics', statisticsRouter);
|
||||
apiRouter.use('/merchants', merchantRouter);
|
||||
apiRouter.use('/reports', reportRouter);
|
||||
apiRouter.use('/admin', adminRouter);
|
||||
16
api/src/routes/merchant.routes.ts
Normal file
16
api/src/routes/merchant.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createMerchant,
|
||||
deleteMerchant,
|
||||
listMerchants,
|
||||
updateMerchant
|
||||
} from '../controllers/merchant.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const merchantRouter = Router();
|
||||
|
||||
merchantRouter.use(requireAuth);
|
||||
merchantRouter.get('/', listMerchants);
|
||||
merchantRouter.post('/', createMerchant);
|
||||
merchantRouter.put('/:id', updateMerchant);
|
||||
merchantRouter.delete('/:id', deleteMerchant);
|
||||
16
api/src/routes/report.routes.ts
Normal file
16
api/src/routes/report.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getPreferences,
|
||||
previewReport,
|
||||
sendReport,
|
||||
updatePreferences
|
||||
} from '../controllers/report.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const reportRouter = Router();
|
||||
|
||||
reportRouter.use(requireAuth);
|
||||
reportRouter.get('/preferences', getPreferences);
|
||||
reportRouter.put('/preferences', updatePreferences);
|
||||
reportRouter.post('/preview', previewReport);
|
||||
reportRouter.post('/send', sendReport);
|
||||
6
api/src/routes/statistics.routes.ts
Normal file
6
api/src/routes/statistics.routes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { getOverview } from '../controllers/statistics.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
export const statisticsRouter = Router();
|
||||
statisticsRouter.use(requireAuth);
|
||||
statisticsRouter.get('/overview', getOverview);
|
||||
10
api/src/server.ts
Normal file
10
api/src/server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AppDataSource } from './config/data-source.js';
|
||||
import { env } from './config/env.js';
|
||||
import { createApp } from './app.js';
|
||||
import { bootstrapData } from './services/seed.service.js';
|
||||
const start = async () => {
|
||||
await AppDataSource.initialize();
|
||||
await bootstrapData();
|
||||
createApp().listen(env.APP_PORT, () => console.log(`API running on http://localhost:${env.APP_PORT}/api`));
|
||||
};
|
||||
start().catch((error) => { console.error('Failed to start API', error); process.exit(1); });
|
||||
60
api/src/services/auth.service.ts
Normal file
60
api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt, { type SignOptions } from 'jsonwebtoken';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { User, type UserRole } from '../entities/User.js';
|
||||
|
||||
const repo = () => AppDataSource.getRepository(User);
|
||||
|
||||
export const sanitizeUser = (user: User) => ({
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
defaultCurrency: user.defaultCurrency,
|
||||
reportPreferences: user.reportPreferences ?? {
|
||||
enabled: false,
|
||||
frequency: 'monthly',
|
||||
thresholdAmount: 0,
|
||||
sendToEmail: user.email,
|
||||
categoryIds: []
|
||||
},
|
||||
createdAt: user.createdAt
|
||||
});
|
||||
|
||||
export const hashPassword = async (password: string) => bcrypt.hash(password, 10);
|
||||
export const comparePassword = async (password: string, hash: string) => bcrypt.compare(password, hash);
|
||||
|
||||
export const signToken = (payload: { id: string; email: string; role: UserRole }) =>
|
||||
jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN as SignOptions['expiresIn'] });
|
||||
|
||||
export const findUserByEmail = (email: string) => repo().findOne({ where: { email: email.toLowerCase() } });
|
||||
|
||||
export const createUser = async (input: {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: UserRole;
|
||||
defaultCurrency?: string;
|
||||
}) => {
|
||||
const existing = await repo().findOne({ where: { email: input.email.toLowerCase() } });
|
||||
if (existing) throw new Error('Email already exists');
|
||||
|
||||
const user = repo().create({
|
||||
fullName: input.fullName,
|
||||
email: input.email.toLowerCase(),
|
||||
passwordHash: await hashPassword(input.password),
|
||||
role: input.role ?? 'USER',
|
||||
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
|
||||
reportPreferences: {
|
||||
enabled: false,
|
||||
frequency: 'monthly',
|
||||
thresholdAmount: 0,
|
||||
sendToEmail: input.email.toLowerCase(),
|
||||
categoryIds: []
|
||||
}
|
||||
});
|
||||
|
||||
return repo().save(user);
|
||||
};
|
||||
60
api/src/services/seed.service.ts
Normal file
60
api/src/services/seed.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { createUser, findUserByEmail } from './auth.service.js';
|
||||
|
||||
const systemCategories = [
|
||||
{ name: 'Rachunki', color: '#b91c1c' },
|
||||
{ name: 'Zakupy', color: '#2563eb' },
|
||||
{ name: 'Transport', color: '#0891b2' },
|
||||
{ name: 'Zdrowie', color: '#16a34a' },
|
||||
{ name: 'Rozrywka', color: '#7c3aed' },
|
||||
{ name: 'Inne', color: '#475569' }
|
||||
];
|
||||
|
||||
export const bootstrapData = async () => {
|
||||
const categoryRepo = AppDataSource.getRepository(Category);
|
||||
const settingsRepo = AppDataSource.getRepository(AppSetting);
|
||||
|
||||
if (!(await findUserByEmail(env.ADMIN_EMAIL))) {
|
||||
await createUser({
|
||||
fullName: 'Master Admin',
|
||||
email: env.ADMIN_EMAIL,
|
||||
password: env.ADMIN_PASSWORD,
|
||||
role: 'ADMIN',
|
||||
defaultCurrency: env.DEFAULT_CURRENCY
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of systemCategories) {
|
||||
const existing = await categoryRepo.findOne({ where: { name: item.name, isSystem: true } });
|
||||
if (!existing) {
|
||||
await categoryRepo.save(categoryRepo.create({ ...item, isSystem: true, user: null }));
|
||||
} else if (existing.color !== item.color) {
|
||||
existing.color = item.color;
|
||||
await categoryRepo.save(existing);
|
||||
}
|
||||
}
|
||||
|
||||
const [settings] = await settingsRepo.find({ take: 1, order: { createdAt: 'ASC' } });
|
||||
if (!settings) {
|
||||
await settingsRepo.save(
|
||||
settingsRepo.create({
|
||||
appName: env.APP_NAME,
|
||||
defaultCurrency: env.DEFAULT_CURRENCY,
|
||||
registrationEnabled: true,
|
||||
allowedProofTypes: ['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER'],
|
||||
uiPreferences: { theme: 'dark', density: 'comfortable', defaultStatsPeriod: 'month' },
|
||||
smtpEnabled: false,
|
||||
smtpHost: null,
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUser: null,
|
||||
smtpPassword: null,
|
||||
smtpFromName: env.APP_NAME,
|
||||
smtpFromEmail: env.ADMIN_EMAIL
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
41
api/src/services/statistics.service.ts
Normal file
41
api/src/services/statistics.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Between, In } from 'typeorm';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
export type StatsFilters = { userId?: string; startDate?: string; endDate?: string; categoryIds?: string[] };
|
||||
export type FlatExpense = { id: string; amount: number; expenseDate: string; categoryId: string; categoryName: string };
|
||||
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 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 aggregateStatistics = (expenses: FlatExpense[], bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||
const total = expenses.reduce((sum, item) => sum + item.amount, 0);
|
||||
const byCategoryMap = new Map<string, { categoryId: string; categoryName: string; total: number; count: number }>();
|
||||
const timelineMap = new Map<string, number>();
|
||||
for (const expense of expenses) {
|
||||
const existing = byCategoryMap.get(expense.categoryId) ?? { categoryId: expense.categoryId, categoryName: expense.categoryName, total: 0, count: 0 };
|
||||
existing.total += expense.amount;
|
||||
existing.count += 1;
|
||||
byCategoryMap.set(expense.categoryId, existing);
|
||||
const bucketLabel = buildBucketLabel(expense.expenseDate, bucket);
|
||||
timelineMap.set(bucketLabel, (timelineMap.get(bucketLabel) ?? 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));
|
||||
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 };
|
||||
};
|
||||
export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||
const repo = AppDataSource.getRepository(Expense);
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filters.userId) where.user = { id: filters.userId };
|
||||
if (filters.startDate && filters.endDate) where.expenseDate = Between(filters.startDate, filters.endDate);
|
||||
else if (filters.startDate) where.expenseDate = Between(filters.startDate, '2999-12-31');
|
||||
else if (filters.endDate) where.expenseDate = Between('1900-01-01', filters.endDate);
|
||||
if (filters.categoryIds?.length) where.category = { id: In(filters.categoryIds) };
|
||||
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);
|
||||
};
|
||||
export const detectPotentialDuplicate = async (input: { userId: string; amount: number; expenseDate: string; merchant?: string | null }) => {
|
||||
const repo = AppDataSource.getRepository(Expense);
|
||||
const candidates = await repo.find({ where: { user: { id: input.userId }, expenseDate: Between(input.expenseDate, input.expenseDate) } });
|
||||
return candidates.some((item) => Math.abs(item.amount - input.amount) < 0.001 && (input.merchant ? item.merchant?.toLowerCase() === input.merchant.toLowerCase() : true));
|
||||
};
|
||||
3
api/src/types/express.ts
Normal file
3
api/src/types/express.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Request } from 'express';
|
||||
export type AuthUser = { id: string; role: 'ADMIN' | 'USER'; email: string };
|
||||
export type AuthenticatedRequest = Request & { user?: AuthUser };
|
||||
4
api/src/utils/decimal.ts
Normal file
4
api/src/utils/decimal.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const decimalTransformer = {
|
||||
to: (value: number | null | undefined) => value == null ? null : value.toFixed(2),
|
||||
from: (value: string | number | null | undefined) => value == null ? 0 : Number(value)
|
||||
};
|
||||
13
api/src/utils/http.ts
Normal file
13
api/src/utils/http.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Proof } from '../entities/Proof.js';
|
||||
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
|
||||
export const serializeProof = (proof: Proof) => ({
|
||||
id: proof.id,
|
||||
type: proof.type,
|
||||
label: proof.label,
|
||||
note: proof.note,
|
||||
originalName: proof.originalName,
|
||||
mimeType: proof.mimeType,
|
||||
fileSize: proof.fileSize,
|
||||
fileUrl: buildProofUrl(proof.storedName),
|
||||
createdAt: proof.createdAt
|
||||
});
|
||||
16
api/tests/statistics.service.test.ts
Normal file
16
api/tests/statistics.service.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aggregateStatistics, buildBucketLabel } from '../src/services/statistics.service.js';
|
||||
describe('statistics.service', () => {
|
||||
it('builds quarter labels', () => { expect(buildBucketLabel('2026-04-05', 'quarter')).toBe('2026-Q2'); });
|
||||
it('aggregates totals', () => {
|
||||
const result = aggregateStatistics([
|
||||
{ id: '1', amount: 100, expenseDate: '2026-04-01', categoryId: 'a', categoryName: 'Rachunki' },
|
||||
{ id: '2', amount: 50, expenseDate: '2026-04-10', categoryId: 'b', categoryName: 'Zakupy' },
|
||||
{ id: '3', amount: 25, expenseDate: '2026-05-01', categoryId: 'a', categoryName: 'Rachunki' }
|
||||
], 'month');
|
||||
expect(result.total).toBe(175);
|
||||
expect(result.count).toBe(3);
|
||||
expect(result.average).toBeCloseTo(58.33, 2);
|
||||
expect(result.topCategory?.categoryName).toBe('Rachunki');
|
||||
});
|
||||
});
|
||||
17
api/tsconfig.json
Normal file
17
api/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
69
docker-compose.yml
Normal file
69
docker-compose.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./api
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
expose:
|
||||
- "4000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:4000/api/health >/dev/null 2>&1 || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
reverse-proxy:
|
||||
image: nginx:1.29-alpine
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PUBLIC_HTTP_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./reverse-proxy/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||
- uploads_data:/srv/uploads:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "expense-control-suite",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"workspaces": ["api", "web"],
|
||||
"scripts": {
|
||||
"dev": "sh ./start_dev.sh",
|
||||
"build": "npm run build --workspace api && npm run build --workspace web",
|
||||
"test": "npm run test --workspace api"
|
||||
}
|
||||
}
|
||||
29
reverse-proxy/nginx.conf.template
Normal file
29
reverse-proxy/nginx.conf.template
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${SERVER_NAME};
|
||||
|
||||
client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:4000/api/;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
alias /srv/uploads/;
|
||||
access_log off;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://web:80/;
|
||||
}
|
||||
}
|
||||
65
start_dev.sh
Executable file
65
start_dev.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
API_DIR="$ROOT_DIR/api"
|
||||
WEB_DIR="$ROOT_DIR/web"
|
||||
|
||||
ensure_install() {
|
||||
APP_DIR="$1"
|
||||
MARKER="$2"
|
||||
if [ ! -d "$APP_DIR/node_modules" ] || [ ! -e "$APP_DIR/$MARKER" ]; then
|
||||
rm -rf "$APP_DIR/node_modules"
|
||||
cd "$APP_DIR"
|
||||
npm install --workspaces=false
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${API_PID:-}" ]; then kill "$API_PID" 2>/dev/null || true; fi
|
||||
if [ -n "${WEB_PID:-}" ]; then kill "$WEB_PID" 2>/dev/null || true; fi
|
||||
}
|
||||
|
||||
trap cleanup INT TERM EXIT
|
||||
mkdir -p "$API_DIR/data" "$API_DIR/uploads"
|
||||
ensure_install "$API_DIR" "node_modules/tsx/package.json"
|
||||
ensure_install "$WEB_DIR" "node_modules/@angular/cli/package.json"
|
||||
|
||||
if [ -x "$WEB_DIR/node_modules/.bin/ng" ]; then
|
||||
"$WEB_DIR/node_modules/.bin/ng" analytics disable --global >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
export NODE_ENV=development
|
||||
export CI=${CI:-true}
|
||||
export NG_CLI_ANALYTICS=${NG_CLI_ANALYTICS:-false}
|
||||
export APP_PORT=${APP_PORT:-4000}
|
||||
export WEB_PORT=${WEB_PORT:-4200}
|
||||
export DB_TYPE=${DB_TYPE:-sqlite}
|
||||
export DB_PATH=${DB_PATH:-$API_DIR/data/dev.sqlite}
|
||||
export DB_SYNC=${DB_SYNC:-true}
|
||||
export DB_LOGGING=${DB_LOGGING:-false}
|
||||
export JWT_SECRET=${JWT_SECRET:-dev-secret-key}
|
||||
export JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
|
||||
export APP_NAME=${APP_NAME:-Expense Control Dev}
|
||||
export DEFAULT_CURRENCY=${DEFAULT_CURRENCY:-PLN}
|
||||
export ADMIN_EMAIL=${ADMIN_EMAIL:-admin@local.dev}
|
||||
export ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin123!}
|
||||
export UPLOAD_DIR=${UPLOAD_DIR:-$API_DIR/uploads}
|
||||
export MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-10}
|
||||
export API_CORS_ORIGIN=${API_CORS_ORIGIN:-http://localhost:$WEB_PORT}
|
||||
|
||||
cd "$API_DIR"
|
||||
npm run dev &
|
||||
API_PID=$!
|
||||
|
||||
cd "$WEB_DIR"
|
||||
npm run start -- --host 0.0.0.0 --port "$WEB_PORT" &
|
||||
WEB_PID=$!
|
||||
|
||||
printf '\n%s\n' '============================================================'
|
||||
printf 'API: %s\n' "http://localhost:$APP_PORT/api"
|
||||
printf 'Frontend: %s\n' "http://localhost:$WEB_PORT"
|
||||
printf 'Admin DEV: %s\n' "$ADMIN_EMAIL"
|
||||
printf 'Hasło DEV: %s\n' "$ADMIN_PASSWORD"
|
||||
printf '%s\n\n' '============================================================'
|
||||
|
||||
wait "$API_PID" "$WEB_PID"
|
||||
12
web/Dockerfile
Normal file
12
web/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --workspaces=false
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/web/browser /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
67
web/angular.json
Normal file
67
web/angular.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"web": {
|
||||
"projectType": "application",
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"index": "src/index.html",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "web:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "web:build:development",
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
web/nginx.conf
Normal file
16
web/nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
8740
web/package-lock.json
generated
Normal file
8740
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
web/package.json
Normal file
34
web/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "expense-control-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"test": "ng test"
|
||||
},
|
||||
"packageManager": "npm@10.9.2",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.2.7",
|
||||
"@angular/compiler": "^21.2.7",
|
||||
"@angular/core": "^21.2.7",
|
||||
"@angular/forms": "^21.2.7",
|
||||
"@angular/platform-browser": "^21.2.7",
|
||||
"@angular/router": "^21.2.7",
|
||||
"@tabler/core": "^1.4.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"ngx-image-cropper": "^9.1.5",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "^0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.6",
|
||||
"@angular/cli": "^21.2.6",
|
||||
"@angular/compiler-cli": "^21.2.7",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
12
web/proxy.conf.json
Normal file
12
web/proxy.conf.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:4000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/uploads": {
|
||||
"target": "http://localhost:4000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
9
web/src/app/app.config.ts
Normal file
9
web/src/app/app.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor]))]
|
||||
};
|
||||
31
web/src/app/app.routes.ts
Normal file
31
web/src/app/app.routes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { adminGuard } from './core/guards/admin.guard';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { AdminComponent } from './features/admin/admin.component';
|
||||
import { LoginComponent } from './features/auth/login.component';
|
||||
import { CategoriesComponent } from './features/categories/categories.component';
|
||||
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
||||
import { ExpensesComponent } from './features/expenses/expenses.component';
|
||||
import { MerchantsComponent } from './features/merchants/merchants.component';
|
||||
import { ReportsComponent } from './features/reports/reports.component';
|
||||
import { StatsComponent } from './features/stats/stats.component';
|
||||
import { ShellComponent } from './layout/shell.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{
|
||||
path: '',
|
||||
component: ShellComponent,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{ path: '', component: DashboardComponent },
|
||||
{ path: 'expenses', component: ExpensesComponent },
|
||||
{ path: 'stats', component: StatsComponent },
|
||||
{ path: 'merchants', component: MerchantsComponent },
|
||||
{ path: 'reports', component: ReportsComponent },
|
||||
{ path: 'categories', component: CategoriesComponent },
|
||||
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
|
||||
]
|
||||
},
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
27
web/src/app/app.ts
Normal file
27
web/src/app/app.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
import { AppSettingsService } from './core/services/app-settings.service';
|
||||
import { ToastOutletComponent } from './shared/ui/toast-outlet.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, ToastOutletComponent],
|
||||
template: `
|
||||
<router-outlet></router-outlet>
|
||||
<app-toast-outlet></app-toast-outlet>
|
||||
`
|
||||
})
|
||||
export class App {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly appSettings = inject(AppSettingsService);
|
||||
|
||||
constructor() {
|
||||
this.appSettings.loadPublic().subscribe({ error: () => undefined });
|
||||
|
||||
if (this.auth.isAuthenticated()) {
|
||||
this.auth.fetchMe().subscribe({ error: () => this.auth.logout() });
|
||||
}
|
||||
}
|
||||
}
|
||||
4
web/src/app/core/guards/admin.guard.ts
Normal file
4
web/src/app/core/guards/admin.guard.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
export const adminGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); return auth.isAdmin() ? true : router.createUrlTree(['/']); };
|
||||
4
web/src/app/core/guards/auth.guard.ts
Normal file
4
web/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
export const authGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); return auth.isAuthenticated() ? true : router.createUrlTree(['/login']); };
|
||||
4
web/src/app/core/interceptors/auth.interceptor.ts
Normal file
4
web/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const token = authService.token(); return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req); };
|
||||
29
web/src/app/core/services/admin.service.ts
Normal file
29
web/src/app/core/services/admin.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { AppSettings, User } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getSettings() {
|
||||
return this.http.get<{ item: AppSettings }>(`${environment.apiBaseUrl}/admin/settings`);
|
||||
}
|
||||
|
||||
updateSettings(payload: Partial<AppSettings>) {
|
||||
return this.http.put<{ item: AppSettings }>(`${environment.apiBaseUrl}/admin/settings`, payload);
|
||||
}
|
||||
|
||||
listUsers() {
|
||||
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
||||
}
|
||||
|
||||
updateUser(id: string, payload: Partial<User>) {
|
||||
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
|
||||
}
|
||||
|
||||
testSmtp(to: string) {
|
||||
return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to });
|
||||
}
|
||||
}
|
||||
40
web/src/app/core/services/app-settings.service.ts
Normal file
40
web/src/app/core/services/app-settings.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { AppSettings } from '../../shared/models';
|
||||
|
||||
export interface PublicAppConfig {
|
||||
appName: string;
|
||||
registrationEnabled: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppSettingsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
readonly publicConfig = signal<PublicAppConfig>({
|
||||
appName: 'Expense Control',
|
||||
registrationEnabled: true
|
||||
});
|
||||
|
||||
readonly settings = signal<AppSettings | null>(null);
|
||||
readonly appName = computed(() => this.settings()?.appName || this.publicConfig().appName || 'Expense Control');
|
||||
readonly registrationEnabled = computed(
|
||||
() => this.settings()?.registrationEnabled ?? this.publicConfig().registrationEnabled ?? true
|
||||
);
|
||||
|
||||
loadPublic() {
|
||||
return this.http
|
||||
.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`)
|
||||
.pipe(tap((config) => this.publicConfig.set(config)));
|
||||
}
|
||||
|
||||
applySettings(item: AppSettings) {
|
||||
this.settings.set(item);
|
||||
this.publicConfig.set({
|
||||
appName: item.appName,
|
||||
registrationEnabled: item.registrationEnabled
|
||||
});
|
||||
}
|
||||
}
|
||||
69
web/src/app/core/services/auth.service.ts
Normal file
69
web/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { User } from '../../shared/models';
|
||||
import type { PublicAppConfig } from './app-settings.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly tokenKey = 'expense-control-token';
|
||||
private readonly userKey = 'expense-control-user';
|
||||
|
||||
readonly token = signal<string | null>(localStorage.getItem(this.tokenKey));
|
||||
readonly currentUser = signal<User | null>(this.readUser());
|
||||
readonly isAuthenticated = computed(() => Boolean(this.token()));
|
||||
readonly isAdmin = computed(() => this.currentUser()?.role === 'ADMIN');
|
||||
|
||||
private readUser(): User | null {
|
||||
const raw = localStorage.getItem(this.userKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as User;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
login(payload: { email: string; password: string }) {
|
||||
return this.http
|
||||
.post<{ token: string; user: User }>(`${environment.apiBaseUrl}/auth/login`, payload)
|
||||
.pipe(tap((response) => this.persistSession(response.token, response.user)));
|
||||
}
|
||||
|
||||
register(payload: { fullName: string; email: string; password: string }) {
|
||||
return this.http
|
||||
.post<{ token: string; user: User }>(`${environment.apiBaseUrl}/auth/register`, payload)
|
||||
.pipe(tap((response) => this.persistSession(response.token, response.user)));
|
||||
}
|
||||
|
||||
fetchMe() {
|
||||
return this.http
|
||||
.get<{ user: User }>(`${environment.apiBaseUrl}/auth/me`)
|
||||
.pipe(tap((response) => this.setUser(response.user)));
|
||||
}
|
||||
|
||||
getPublicConfig() {
|
||||
return this.http.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`);
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
localStorage.removeItem(this.userKey);
|
||||
this.token.set(null);
|
||||
this.currentUser.set(null);
|
||||
}
|
||||
|
||||
private persistSession(token: string, user: User) {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
this.token.set(token);
|
||||
this.currentUser.set(user);
|
||||
}
|
||||
|
||||
private setUser(user: User) {
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
this.currentUser.set(user);
|
||||
}
|
||||
}
|
||||
52
web/src/app/core/services/categories.service.ts
Normal file
52
web/src/app/core/services/categories.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Category } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CategoriesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly items = signal<Category[]>([]);
|
||||
private loaded = false;
|
||||
|
||||
ensureLoaded(force = false) {
|
||||
if (this.loaded && !force) return;
|
||||
this.list(force).subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
list(force = false) {
|
||||
return this.http.get<{ items: Category[] }>(`${environment.apiBaseUrl}/categories`).pipe(
|
||||
tap((response) => {
|
||||
this.items.set(response.items);
|
||||
if (response.items.length || force) this.loaded = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
create(payload: { name: string; color: string }) {
|
||||
return this.http.post<{ item: Category }>(`${environment.apiBaseUrl}/categories`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) => [...items, response.item].sort((a, b) => a.name.localeCompare(b.name, 'pl')));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
update(id: string, payload: { name: string; color: string }) {
|
||||
return this.http.put<{ item: Category }>(`${environment.apiBaseUrl}/categories/${id}`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) =>
|
||||
items
|
||||
.map((item) => (item.id === id ? response.item : item))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'pl'))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>(`${environment.apiBaseUrl}/categories/${id}`).pipe(
|
||||
tap(() => this.items.update((items) => items.filter((item) => item.id !== id)))
|
||||
);
|
||||
}
|
||||
}
|
||||
13
web/src/app/core/services/expenses.service.ts
Normal file
13
web/src/app/core/services/expenses.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Expense, Proof } from '../../shared/models';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExpensesService {
|
||||
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); }
|
||||
update(id: string, payload: Partial<Expense> & { categoryId: string }) { return this.http.put<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}`, payload); }
|
||||
delete(id: string) { return this.http.delete<void>(`${environment.apiBaseUrl}/expenses/${id}`); }
|
||||
addProof(id: string, formData: FormData) { return this.http.post<{ proof: Proof; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData); }
|
||||
}
|
||||
52
web/src/app/core/services/merchants.service.ts
Normal file
52
web/src/app/core/services/merchants.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Merchant } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MerchantsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly items = signal<Merchant[]>([]);
|
||||
private loaded = false;
|
||||
|
||||
ensureLoaded(force = false) {
|
||||
if (this.loaded && !force) return;
|
||||
this.list(force).subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
list(force = false) {
|
||||
return this.http.get<{ items: Merchant[] }>(`${environment.apiBaseUrl}/merchants`).pipe(
|
||||
tap((response) => {
|
||||
this.items.set(response.items);
|
||||
if (response.items.length || force) this.loaded = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
create(payload: { name: string; kind: Merchant['kind']; notes?: string | null; isActive?: boolean }) {
|
||||
return this.http.post<{ item: Merchant }>(`${environment.apiBaseUrl}/merchants`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) => [...items, response.item].sort((a, b) => a.name.localeCompare(b.name, 'pl')));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
update(id: string, payload: { name: string; kind: Merchant['kind']; notes?: string | null; isActive?: boolean }) {
|
||||
return this.http.put<{ item: Merchant }>(`${environment.apiBaseUrl}/merchants/${id}`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) =>
|
||||
items
|
||||
.map((item) => (item.id === id ? response.item : item))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'pl'))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>(`${environment.apiBaseUrl}/merchants/${id}`).pipe(
|
||||
tap(() => this.items.update((items) => items.filter((item) => item.id !== id)))
|
||||
);
|
||||
}
|
||||
}
|
||||
28
web/src/app/core/services/reports.service.ts
Normal file
28
web/src/app/core/services/reports.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReportsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getPreferences() {
|
||||
return this.http.get<{ item: ReportPreferences }>(`${environment.apiBaseUrl}/reports/preferences`);
|
||||
}
|
||||
|
||||
updatePreferences(payload: ReportPreferences) {
|
||||
return this.http.put<{ item: ReportPreferences }>(`${environment.apiBaseUrl}/reports/preferences`, payload);
|
||||
}
|
||||
|
||||
preview(payload?: ReportPreferences) {
|
||||
return this.http.post<{ range: { startDate: string; endDate: string; label: string }; summary: StatsResponse; html: string }>(
|
||||
`${environment.apiBaseUrl}/reports/preview`,
|
||||
payload ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
send() {
|
||||
return this.http.post<{ message: string; sentTo: string }>(`${environment.apiBaseUrl}/reports/send`, {});
|
||||
}
|
||||
}
|
||||
9
web/src/app/core/services/stats.service.ts
Normal file
9
web/src/app/core/services/stats.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { StatsResponse } from '../../shared/models';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StatsService {
|
||||
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 }); }
|
||||
}
|
||||
44
web/src/app/core/services/toast.service.ts
Normal file
44
web/src/app/core/services/toast.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ToastItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
tone: 'success' | 'danger' | 'warning' | 'info';
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
readonly items = signal<ToastItem[]>([]);
|
||||
private counter = 0;
|
||||
|
||||
show(message: string, tone: ToastItem['tone'] = 'info', title = this.defaultTitle(tone)) {
|
||||
const id = ++this.counter;
|
||||
this.items.update((items) => [...items, { id, title, message, tone }]);
|
||||
setTimeout(() => this.dismiss(id), 4200);
|
||||
}
|
||||
|
||||
dismiss(id: number) {
|
||||
this.items.update((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
success(message: string, title = 'Gotowe') {
|
||||
this.show(message, 'success', title);
|
||||
}
|
||||
|
||||
error(message: string, title = 'Błąd') {
|
||||
this.show(message, 'danger', title);
|
||||
}
|
||||
|
||||
warning(message: string, title = 'Uwaga') {
|
||||
this.show(message, 'warning', title);
|
||||
}
|
||||
|
||||
info(message: string, title = 'Informacja') {
|
||||
this.show(message, 'info', title);
|
||||
}
|
||||
|
||||
private defaultTitle(tone: ToastItem['tone']) {
|
||||
return tone === 'success' ? 'Gotowe' : tone === 'danger' ? 'Błąd' : tone === 'warning' ? 'Uwaga' : 'Informacja';
|
||||
}
|
||||
}
|
||||
145
web/src/app/core/services/ui.service.ts
Normal file
145
web/src/app/core/services/ui.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
|
||||
export type UiTheme = 'light' | 'dark';
|
||||
export type UiLanguage = 'pl' | 'en';
|
||||
|
||||
const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
pl: {
|
||||
'app.name': 'Expense Control',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.expenses': 'Wydatki',
|
||||
'nav.stats': 'Statystyki',
|
||||
'nav.merchants': 'Kontrahenci',
|
||||
'nav.reports': 'Raporty',
|
||||
'nav.categories': 'Kategorie',
|
||||
'nav.admin': 'Administracja',
|
||||
'action.logout': 'Wyloguj',
|
||||
'action.addExpense': 'Dodaj wydatek',
|
||||
'action.openReports': 'Raporty',
|
||||
'action.login': 'Zaloguj się',
|
||||
'action.loggingIn': 'Logowanie...',
|
||||
'action.createAccount': 'Utwórz konto',
|
||||
'action.creatingAccount': 'Tworzenie konta...',
|
||||
'action.loginMode': 'Logowanie',
|
||||
'action.registerMode': 'Rejestracja',
|
||||
'theme.label': 'Motyw',
|
||||
'theme.dark': 'Ciemny',
|
||||
'theme.light': 'Jasny',
|
||||
'lang.label': 'Język',
|
||||
'lang.pl': 'Polski',
|
||||
'lang.en': 'English',
|
||||
'login.email': 'E-mail',
|
||||
'login.password': 'Hasło',
|
||||
'login.fullName': 'Imię i nazwisko',
|
||||
'dashboard.section': 'Panel wydatków',
|
||||
'dashboard.subtitle': 'Szybki podgląd wydatków, kontrahentów i raportów SMTP.',
|
||||
'dashboard.total': 'Suma miesiąca',
|
||||
'dashboard.count': 'Liczba wydatków',
|
||||
'dashboard.avg': 'Średnia',
|
||||
'dashboard.top': 'Największa kategoria',
|
||||
'dashboard.share': 'Udział kategorii',
|
||||
'dashboard.areas': 'Najmocniejsze obszary kosztów',
|
||||
'dashboard.recent': 'Ostatnie wydatki',
|
||||
'common.none': 'Brak',
|
||||
'common.noData': 'Brak danych.',
|
||||
'common.noExpenses': 'Brak wydatków.',
|
||||
'common.noCategories': 'Brak kategorii.'
|
||||
},
|
||||
en: {
|
||||
'app.name': 'Expense Control',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.expenses': 'Expenses',
|
||||
'nav.stats': 'Statistics',
|
||||
'nav.merchants': 'Partners',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.categories': 'Categories',
|
||||
'nav.admin': 'Admin',
|
||||
'action.logout': 'Sign out',
|
||||
'action.addExpense': 'Add expense',
|
||||
'action.openReports': 'Reports',
|
||||
'action.login': 'Sign in',
|
||||
'action.loggingIn': 'Signing in...',
|
||||
'action.createAccount': 'Create account',
|
||||
'action.creatingAccount': 'Creating account...',
|
||||
'action.loginMode': 'Sign in',
|
||||
'action.registerMode': 'Register',
|
||||
'theme.label': 'Theme',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.light': 'Light',
|
||||
'lang.label': 'Language',
|
||||
'lang.pl': 'Polish',
|
||||
'lang.en': 'English',
|
||||
'login.email': 'Email',
|
||||
'login.password': 'Password',
|
||||
'login.fullName': 'Full name',
|
||||
'dashboard.section': 'Expense overview',
|
||||
'dashboard.subtitle': 'Fast access to expenses, partners and SMTP reports.',
|
||||
'dashboard.total': 'Month total',
|
||||
'dashboard.count': 'Expense count',
|
||||
'dashboard.avg': 'Average',
|
||||
'dashboard.top': 'Top category',
|
||||
'dashboard.share': 'Category share',
|
||||
'dashboard.areas': 'Top cost areas',
|
||||
'dashboard.recent': 'Recent expenses',
|
||||
'common.none': 'None',
|
||||
'common.noData': 'No data.',
|
||||
'common.noExpenses': 'No expenses.',
|
||||
'common.noCategories': 'No categories.'
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UiService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
readonly theme = signal<UiTheme>(this.readTheme());
|
||||
readonly language = signal<UiLanguage>(this.readLanguage());
|
||||
readonly resolvedTheme = computed<'light' | 'dark'>(() => this.theme());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const resolved = this.resolvedTheme();
|
||||
const html = this.document.documentElement;
|
||||
const body = this.document.body;
|
||||
html.setAttribute('data-bs-theme', resolved);
|
||||
html.setAttribute('lang', this.language());
|
||||
body.setAttribute('data-bs-theme', resolved);
|
||||
try {
|
||||
window.localStorage.setItem('expense-control-theme', this.theme());
|
||||
window.localStorage.setItem('expense-control-lang', this.language());
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
setTheme(theme: UiTheme) {
|
||||
this.theme.set(theme);
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
this.theme.set(this.theme() === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
setLanguage(language: UiLanguage) {
|
||||
this.language.set(language);
|
||||
}
|
||||
|
||||
t(key: string) {
|
||||
return translations[this.language()][key] ?? translations.pl[key] ?? key;
|
||||
}
|
||||
|
||||
private readTheme(): UiTheme {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('expense-control-theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
} catch {}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
private readLanguage(): UiLanguage {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('expense-control-lang');
|
||||
if (stored === 'pl' || stored === 'en') return stored;
|
||||
} catch {}
|
||||
return 'pl';
|
||||
}
|
||||
}
|
||||
242
web/src/app/features/admin/admin.component.ts
Normal file
242
web/src/app/features/admin/admin.component.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { AdminService } from '../../core/services/admin.service';
|
||||
import { AppSettingsService } from '../../core/services/app-settings.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import type { AppSettings, User } from '../../shared/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, DatePipe],
|
||||
template: `
|
||||
<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 {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly admin = inject(AdminService);
|
||||
private readonly appSettings = inject(AppSettingsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly users = signal<User[]>([]);
|
||||
readonly settings = signal<AppSettings | null>(null);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
appName: ['', [Validators.required, Validators.minLength(2)]],
|
||||
defaultCurrency: ['PLN', Validators.required],
|
||||
allowedProofTypes: ['RECEIPT,INVOICE,NOTE,BANK_STATEMENT,OTHER', Validators.required],
|
||||
registrationEnabled: [true],
|
||||
smtpEnabled: [false],
|
||||
smtpHost: [''],
|
||||
smtpPort: [587],
|
||||
smtpSecure: [false],
|
||||
smtpUser: [''],
|
||||
smtpPassword: [''],
|
||||
smtpFromName: [''],
|
||||
smtpFromEmail: ['']
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.admin.getSettings().subscribe({
|
||||
next: (response) => {
|
||||
this.settings.set(response.item);
|
||||
this.appSettings.applySettings(response.item);
|
||||
this.form.reset({
|
||||
appName: response.item.appName,
|
||||
defaultCurrency: response.item.defaultCurrency,
|
||||
allowedProofTypes: response.item.allowedProofTypes.join(','),
|
||||
registrationEnabled: response.item.registrationEnabled,
|
||||
smtpEnabled: response.item.smtpEnabled,
|
||||
smtpHost: response.item.smtpHost ?? '',
|
||||
smtpPort: response.item.smtpPort,
|
||||
smtpSecure: response.item.smtpSecure,
|
||||
smtpUser: response.item.smtpUser ?? '',
|
||||
smtpPassword: response.item.smtpPassword ?? '',
|
||||
smtpFromName: response.item.smtpFromName ?? '',
|
||||
smtpFromEmail: response.item.smtpFromEmail ?? ''
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
this.saving.set(true);
|
||||
const raw = this.form.getRawValue();
|
||||
this.admin
|
||||
.updateSettings({
|
||||
appName: raw.appName,
|
||||
defaultCurrency: raw.defaultCurrency,
|
||||
registrationEnabled: raw.registrationEnabled,
|
||||
allowedProofTypes: raw.allowedProofTypes.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
uiPreferences: { theme: 'dark', density: 'comfortable', defaultStatsPeriod: 'month' },
|
||||
smtpEnabled: raw.smtpEnabled,
|
||||
smtpHost: raw.smtpHost || null,
|
||||
smtpPort: Number(raw.smtpPort),
|
||||
smtpSecure: raw.smtpSecure,
|
||||
smtpUser: raw.smtpUser || null,
|
||||
smtpPassword: raw.smtpPassword || null,
|
||||
smtpFromName: raw.smtpFromName || null,
|
||||
smtpFromEmail: raw.smtpFromEmail || null
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.saving.set(false);
|
||||
this.settings.set(response.item);
|
||||
this.appSettings.applySettings(response.item);
|
||||
this.toast.success('Ustawienia zapisane.');
|
||||
},
|
||||
error: (error) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(error.error?.message ?? 'Nie udało się zapisać ustawień.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendTest() {
|
||||
const to = this.form.getRawValue().smtpFromEmail;
|
||||
if (!to) {
|
||||
this.toast.error('Uzupełnij e-mail nadawcy.');
|
||||
return;
|
||||
}
|
||||
this.admin.testSmtp(to).subscribe({
|
||||
next: () => this.toast.success('Wiadomość testowa została wysłana.'),
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się wysłać testu SMTP.')
|
||||
});
|
||||
}
|
||||
|
||||
toggleRole(user: User) {
|
||||
this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({
|
||||
next: (response) => {
|
||||
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
|
||||
this.toast.success('Rola została zaktualizowana.');
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zmienić roli.')
|
||||
});
|
||||
}
|
||||
|
||||
toggleActive(user: User) {
|
||||
this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({
|
||||
next: (response) => {
|
||||
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
|
||||
this.toast.success('Status konta został zaktualizowany.');
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zmienić statusu.')
|
||||
});
|
||||
}
|
||||
}
|
||||
186
web/src/app/features/auth/login.component.ts
Normal file
186
web/src/app/features/auth/login.component.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AppSettingsService } from '../../core/services/app-settings.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<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-grid gap-2 login-toolbar-controls">
|
||||
<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')">
|
||||
{{ ui.t('lang.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')">
|
||||
{{ ui.t('lang.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>
|
||||
</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>
|
||||
|
||||
<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="M9 8l0 -2a2 2 0 1 1 4 0v2"/><path d="M5 8h14l0 12h-14z"/><path d="M12 12l0 .01"/></svg>
|
||||
{{ loading() ? (mode() === 'login' ? ui.t('action.loggingIn') : ui.t('action.creatingAccount')) : (mode() === 'login' ? ui.t('action.login') : ui.t('action.createAccount')) }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@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)="mode.set(mode() === 'login' ? 'register' : 'login')">
|
||||
{{ mode() === 'login' ? ui.t('action.registerMode') : ui.t('action.loginMode') }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class LoginComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toast = inject(ToastService);
|
||||
readonly ui = inject(UiService);
|
||||
readonly appSettings = inject(AppSettingsService);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly mode = signal<'login' | 'register'>('login');
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(6)]],
|
||||
fullName: ['']
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.appSettings.loadPublic().subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.form.invalid) return;
|
||||
this.loading.set(true);
|
||||
const raw = this.form.getRawValue();
|
||||
|
||||
if (this.mode() === 'login') {
|
||||
this.auth.login({ email: raw.email, password: raw.password }).subscribe({
|
||||
next: () => {
|
||||
this.loading.set(false);
|
||||
this.router.navigate(['/']);
|
||||
},
|
||||
error: (error) => {
|
||||
this.loading.set(false);
|
||||
this.toast.error(error.error?.message ?? 'Nie udało się zalogować.');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.register({ email: raw.email, password: raw.password, fullName: raw.fullName || raw.email }).subscribe({
|
||||
next: () => {
|
||||
this.loading.set(false);
|
||||
this.toast.success('Konto zostało utworzone.');
|
||||
this.mode.set('login');
|
||||
},
|
||||
error: (error) => {
|
||||
this.loading.set(false);
|
||||
this.toast.error(error.error?.message ?? 'Nie udało się utworzyć konta.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loginSubtitle() {
|
||||
return this.ui.language() === 'pl'
|
||||
? 'Zaloguj się, aby zarządzać wydatkami, kontrahentami i raportami.'
|
||||
: 'Sign in to manage expenses, merchants and reports.';
|
||||
}
|
||||
|
||||
registerSubtitle() {
|
||||
return this.ui.language() === 'pl'
|
||||
? 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.'
|
||||
: 'Create an account and start collecting proofs and analytics.';
|
||||
}
|
||||
|
||||
switchToRegisterLabel() {
|
||||
return this.ui.language() === 'pl' ? 'Nie masz konta?' : 'Need an account?';
|
||||
}
|
||||
|
||||
switchToLoginLabel() {
|
||||
return this.ui.language() === 'pl' ? 'Masz już konto?' : 'Already registered?';
|
||||
}
|
||||
}
|
||||
151
web/src/app/features/categories/categories.component.ts
Normal file
151
web/src/app/features/categories/categories.component.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import type { Category } from '../../shared/models';
|
||||
|
||||
const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b', '#475569'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-categories',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<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 {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categories = inject(CategoriesService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly items = this.categories.items;
|
||||
readonly presets = presets;
|
||||
editingId: string | null = null;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
color: ['#2563eb', Validators.required]
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.categories.ensureLoaded(true);
|
||||
}
|
||||
|
||||
pickColor(color: string) {
|
||||
this.form.patchValue({ color });
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.form.invalid) return;
|
||||
const payload = this.form.getRawValue();
|
||||
const request = this.editingId ? this.categories.update(this.editingId, payload) : this.categories.create(payload);
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.editingId ? 'Kategoria została zapisana.' : 'Kategoria została dodana.');
|
||||
this.reset();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać kategorii.')
|
||||
});
|
||||
}
|
||||
|
||||
edit(item: Category) {
|
||||
this.editingId = item.id;
|
||||
this.form.patchValue({ name: item.name, color: item.color });
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.editingId = null;
|
||||
this.form.reset({ name: '', color: '#2563eb' });
|
||||
}
|
||||
|
||||
remove(item: Category) {
|
||||
this.categories.delete(item.id).subscribe({
|
||||
next: () => this.toast.success('Kategoria została usunięta.'),
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kategorii.')
|
||||
});
|
||||
}
|
||||
}
|
||||
209
web/src/app/features/dashboard/dashboard.component.ts
Normal file
209
web/src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Chart, DoughnutController, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { ExpensesService } from '../../core/services/expenses.service';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import type { Expense, StatsResponse } from '../../shared/models';
|
||||
|
||||
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CurrencyPipe, DatePipe, RouterLink],
|
||||
template: `
|
||||
<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 {
|
||||
readonly auth = inject(AuthService);
|
||||
readonly ui = inject(UiService);
|
||||
private readonly expensesService = inject(ExpensesService);
|
||||
private readonly statsService = inject(StatsService);
|
||||
|
||||
recentExpenses: Expense[] = [];
|
||||
stats: StatsResponse | null = null;
|
||||
private categoryChart?: Chart;
|
||||
|
||||
ngOnInit() {
|
||||
const now = new Date();
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
|
||||
const end = now.toISOString().slice(0, 10);
|
||||
|
||||
this.expensesService.list({ startDate: start, endDate: end }).subscribe({
|
||||
next: (response) => (this.recentExpenses = response.items.slice(0, 8))
|
||||
});
|
||||
|
||||
this.statsService.overview({ startDate: start, endDate: end, bucket: 'month' }).subscribe({
|
||||
next: (response) => {
|
||||
this.stats = response;
|
||||
setTimeout(() => this.renderChart(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hasCategoryData() {
|
||||
return Boolean(this.stats?.byCategory?.length);
|
||||
}
|
||||
|
||||
private renderChart() {
|
||||
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
|
||||
if (!canvas || !this.stats?.byCategory.length) {
|
||||
this.categoryChart?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
this.categoryChart?.destroy();
|
||||
this.categoryChart = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: this.stats.byCategory.map((item) => item.categoryName),
|
||||
datasets: [{ data: this.stats.byCategory.map((item) => item.total) }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '66%',
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
507
web/src/app/features/expenses/expenses.component.ts
Normal file
507
web/src/app/features/expenses/expenses.component.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { ExpensesService } from '../../core/services/expenses.service';
|
||||
import { MerchantsService } from '../../core/services/merchants.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import type { Expense, Merchant, Proof } from '../../shared/models';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
@Component({
|
||||
selector: 'app-expenses',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent],
|
||||
template: `
|
||||
<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 {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly merchantsService = inject(MerchantsService);
|
||||
private readonly expensesService = inject(ExpensesService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly merchants = this.merchantsService.items;
|
||||
readonly expenses = signal<Expense[]>([]);
|
||||
readonly selectedMerchantId = signal('');
|
||||
readonly editingExpenseId = signal<string | null>(null);
|
||||
readonly saving = signal(false);
|
||||
readonly merchantModalOpen = signal(false);
|
||||
readonly proofPreview = signal<Proof | null>(null);
|
||||
|
||||
readonly imageChangedEvent = signal<Event | null>(null);
|
||||
readonly croppedFile = signal<File | null>(null);
|
||||
readonly croppedPreview = signal<string | null>(null);
|
||||
readonly showCropper = signal(false);
|
||||
|
||||
readonly expenseForm = this.fb.nonNullable.group({
|
||||
title: ['', [Validators.required, Validators.minLength(2)]],
|
||||
amount: [0],
|
||||
expenseDate: [today, Validators.required],
|
||||
categoryId: ['', Validators.required],
|
||||
merchant: [''],
|
||||
paymentMethod: [''],
|
||||
description: [''],
|
||||
proofType: ['RECEIPT'],
|
||||
proofLabel: [''],
|
||||
proofNote: ['']
|
||||
});
|
||||
|
||||
readonly filterForm = this.fb.nonNullable.group({
|
||||
startDate: [''],
|
||||
endDate: [''],
|
||||
categoryId: [''],
|
||||
search: ['']
|
||||
});
|
||||
|
||||
readonly merchantForm = this.fb.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
kind: ['MERCHANT' as Merchant['kind'], Validators.required],
|
||||
notes: ['']
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.ensureLoaded(true);
|
||||
this.merchantsService.ensureLoaded(true);
|
||||
this.loadExpenses();
|
||||
}
|
||||
|
||||
activeMerchants() {
|
||||
return this.merchants().filter((item) => item.isActive);
|
||||
}
|
||||
|
||||
loadExpenses() {
|
||||
this.expensesService.list(this.filterForm.getRawValue()).subscribe({
|
||||
next: (response) => this.expenses.set(response.items)
|
||||
});
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '' });
|
||||
this.loadExpenses();
|
||||
}
|
||||
|
||||
selectMerchant(id: string) {
|
||||
this.selectedMerchantId.set(id);
|
||||
const merchant = this.merchants().find((item) => item.id === id);
|
||||
this.expenseForm.patchValue({ merchant: merchant?.name ?? '' });
|
||||
}
|
||||
|
||||
openMerchantModal() {
|
||||
this.merchantForm.reset({ name: '', kind: 'MERCHANT', notes: '' });
|
||||
this.merchantModalOpen.set(true);
|
||||
}
|
||||
|
||||
saveMerchant() {
|
||||
if (this.merchantForm.invalid) return;
|
||||
this.merchantsService.create({ ...this.merchantForm.getRawValue(), isActive: true }).subscribe({
|
||||
next: (response) => {
|
||||
this.toast.success('Kontrahent został dodany.');
|
||||
this.merchantModalOpen.set(false);
|
||||
this.selectedMerchantId.set(response.item.id);
|
||||
this.expenseForm.patchValue({ merchant: response.item.name });
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się dodać kontrahenta.')
|
||||
});
|
||||
}
|
||||
|
||||
onProofSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
this.croppedFile.set(file);
|
||||
this.croppedPreview.set(null);
|
||||
this.imageChangedEvent.set(event);
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
this.showCropper.set(true);
|
||||
} else {
|
||||
this.showCropper.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onImageCropped(event: ImageCroppedEvent) {
|
||||
if (!event.blob) return;
|
||||
const file = new File([event.blob], `proof-${Date.now()}.png`, { type: 'image/png' });
|
||||
this.croppedFile.set(file);
|
||||
this.croppedPreview.set(event.objectUrl ?? null);
|
||||
}
|
||||
|
||||
submitExpense() {
|
||||
if (this.expenseForm.invalid) return;
|
||||
const raw = this.expenseForm.getRawValue();
|
||||
this.saving.set(true);
|
||||
|
||||
if (this.editingExpenseId()) {
|
||||
this.expensesService
|
||||
.update(this.editingExpenseId()!, {
|
||||
title: raw.title,
|
||||
amount: raw.amount,
|
||||
expenseDate: raw.expenseDate,
|
||||
categoryId: raw.categoryId,
|
||||
merchant: raw.merchant,
|
||||
paymentMethod: raw.paymentMethod as Expense['paymentMethod'],
|
||||
description: raw.description,
|
||||
currency: 'PLN'
|
||||
})
|
||||
.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();
|
||||
formData.set('title', raw.title);
|
||||
formData.set('amount', String(raw.amount));
|
||||
formData.set('expenseDate', raw.expenseDate);
|
||||
formData.set('categoryId', raw.categoryId);
|
||||
formData.set('merchant', raw.merchant);
|
||||
formData.set('paymentMethod', raw.paymentMethod);
|
||||
formData.set('description', raw.description);
|
||||
formData.set('currency', 'PLN');
|
||||
formData.set('proofType', raw.proofType);
|
||||
formData.set('proofLabel', raw.proofLabel);
|
||||
formData.set('proofNote', raw.proofNote);
|
||||
if (this.croppedFile()) formData.set('proofFile', this.croppedFile()!);
|
||||
|
||||
this.expensesService.create(formData).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toast.success('Wydatek został dodany.');
|
||||
this.expenseForm.reset({
|
||||
title: '',
|
||||
amount: 0,
|
||||
expenseDate: today,
|
||||
categoryId: '',
|
||||
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) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(error.error?.message ?? 'Nie udało się dodać wydatku.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startEdit(item: Expense) {
|
||||
this.editingExpenseId.set(item.id);
|
||||
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 ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editingExpenseId.set(null);
|
||||
this.expenseForm.reset({
|
||||
title: '',
|
||||
amount: 0,
|
||||
expenseDate: today,
|
||||
categoryId: '',
|
||||
merchant: '',
|
||||
paymentMethod: '',
|
||||
description: '',
|
||||
proofType: 'RECEIPT',
|
||||
proofLabel: '',
|
||||
proofNote: ''
|
||||
});
|
||||
}
|
||||
|
||||
removeExpense(item: Expense) {
|
||||
this.expensesService.delete(item.id).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Wydatek został usunięty.');
|
||||
this.loadExpenses();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć wydatku.')
|
||||
});
|
||||
}
|
||||
|
||||
openProof(proof: Proof) {
|
||||
this.proofPreview.set(proof);
|
||||
}
|
||||
|
||||
closeMerchantModal() {
|
||||
this.merchantModalOpen.set(false);
|
||||
}
|
||||
|
||||
closeProofPreview() {
|
||||
this.proofPreview.set(null);
|
||||
}
|
||||
}
|
||||
170
web/src/app/features/merchants/merchants.component.ts
Normal file
170
web/src/app/features/merchants/merchants.component.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MerchantsService } from '../../core/services/merchants.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import type { Merchant } from '../../shared/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-merchants',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<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 {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly merchants = inject(MerchantsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly items = this.merchants.items;
|
||||
readonly modalOpen = signal(false);
|
||||
readonly editingId = signal<string | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
kind: ['MERCHANT' as Merchant['kind'], Validators.required],
|
||||
notes: [''],
|
||||
isActive: [true]
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.merchants.ensureLoaded(true);
|
||||
}
|
||||
|
||||
labelKind(kind: Merchant['kind']) {
|
||||
return kind === 'SERVICE_PROVIDER' ? 'Usługodawca' : kind === 'MERCHANT' ? 'Sprzedawca' : 'Inny';
|
||||
}
|
||||
|
||||
openCreate() {
|
||||
this.editingId.set(null);
|
||||
this.form.reset({ name: '', kind: 'MERCHANT', notes: '', isActive: true });
|
||||
this.modalOpen.set(true);
|
||||
}
|
||||
|
||||
openEdit(item: Merchant) {
|
||||
this.editingId.set(item.id);
|
||||
this.form.reset({
|
||||
name: item.name,
|
||||
kind: item.kind,
|
||||
notes: item.notes ?? '',
|
||||
isActive: item.isActive
|
||||
});
|
||||
this.modalOpen.set(true);
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.modalOpen.set(false);
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.form.invalid) return;
|
||||
const payload = this.form.getRawValue();
|
||||
const request = this.editingId() ? this.merchants.update(this.editingId()!, payload) : this.merchants.create(payload);
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.editingId() ? 'Kontrahent został zapisany.' : 'Kontrahent został dodany.');
|
||||
this.closeModal();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać kontrahenta.')
|
||||
});
|
||||
}
|
||||
|
||||
remove(item: Merchant) {
|
||||
this.merchants.delete(item.id).subscribe({
|
||||
next: () => this.toast.success('Kontrahent został usunięty.'),
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kontrahenta.')
|
||||
});
|
||||
}
|
||||
}
|
||||
160
web/src/app/features/reports/reports.component.ts
Normal file
160
web/src/app/features/reports/reports.component.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { ReportsService } from '../../core/services/reports.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
||||
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reports',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, CategoryPickerComponent],
|
||||
template: `
|
||||
<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 {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly reports = inject(ReportsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly html = signal('<div class="text-secondary">Brak danych raportu.</div>');
|
||||
readonly summary = signal<StatsResponse | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
enabled: [false],
|
||||
frequency: ['monthly' as ReportPreferences['frequency'], Validators.required],
|
||||
sendToEmail: ['', Validators.required],
|
||||
thresholdAmount: [0],
|
||||
categoryIds: [[] as string[]]
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.ensureLoaded(true);
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setCategoryIds(categoryIds: string[]) {
|
||||
this.form.patchValue({ categoryIds });
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
const payload: ReportPreferences = this.form.getRawValue();
|
||||
this.reports.updatePreferences(payload).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Ustawienia raportów zapisane.');
|
||||
this.preview();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać raportów.')
|
||||
});
|
||||
}
|
||||
|
||||
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.')
|
||||
});
|
||||
}
|
||||
}
|
||||
233
web/src/app/features/stats/stats.component.ts
Normal file
233
web/src/app/features/stats/stats.component.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import {
|
||||
Chart,
|
||||
DoughnutController,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
} from 'chart.js';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import type { StatsResponse } from '../../shared/models';
|
||||
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
||||
|
||||
Chart.register(
|
||||
DoughnutController,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: 'app-stats',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, CategoryPickerComponent],
|
||||
template: `
|
||||
<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 {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly statsService = inject(StatsService);
|
||||
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly stats = signal<StatsResponse | null>(null);
|
||||
private categoryChart?: Chart;
|
||||
private lineChart?: Chart;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
bucket: ['month' as 'month' | 'quarter' | 'year'],
|
||||
startDate: [''],
|
||||
endDate: [''],
|
||||
categoryIds: [[] as string[]]
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.ensureLoaded(true);
|
||||
this.load();
|
||||
}
|
||||
|
||||
setCategoryIds(categoryIds: string[]) {
|
||||
this.form.patchValue({ categoryIds });
|
||||
}
|
||||
|
||||
load() {
|
||||
const raw = this.form.getRawValue();
|
||||
this.statsService
|
||||
.overview({
|
||||
startDate: raw.startDate || undefined,
|
||||
endDate: raw.endDate || undefined,
|
||||
categoryIds: raw.categoryIds.join(',') || undefined,
|
||||
bucket: raw.bucket
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.stats.set(response);
|
||||
setTimeout(() => this.renderCharts(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [] });
|
||||
this.load();
|
||||
}
|
||||
|
||||
hasCategoryData() {
|
||||
return Boolean(this.stats()?.byCategory?.length);
|
||||
}
|
||||
|
||||
hasTimelineData() {
|
||||
return Boolean(this.stats()?.timeline?.length);
|
||||
}
|
||||
|
||||
private renderCharts() {
|
||||
const current = this.stats();
|
||||
const categoryCanvas = document.getElementById('statsCategoryChart') as HTMLCanvasElement | null;
|
||||
const lineCanvas = document.getElementById('statsLineChart') as HTMLCanvasElement | null;
|
||||
|
||||
if (categoryCanvas && current?.byCategory?.length) {
|
||||
this.categoryChart?.destroy();
|
||||
this.categoryChart = new Chart(categoryCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: current.byCategory.map((item) => item.categoryName),
|
||||
datasets: [{ data: current.byCategory.map((item) => item.total) }]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
cutout: '64%',
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.categoryChart?.destroy();
|
||||
}
|
||||
|
||||
if (lineCanvas && current?.timeline?.length) {
|
||||
this.lineChart?.destroy();
|
||||
this.lineChart = new Chart(lineCanvas, {
|
||||
type: 'line',
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
112
web/src/app/layout/shell.component.ts
Normal file
112
web/src/app/layout/shell.component.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from '../core/services/auth.service';
|
||||
import { AppSettingsService } from '../core/services/app-settings.service';
|
||||
import { UiService } from '../core/services/ui.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<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 {
|
||||
readonly auth = inject(AuthService);
|
||||
readonly ui = inject(UiService);
|
||||
readonly appSettings = inject(AppSettingsService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
logout() {
|
||||
this.auth.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
92
web/src/app/shared/models.ts
Normal file
92
web/src/app/shared/models.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
role: 'ADMIN' | 'USER';
|
||||
isActive: boolean;
|
||||
defaultCurrency: string;
|
||||
reportPreferences?: ReportPreferences;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
isSystem: boolean;
|
||||
ownerId: string | null;
|
||||
}
|
||||
|
||||
export interface Merchant {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'MERCHANT' | 'SERVICE_PROVIDER' | 'OTHER';
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Proof {
|
||||
id: string;
|
||||
type: 'RECEIPT' | 'INVOICE' | 'NOTE' | 'BANK_STATEMENT' | 'OTHER';
|
||||
label: string | null;
|
||||
note: string | null;
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
fileUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Expense {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: number;
|
||||
expenseDate: string;
|
||||
merchant: string | null;
|
||||
paymentMethod: 'CARD' | 'CASH' | 'TRANSFER' | 'BLIK' | 'OTHER' | null;
|
||||
currency: string;
|
||||
possibleDuplicate: boolean;
|
||||
category: Category;
|
||||
proofs: Proof[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
total: number;
|
||||
count: number;
|
||||
average: number;
|
||||
topCategory: { categoryId: string; categoryName: string; total: number; count: number } | null;
|
||||
byCategory: Array<{ categoryId: string; categoryName: string; total: number; count: number }>;
|
||||
timeline: Array<{ label: string; total: number }>;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
id: string;
|
||||
appName: string;
|
||||
defaultCurrency: string;
|
||||
registrationEnabled: boolean;
|
||||
allowedProofTypes: string[];
|
||||
uiPreferences: Record<string, string | number | boolean>;
|
||||
smtpEnabled: boolean;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUser: string | null;
|
||||
smtpPassword: string | null;
|
||||
smtpFromName: string | null;
|
||||
smtpFromEmail: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReportPreferences {
|
||||
enabled: boolean;
|
||||
frequency: 'monthly' | 'yearly' | 'threshold';
|
||||
thresholdAmount: number;
|
||||
sendToEmail: string | null;
|
||||
categoryIds: string[];
|
||||
}
|
||||
79
web/src/app/shared/ui/category-picker.component.ts
Normal file
79
web/src/app/shared/ui/category-picker.component.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, HostListener, computed, input, output, signal } from '@angular/core';
|
||||
import type { Category } from '../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<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 {
|
||||
readonly items = input<Category[]>([]);
|
||||
readonly selectedIds = input<string[]>([]);
|
||||
readonly placeholder = input('Wybierz kategorie');
|
||||
readonly changed = output<string[]>();
|
||||
|
||||
readonly open = signal(false);
|
||||
readonly selectedItems = computed(() => this.items().filter((item) => this.selectedIds().includes(item.id)));
|
||||
|
||||
toggle(event?: Event) {
|
||||
event?.stopPropagation();
|
||||
this.open.update((value) => !value);
|
||||
}
|
||||
|
||||
isSelected(id: string) {
|
||||
return this.selectedIds().includes(id);
|
||||
}
|
||||
|
||||
toggleItem(id: string) {
|
||||
const next = this.isSelected(id)
|
||||
? this.selectedIds().filter((item) => item !== id)
|
||||
: [...this.selectedIds(), id];
|
||||
this.changed.emit(next);
|
||||
}
|
||||
|
||||
@HostListener('document:click')
|
||||
closeOnOutsideClick() {
|
||||
this.open.set(false);
|
||||
}
|
||||
}
|
||||
43
web/src/app/shared/ui/toast-outlet.component.ts
Normal file
43
web/src/app/shared/ui/toast-outlet.component.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ToastItem, ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast-outlet',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="toast-host position-fixed top-0 end-0 p-3">
|
||||
@for (item of toast.items(); track item.id) {
|
||||
<div class="toast show ec-toast"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
[class.ec-toast-success]="item.tone === 'success'"
|
||||
[class.ec-toast-danger]="item.tone === 'danger'"
|
||||
[class.ec-toast-warning]="item.tone === 'warning'"
|
||||
[class.ec-toast-info]="item.tone === 'info'">
|
||||
<div class="toast-header">
|
||||
<span class="ec-toast-dot me-2" [ngClass]="toneDotClass(item)"></span>
|
||||
<strong class="me-auto">{{ item.title }}</strong>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="toast.dismiss(item.id)"></button>
|
||||
</div>
|
||||
<div class="toast-body">{{ item.message }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ToastOutletComponent {
|
||||
readonly toast = inject(ToastService);
|
||||
|
||||
toneDotClass(item: ToastItem) {
|
||||
return item.tone === 'success'
|
||||
? 'bg-success'
|
||||
: item.tone === 'danger'
|
||||
? 'bg-danger'
|
||||
: item.tone === 'warning'
|
||||
? 'bg-warning'
|
||||
: 'bg-info';
|
||||
}
|
||||
}
|
||||
5
web/src/environments/environment.ts
Normal file
5
web/src/environments/environment.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: '/api',
|
||||
assetBaseUrl: ''
|
||||
};
|
||||
12
web/src/index.html
Normal file
12
web/src/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Expense Control</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
web/src/main.ts
Normal file
6
web/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import '@tabler/core/dist/js/tabler.min.js';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { App } from './app/app';
|
||||
import { appConfig } from './app/app.config';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||
324
web/src/styles.scss
Normal file
324
web/src/styles.scss
Normal file
@@ -0,0 +1,324 @@
|
||||
@import "@tabler/core/dist/css/tabler.min.css";
|
||||
|
||||
:root {
|
||||
--tblr-primary: #111827;
|
||||
--tblr-primary-rgb: 17, 24, 39;
|
||||
--tblr-border-radius: 0.75rem;
|
||||
--tblr-border-radius-lg: 0.9rem;
|
||||
--tblr-border-radius-sm: 0.45rem;
|
||||
--tblr-card-border-radius: 0.9rem;
|
||||
--tblr-body-bg: #f4f6f8;
|
||||
--ec-shell-bg: #f4f6f8;
|
||||
--ec-card-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
|
||||
--ec-card-border: rgba(15, 23, 42, 0.08);
|
||||
--ec-navbar-bg: rgba(255, 255, 255, 0.95);
|
||||
--ec-subnav-bg: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--ec-shell-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--tblr-primary: #0f172a;
|
||||
--tblr-primary-rgb: 15, 23, 42;
|
||||
--tblr-body-bg: #06080d;
|
||||
--ec-shell-bg: #06080d;
|
||||
--ec-card-shadow: 0 2px 10px rgba(0, 0, 0, 0.22);
|
||||
--ec-card-border: rgba(255, 255, 255, 0.06);
|
||||
--ec-navbar-bg: rgba(6, 8, 13, 0.96);
|
||||
--ec-subnav-bg: rgba(10, 13, 20, 0.96);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.pv-navbar,
|
||||
.pv-subnav {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.pv-navbar {
|
||||
background: var(--ec-navbar-bg);
|
||||
border-bottom: 1px solid var(--ec-card-border);
|
||||
}
|
||||
|
||||
.pv-subnav {
|
||||
background: var(--ec-subnav-bg);
|
||||
border-bottom: 1px solid var(--ec-card-border);
|
||||
}
|
||||
|
||||
.pv-subnav-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding-block: 0.65rem;
|
||||
}
|
||||
|
||||
.pv-subnav-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pv-subnav-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pv-subnav .nav-link {
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--tblr-secondary);
|
||||
}
|
||||
|
||||
.pv-subnav .nav-link.active {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .pv-subnav .nav-link.active {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pv-navbar-user {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card,
|
||||
.pv-card,
|
||||
.login-card,
|
||||
.modal-content,
|
||||
.alert,
|
||||
.toast {
|
||||
border: 1px solid var(--ec-card-border);
|
||||
box-shadow: var(--ec-card-shadow);
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.card-body,
|
||||
.login-card .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card .card-title,
|
||||
.login-card .card-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ec-page-header .page-title,
|
||||
.page-title,
|
||||
.display-6,
|
||||
.h1,
|
||||
.h2,
|
||||
.h3 {
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.ec-page-header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ec-chart-wrap {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.ec-chart-wrap-sm {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.login-page-shell {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.03), transparent 30%),
|
||||
var(--ec-shell-bg);
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
min-height: calc(100vh - 3rem);
|
||||
}
|
||||
|
||||
.login-card-enhanced {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-input-stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login-submit-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.login-footer-note {
|
||||
margin-top: 1rem;
|
||||
color: var(--tblr-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.card-table tbody tr:last-child td {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.toast-host {
|
||||
z-index: 1080;
|
||||
width: min(420px, 100vw);
|
||||
top: 5rem !important;
|
||||
}
|
||||
|
||||
.toast-host .toast {
|
||||
opacity: 1;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.ec-segmented-control {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ec-segmented-control .nav-link {
|
||||
min-width: 3rem;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ec-segmented-control .nav-link.active {
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--tblr-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.login-toolbar-controls {
|
||||
min-width: min(100%, 17rem);
|
||||
}
|
||||
|
||||
.ec-toast {
|
||||
width: min(420px, calc(100vw - 1.5rem));
|
||||
border-top: 3px solid transparent;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.ec-toast .toast-header,
|
||||
.ec-toast .toast-body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ec-toast-success {
|
||||
border-top-color: var(--tblr-success);
|
||||
}
|
||||
|
||||
.ec-toast-danger {
|
||||
border-top-color: var(--tblr-danger);
|
||||
}
|
||||
|
||||
.ec-toast-warning {
|
||||
border-top-color: var(--tblr-warning);
|
||||
}
|
||||
|
||||
.ec-toast-info {
|
||||
border-top-color: var(--tblr-info);
|
||||
}
|
||||
|
||||
.ec-toast-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ec-proof-preview {
|
||||
width: 100%;
|
||||
max-height: 75vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.ec-card-header-muted {
|
||||
color: var(--tblr-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ec-color-swatch {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
border: 1px solid var(--ec-card-border);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.pv-subnav-shell,
|
||||
.pv-subnav-main {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pv-subnav-tabs {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-chart-wrap {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.ec-chart-wrap-sm {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-body {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pv-navbar-user {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ec-page-header-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.ec-page-header-actions > .btn {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.toast-host {
|
||||
top: 4.5rem !important;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
6
web/tsconfig.app.json
Normal file
6
web/tsconfig.app.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "outDir": "./out-tsc/app", "types": [] },
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
17
web/tsconfig.json
Normal file
17
web/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": { "strictTemplates": true }
|
||||
}
|
||||
Reference in New Issue
Block a user