zmiany cd
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,3 +12,5 @@ web/.angular/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
docker-data/postgres/*
|
||||||
|
docker-data/uploads/*
|
||||||
51
api/package-lock.json
generated
51
api/package-lock.json
generated
@@ -604,6 +604,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -618,6 +621,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -632,6 +638,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -646,6 +655,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -660,6 +672,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -674,6 +689,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -688,6 +706,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -702,6 +723,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -716,6 +740,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -730,6 +757,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -744,6 +774,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -758,6 +791,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -772,6 +808,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1731,9 +1770,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.0",
|
"version": "17.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
||||||
"integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==",
|
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3905,9 +3944,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
import { AppSetting } from '../entities/AppSetting.js';
|
import { AppSetting } from '../entities/AppSetting.js';
|
||||||
|
import { Budget } from '../entities/Budget.js';
|
||||||
|
import { Category } from '../entities/Category.js';
|
||||||
|
import { Expense } from '../entities/Expense.js';
|
||||||
|
import { Merchant } from '../entities/Merchant.js';
|
||||||
|
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||||
import { User } from '../entities/User.js';
|
import { User } from '../entities/User.js';
|
||||||
import { sanitizeUser } from '../services/auth.service.js';
|
import { sanitizeUser } from '../services/auth.service.js';
|
||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
@@ -30,8 +38,17 @@ const userUpdateSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
const rootPackage = require(path.resolve(process.cwd(), '..', 'package.json')) as { version?: string };
|
||||||
|
const apiPackage = require(path.resolve(process.cwd(), 'package.json')) as { version?: string };
|
||||||
|
const webPackage = require(path.resolve(process.cwd(), '..', 'web', 'package.json')) as { version?: string };
|
||||||
|
|
||||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||||
const userRepo = () => AppDataSource.getRepository(User);
|
const userRepo = () => AppDataSource.getRepository(User);
|
||||||
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||||
|
const merchantRepo = () => AppDataSource.getRepository(Merchant);
|
||||||
|
const budgetRepo = () => AppDataSource.getRepository(Budget);
|
||||||
|
const recurringRepo = () => AppDataSource.getRepository(RecurringExpense);
|
||||||
|
|
||||||
const sanitizeSettings = (item: AppSetting) => ({
|
const sanitizeSettings = (item: AppSetting) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -145,3 +162,39 @@ export const testSmtp = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
|
|
||||||
return res.json({ message: 'SMTP test message was sent' });
|
return res.json({ message: 'SMTP test message was sent' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getSystemInfo = async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const settings = await getSettingsEntity();
|
||||||
|
const [users, expenses, categories, merchants, budgets, recurring, usersWithIntegrations] = await Promise.all([
|
||||||
|
userRepo().count(),
|
||||||
|
expenseRepo().count(),
|
||||||
|
categoryRepo().count(),
|
||||||
|
merchantRepo().count(),
|
||||||
|
budgetRepo().count(),
|
||||||
|
recurringRepo().count(),
|
||||||
|
userRepo().find({ select: { id: true, shoppingListIntegration: true } })
|
||||||
|
]);
|
||||||
|
const shoppingIntegrations = usersWithIntegrations.filter((user) => Boolean(user.shoppingListIntegration?.enabled)).length;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
item: {
|
||||||
|
appName: settings?.appName ?? 'Expense Control',
|
||||||
|
suiteVersion: rootPackage.version ?? '0.0.0',
|
||||||
|
apiVersion: apiPackage.version ?? '0.0.0',
|
||||||
|
webVersion: webPackage.version ?? '0.0.0',
|
||||||
|
nodeVersion: process.version,
|
||||||
|
environment: process.env.NODE_ENV ?? 'development',
|
||||||
|
database: env.DB_TYPE,
|
||||||
|
uploadDir: env.UPLOAD_DIR,
|
||||||
|
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||||
|
smtpConfigured: Boolean(settings?.smtpEnabled && settings?.smtpHost && settings?.smtpFromEmail),
|
||||||
|
counters: { users, expenses, categories, merchants, budgets, recurring, shoppingIntegrations },
|
||||||
|
sources: {
|
||||||
|
appRepository: 'https://git.linuxiarz.pl/gru/expense-control',
|
||||||
|
shoppingListRepository: 'https://git.linuxiarz.pl/gru/lista_zakupowa_live',
|
||||||
|
apiBasePath: '/api'
|
||||||
|
},
|
||||||
|
checkedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
|
getSystemInfo,
|
||||||
listUsers,
|
listUsers,
|
||||||
testSmtp,
|
testSmtp,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
@@ -16,3 +17,4 @@ adminRouter.put('/settings', updateSettings);
|
|||||||
adminRouter.get('/users', listUsers);
|
adminRouter.get('/users', listUsers);
|
||||||
adminRouter.patch('/users/:id', updateUser);
|
adminRouter.patch('/users/:id', updateUser);
|
||||||
adminRouter.post('/test-smtp', testSmtp);
|
adminRouter.post('/test-smtp', testSmtp);
|
||||||
|
adminRouter.get('/system-info', getSystemInfo);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:18-alpine
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- ./docker-data/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- ./docker-data/uploads:/app/uploads
|
||||||
expose:
|
expose:
|
||||||
- "4000"
|
- "4000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -61,9 +61,5 @@ services:
|
|||||||
- "${PUBLIC_HTTP_PORT:-8080}:80"
|
- "${PUBLIC_HTTP_PORT:-8080}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./reverse-proxy/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
|
- ./reverse-proxy/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||||
- uploads_data:/srv/uploads:ro
|
- ./docker-data/uploads:/srv/uploads:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
uploads_data:
|
|
||||||
|
|||||||
11029
package-lock.json
generated
11029
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80 default_server;
|
||||||
server_name ${SERVER_NAME};
|
server_name ${SERVER_NAME};
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ server {
|
|||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
alias /srv/uploads/;
|
alias /srv/uploads/;
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 7d;
|
expires 30d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
server_tokens off;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -9,8 +10,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
|
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
|
||||||
expires 7d;
|
expires 1y;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
web/package-lock.json
generated
66
web/package-lock.json
generated
@@ -625,9 +625,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -649,9 +649,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2689,9 +2689,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@npmcli/agent/node_modules/lru-cache": {
|
"node_modules/@npmcli/agent/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2742,9 +2742,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@npmcli/git/node_modules/lru-cache": {
|
"node_modules/@npmcli/git/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4342,9 +4342,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.14",
|
"version": "2.10.15",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
||||||
"integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==",
|
"integrity": "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4523,9 +4523,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cacache/node_modules/lru-cache": {
|
"node_modules/cacache/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4564,9 +4564,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001785",
|
"version": "1.0.30001786",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
||||||
"integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==",
|
"integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4931,9 +4931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5643,9 +5643,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.10",
|
"version": "4.12.11",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
|
||||||
"integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==",
|
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5666,9 +5666,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hosted-git-info/node_modules/lru-cache": {
|
"node_modules/hosted-git-info/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7107,9 +7107,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
"version": "11.2.7",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
|
||||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
"integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { AppSettings, User } from '../../shared/models';
|
import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@@ -26,4 +26,8 @@ export class AdminService {
|
|||||||
testSmtp(to: string) {
|
testSmtp(to: string) {
|
||||||
return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to });
|
return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSystemInfo() {
|
||||||
|
return this.http.get<{ item: AdminSystemInfo }>(`${environment.apiBaseUrl}/admin/system-info`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
web/src/app/core/services/api-status.service.ts
Normal file
31
web/src/app/core/services/api-status.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { interval, of } from 'rxjs';
|
||||||
|
import { catchError, startWith, switchMap } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export type ApiConnectionState = 'online' | 'offline';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApiStatusService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
readonly state = signal<ApiConnectionState>('online');
|
||||||
|
readonly checkedAt = signal<string | null>(null);
|
||||||
|
readonly label = computed(() => (this.state() === 'online' ? 'API online' : 'API offline'));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
interval(30000)
|
||||||
|
.pipe(
|
||||||
|
startWith(0),
|
||||||
|
switchMap(() =>
|
||||||
|
this.http.get<{ status: string }>(`${environment.apiBaseUrl}/health`).pipe(
|
||||||
|
catchError(() => of({ status: 'offline' }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.state.set(response.status === 'ok' ? 'online' : 'offline');
|
||||||
|
this.checkedAt.set(new Date().toISOString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,9 +56,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'login.email': 'E-mail',
|
'login.email': 'E-mail',
|
||||||
'login.password': 'Hasło',
|
'login.password': 'Hasło',
|
||||||
'login.fullName': 'Imię i nazwisko',
|
'login.fullName': 'Imię i nazwisko',
|
||||||
'login.subtitle': 'Zaloguj się, aby zarządzać wydatkami, kontrahentami i raportami.',
|
'login.subtitle': 'Zaloguj się',
|
||||||
'register.subtitle': 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.',
|
'register.subtitle': 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.',
|
||||||
'login.footer': 'Użyj swojego konta, aby zarządzać wydatkami, raportami i uprawnieniami.',
|
'login.footer': 'Użyj swojego konta.',
|
||||||
'register.footer': 'Po utworzeniu konta od razu wrócisz do logowania.',
|
'register.footer': 'Po utworzeniu konta od razu wrócisz do logowania.',
|
||||||
'login.needAccount': 'Nie masz konta?',
|
'login.needAccount': 'Nie masz konta?',
|
||||||
'login.haveAccount': 'Masz już konto?',
|
'login.haveAccount': 'Masz już konto?',
|
||||||
@@ -321,6 +321,35 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
|
|
||||||
'nav.integrations': 'Integracje',
|
'nav.integrations': 'Integracje',
|
||||||
|
|
||||||
|
'nav.navigation': 'Nawigacja',
|
||||||
|
'nav.toggleMenu': 'Przełącz menu',
|
||||||
|
|
||||||
|
'admin.techTitle': 'Dane techniczne',
|
||||||
|
'admin.techSubtitle': 'Informacje widoczne tylko dla administratora o aplikacji, API i stanie środowiska.',
|
||||||
|
'admin.appVersion': 'Wersja aplikacji',
|
||||||
|
'admin.database': 'Baza danych',
|
||||||
|
'admin.smtpReady': 'SMTP gotowe',
|
||||||
|
'admin.smtpNotReady': 'SMTP niegotowe',
|
||||||
|
'admin.kpi.users': 'Użytkownicy',
|
||||||
|
'admin.kpi.expenses': 'Wydatki',
|
||||||
|
'admin.kpi.categories': 'Kategorie',
|
||||||
|
'admin.kpi.merchants': 'Kontrahenci',
|
||||||
|
'admin.kpi.budgets': 'Budżety',
|
||||||
|
'admin.kpi.recurring': 'Cykliczne',
|
||||||
|
'admin.kpi.integrations': 'Integracje users',
|
||||||
|
|
||||||
|
'integrations.projectLink': 'Repozytorium list zakupowych',
|
||||||
|
'integrations.selfHostedTitle': 'Połączenie self-hosted',
|
||||||
|
'integrations.selfHostedHint': 'Tutaj ustawiasz URL i token do osobnej, samodzielnie hostowanej aplikacji list zakupowych.',
|
||||||
|
'integrations.importExplainTitle': 'Jak działa import',
|
||||||
|
'integrations.importExplainBody': 'Import z list zakupowych zapisuje dane jako zwykły lokalny wydatek w tej aplikacji. Możesz zaimportować całą listę jako 1 wydatek albo pojedyncze pozycje osobno.',
|
||||||
|
|
||||||
|
'footer.apiOnline': 'API online',
|
||||||
|
'footer.apiOffline': 'API offline',
|
||||||
|
'footer.selfHosted': 'Self-hosted expense stack',
|
||||||
|
'footer.source': 'Kod źródłowy expense-control',
|
||||||
|
'footer.shoppingSource': 'Kod źródłowy list zakupowych',
|
||||||
|
|
||||||
'action.testConnection': 'Test połączenia',
|
'action.testConnection': 'Test połączenia',
|
||||||
'action.refresh': 'Odśwież',
|
'action.refresh': 'Odśwież',
|
||||||
|
|
||||||
@@ -437,9 +466,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'login.email': 'Email',
|
'login.email': 'Email',
|
||||||
'login.password': 'Password',
|
'login.password': 'Password',
|
||||||
'login.fullName': 'Full name',
|
'login.fullName': 'Full name',
|
||||||
'login.subtitle': 'Sign in to manage expenses, merchants and reports.',
|
'login.subtitle': 'Sign in',
|
||||||
'register.subtitle': 'Create an account and start collecting proofs and analytics.',
|
'register.subtitle': 'Create an account and start collecting proofs and analytics.',
|
||||||
'login.footer': 'Use your account to manage expenses, reports and permissions.',
|
'login.footer': 'Use your account.',
|
||||||
'register.footer': 'After creating an account you will be taken back to sign in.',
|
'register.footer': 'After creating an account you will be taken back to sign in.',
|
||||||
'login.needAccount': 'Need an account?',
|
'login.needAccount': 'Need an account?',
|
||||||
'login.haveAccount': 'Already registered?',
|
'login.haveAccount': 'Already registered?',
|
||||||
@@ -702,6 +731,35 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
|
|
||||||
'nav.integrations': 'Integrations',
|
'nav.integrations': 'Integrations',
|
||||||
|
|
||||||
|
'nav.navigation': 'Navigation',
|
||||||
|
'nav.toggleMenu': 'Toggle menu',
|
||||||
|
|
||||||
|
'admin.techTitle': 'Technical details',
|
||||||
|
'admin.techSubtitle': 'Admin-only information about the app, API and runtime environment.',
|
||||||
|
'admin.appVersion': 'App version',
|
||||||
|
'admin.database': 'Database',
|
||||||
|
'admin.smtpReady': 'SMTP ready',
|
||||||
|
'admin.smtpNotReady': 'SMTP not ready',
|
||||||
|
'admin.kpi.users': 'Users',
|
||||||
|
'admin.kpi.expenses': 'Expenses',
|
||||||
|
'admin.kpi.categories': 'Categories',
|
||||||
|
'admin.kpi.merchants': 'Merchants',
|
||||||
|
'admin.kpi.budgets': 'Budgets',
|
||||||
|
'admin.kpi.recurring': 'Recurring',
|
||||||
|
'admin.kpi.integrations': 'User integrations',
|
||||||
|
|
||||||
|
'integrations.projectLink': 'Shopping list repository',
|
||||||
|
'integrations.selfHostedTitle': 'Self-hosted connection',
|
||||||
|
'integrations.selfHostedHint': 'Set the URL and token for the separate self-hosted shopping list application here.',
|
||||||
|
'integrations.importExplainTitle': 'How import works',
|
||||||
|
'integrations.importExplainBody': 'Importing from shopping lists creates a normal local expense in this app. You can import the whole list as one expense or import single entries separately.',
|
||||||
|
|
||||||
|
'footer.apiOnline': 'API online',
|
||||||
|
'footer.apiOffline': 'API offline',
|
||||||
|
'footer.selfHosted': 'Self-hosted expense stack',
|
||||||
|
'footer.source': 'expense-control source',
|
||||||
|
'footer.shoppingSource': 'shopping-list source',
|
||||||
|
|
||||||
'action.testConnection': 'Test connection',
|
'action.testConnection': 'Test connection',
|
||||||
'action.refresh': 'Refresh',
|
'action.refresh': 'Refresh',
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AdminService } from '../../core/services/admin.service';
|
|||||||
import { AppSettingsService } from '../../core/services/app-settings.service';
|
import { AppSettingsService } from '../../core/services/app-settings.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { UiService } from '../../core/services/ui.service';
|
import { UiService } from '../../core/services/ui.service';
|
||||||
import type { AppSettings, User } from '../../shared/models';
|
import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
@@ -21,6 +21,62 @@ import type { AppSettings, User } from '../../shared/models';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (systemInfo()) {
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card pv-card overflow-hidden ec-accent-card ec-accent-card-info">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title mb-1">{{ ui.t('admin.techTitle') }}</h3>
|
||||||
|
<div class="text-secondary small">{{ ui.t('admin.techSubtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-info">{{ systemInfo()!.environment }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('admin.appVersion') }}</div><div class="ec-stat-value">{{ systemInfo()!.suiteVersion }}</div></div></div>
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">API</div><div class="ec-stat-value">{{ systemInfo()!.apiVersion }}</div></div></div>
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Web</div><div class="ec-stat-value">{{ systemInfo()!.webVersion }}</div></div></div>
|
||||||
|
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Node.js</div><div class="ec-stat-value">{{ systemInfo()!.nodeVersion }}</div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-vcenter mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="text-secondary">{{ ui.t('admin.database') }}</td><td class="fw-semibold">{{ systemInfo()!.database }}</td></tr>
|
||||||
|
<tr><td class="text-secondary">Upload dir</td><td class="fw-semibold text-break">{{ systemInfo()!.uploadDir }}</td></tr>
|
||||||
|
<tr><td class="text-secondary">{{ ui.t('admin.registration') }}</td><td><span class="badge" [class.bg-success]="systemInfo()!.registrationEnabled" [class.bg-secondary]="!systemInfo()!.registrationEnabled">{{ systemInfo()!.registrationEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td></tr>
|
||||||
|
<tr><td class="text-secondary">SMTP</td><td><span class="badge" [class.bg-success]="systemInfo()!.smtpConfigured" [class.bg-warning]="!systemInfo()!.smtpConfigured">{{ systemInfo()!.smtpConfigured ? ui.t('admin.smtpReady') : ui.t('admin.smtpNotReady') }}</span></td></tr>
|
||||||
|
<tr><td class="text-secondary">API base</td><td class="fw-semibold">{{ systemInfo()!.sources.apiBasePath }}</td></tr>
|
||||||
|
<tr><td class="text-secondary">{{ ui.t('table.date') }}</td><td class="fw-semibold">{{ systemInfo()!.checkedAt | date:'yyyy-MM-dd HH:mm:ss' }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.users') }}</span><strong>{{ systemInfo()!.counters.users }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.expenses') }}</span><strong>{{ systemInfo()!.counters.expenses }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.categories') }}</span><strong>{{ systemInfo()!.counters.categories }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.merchants') }}</span><strong>{{ systemInfo()!.counters.merchants }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.budgets') }}</span><strong>{{ systemInfo()!.counters.budgets }}</strong></div></div>
|
||||||
|
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.recurring') }}</span><strong>{{ systemInfo()!.counters.recurring }}</strong></div></div>
|
||||||
|
<div class="col-12"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.integrations') }}</span><strong>{{ systemInfo()!.counters.shoppingIntegrations }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 d-flex gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="https://git.linuxiarz.pl/gru/expense-control" target="_blank" rel="noreferrer">{{ ui.t('footer.source') }}</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('footer.shoppingSource') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row row-cards align-items-start">
|
<div class="row row-cards align-items-start">
|
||||||
<div class="col-xl-5">
|
<div class="col-xl-5">
|
||||||
<div class="card pv-card overflow-hidden">
|
<div class="card pv-card overflow-hidden">
|
||||||
@@ -130,6 +186,7 @@ export class AdminComponent implements OnInit {
|
|||||||
|
|
||||||
readonly users = signal<User[]>([]);
|
readonly users = signal<User[]>([]);
|
||||||
readonly settings = signal<AppSettings | null>(null);
|
readonly settings = signal<AppSettings | null>(null);
|
||||||
|
readonly systemInfo = signal<AdminSystemInfo | null>(null);
|
||||||
readonly saving = signal(false);
|
readonly saving = signal(false);
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group({
|
readonly form = this.fb.nonNullable.group({
|
||||||
@@ -147,9 +204,7 @@ export class AdminComponent implements OnInit {
|
|||||||
smtpFromEmail: ['']
|
smtpFromEmail: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() { this.load(); }
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
this.admin.getSettings().subscribe({
|
this.admin.getSettings().subscribe({
|
||||||
@@ -174,40 +229,40 @@ export class AdminComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
|
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
|
||||||
|
this.admin.getSystemInfo().subscribe({ next: (response) => this.systemInfo.set(response.item) });
|
||||||
}
|
}
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
const raw = this.form.getRawValue();
|
const raw = this.form.getRawValue();
|
||||||
this.admin
|
this.admin.updateSettings({
|
||||||
.updateSettings({
|
appName: raw.appName,
|
||||||
appName: raw.appName,
|
defaultCurrency: raw.defaultCurrency,
|
||||||
defaultCurrency: raw.defaultCurrency,
|
registrationEnabled: raw.registrationEnabled,
|
||||||
registrationEnabled: raw.registrationEnabled,
|
allowedProofTypes: raw.allowedProofTypes.split(',').map((item) => item.trim()).filter(Boolean),
|
||||||
allowedProofTypes: raw.allowedProofTypes.split(',').map((item) => item.trim()).filter(Boolean),
|
uiPreferences: { theme: 'dark', density: 'comfortable', defaultStatsPeriod: 'month' },
|
||||||
uiPreferences: { theme: 'dark', density: 'comfortable', defaultStatsPeriod: 'month' },
|
smtpEnabled: raw.smtpEnabled,
|
||||||
smtpEnabled: raw.smtpEnabled,
|
smtpHost: raw.smtpHost || null,
|
||||||
smtpHost: raw.smtpHost || null,
|
smtpPort: Number(raw.smtpPort),
|
||||||
smtpPort: Number(raw.smtpPort),
|
smtpSecure: raw.smtpSecure,
|
||||||
smtpSecure: raw.smtpSecure,
|
smtpUser: raw.smtpUser || null,
|
||||||
smtpUser: raw.smtpUser || null,
|
smtpPassword: raw.smtpPassword || null,
|
||||||
smtpPassword: raw.smtpPassword || null,
|
smtpFromName: raw.smtpFromName || null,
|
||||||
smtpFromName: raw.smtpFromName || null,
|
smtpFromEmail: raw.smtpFromEmail || null
|
||||||
smtpFromEmail: raw.smtpFromEmail || null
|
}).subscribe({
|
||||||
})
|
next: (response) => {
|
||||||
.subscribe({
|
this.saving.set(false);
|
||||||
next: (response) => {
|
this.settings.set(response.item);
|
||||||
this.saving.set(false);
|
this.appSettings.applySettings(response.item);
|
||||||
this.settings.set(response.item);
|
this.toast.success(this.ui.t('admin.settingsSaved'));
|
||||||
this.appSettings.applySettings(response.item);
|
this.admin.getSystemInfo().subscribe({ next: (systemResponse) => this.systemInfo.set(systemResponse.item) });
|
||||||
this.toast.success(this.ui.t('admin.settingsSaved'));
|
},
|
||||||
},
|
error: (error) => {
|
||||||
error: (error) => {
|
this.saving.set(false);
|
||||||
this.saving.set(false);
|
this.toast.error(error.error?.message ?? this.ui.t('admin.settingsError'));
|
||||||
this.toast.error(error.error?.message ?? this.ui.t('admin.settingsError'));
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTest() {
|
sendTest() {
|
||||||
|
|||||||
@@ -35,9 +35,16 @@ const monthRange = (period: string) => {
|
|||||||
|
|
||||||
<div class="row row-cards mb-3">
|
<div class="row row-cards mb-3">
|
||||||
<div class="col-lg-5">
|
<div class="col-lg-5">
|
||||||
<div class="card overflow-hidden h-100">
|
<div class="card overflow-hidden ec-accent-card ec-accent-card-primary h-100">
|
||||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.shoppingList') }}</h3></div>
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<div class="card-body">
|
<h3 class="card-title mb-0">{{ ui.t('integrations.shoppingList') }}</h3>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('integrations.projectLink') }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.selfHostedTitle') }}</div>
|
||||||
|
<div>{{ ui.t('integrations.selfHostedHint') }}</div>
|
||||||
|
</div>
|
||||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||||
<label class="form-check">
|
<label class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" formControlName="enabled" />
|
<input class="form-check-input" type="checkbox" formControlName="enabled" />
|
||||||
@@ -100,22 +107,8 @@ const monthRange = (period: string) => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="row row-cards">
|
<div class="row row-cards">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
|
||||||
<div class="card overflow-hidden">
|
<div class="col-md-6"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-secondary">{{ ui.t('integrations.externalSpend') }}</div>
|
|
||||||
<div class="display-6">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-secondary">{{ ui.t('integrations.externalCount') }}</div>
|
|
||||||
<div class="display-6">{{ summaryCount() }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border rounded-3 p-3 bg-body-tertiary">
|
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||||
@@ -154,9 +147,14 @@ const monthRange = (period: string) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card overflow-hidden h-100">
|
<div class="card overflow-hidden ec-accent-card ec-accent-card-success h-100">
|
||||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
||||||
<div class="card-body d-grid gap-3">
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
|
||||||
|
<div>{{ ui.t('integrations.importExplainBody') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="importForm" class="row g-3">
|
<form [formGroup]="importForm" class="row g-3">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<label class="form-label">{{ ui.t('expenses.field.category') }}</label>
|
<label class="form-label">{{ ui.t('expenses.field.category') }}</label>
|
||||||
@@ -186,7 +184,7 @@ const monthRange = (period: string) => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if (selectedList()) {
|
@if (selectedList()) {
|
||||||
<div class="border rounded-3 p-3">
|
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||||
<div class="d-flex justify-content-between gap-3 flex-wrap align-items-start">
|
<div class="d-flex justify-content-between gap-3 flex-wrap align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold">{{ listTitle(selectedList()!) }}</div>
|
<div class="fw-semibold">{{ listTitle(selectedList()!) }}</div>
|
||||||
@@ -213,12 +211,7 @@ const monthRange = (period: string) => {
|
|||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-vcenter card-table mb-0">
|
<table class="table table-vcenter card-table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.actions') }}</th></tr>
|
||||||
<th>{{ ui.t('table.title') }}</th>
|
|
||||||
<th>{{ ui.t('table.date') }}</th>
|
|
||||||
<th class="text-end">{{ ui.t('table.amount') }}</th>
|
|
||||||
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (item of latestExpenses(); track $index) {
|
@for (item of latestExpenses(); track $index) {
|
||||||
@@ -246,12 +239,7 @@ const monthRange = (period: string) => {
|
|||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-vcenter card-table mb-0">
|
<table class="table table-vcenter card-table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.actions') }}</th></tr>
|
||||||
<th>{{ ui.t('table.title') }}</th>
|
|
||||||
<th>{{ ui.t('table.date') }}</th>
|
|
||||||
<th class="text-end">{{ ui.t('table.amount') }}</th>
|
|
||||||
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (item of selectedListExpenses(); track $index) {
|
@for (item of selectedListExpenses(); track $index) {
|
||||||
@@ -329,14 +317,7 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
this.integration.getSettings().subscribe({
|
this.integration.getSettings().subscribe({
|
||||||
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean; authMode: 'bearer' | 'x-api-token' | 'both'; ownerId: string | null; defaultListId: string | null } }) => {
|
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean; authMode: 'bearer' | 'x-api-token' | 'both'; ownerId: string | null; defaultListId: string | null } }) => {
|
||||||
const item = response.item;
|
const item = response.item;
|
||||||
this.form.reset({
|
this.form.reset({ enabled: item.enabled, baseUrl: item.baseUrl || '', apiToken: '', authMode: item.authMode, ownerId: item.ownerId || '', defaultListId: item.defaultListId || '' });
|
||||||
enabled: item.enabled,
|
|
||||||
baseUrl: item.baseUrl || '',
|
|
||||||
apiToken: '',
|
|
||||||
authMode: item.authMode,
|
|
||||||
ownerId: item.ownerId || '',
|
|
||||||
defaultListId: item.defaultListId || ''
|
|
||||||
});
|
|
||||||
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
|
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
|
||||||
if (this.configured()) this.refresh();
|
if (this.configured()) this.refresh();
|
||||||
},
|
},
|
||||||
@@ -347,23 +328,14 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
save() {
|
save() {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
const raw = this.form.getRawValue();
|
const raw = this.form.getRawValue();
|
||||||
this.integration
|
this.integration.updateSettings({ enabled: raw.enabled, baseUrl: raw.baseUrl || null, apiToken: raw.apiToken || undefined, authMode: raw.authMode, ownerId: raw.ownerId || null, defaultListId: raw.defaultListId || null }).subscribe({
|
||||||
.updateSettings({
|
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean } }) => {
|
||||||
enabled: raw.enabled,
|
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
|
||||||
baseUrl: raw.baseUrl || null,
|
this.toast.success(this.ui.t('integrations.saveSuccess'));
|
||||||
apiToken: raw.apiToken || undefined,
|
if (this.configured()) this.refresh();
|
||||||
authMode: raw.authMode,
|
},
|
||||||
ownerId: raw.ownerId || null,
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.saveError'))
|
||||||
defaultListId: raw.defaultListId || null
|
});
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean } }) => {
|
|
||||||
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
|
|
||||||
this.toast.success(this.ui.t('integrations.saveSuccess'));
|
|
||||||
if (this.configured()) this.refresh();
|
|
||||||
},
|
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.saveError'))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test() {
|
test() {
|
||||||
@@ -378,41 +350,22 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
const raw = this.form.getRawValue();
|
const raw = this.form.getRawValue();
|
||||||
const history = this.historyForm.getRawValue();
|
const history = this.historyForm.getRawValue();
|
||||||
const range = monthRange(history.period);
|
const range = monthRange(history.period);
|
||||||
const filters = {
|
const filters = { start_date: range.start, end_date: range.end, owner_id: raw.ownerId || undefined, list_id: raw.defaultListId || undefined };
|
||||||
start_date: range.start,
|
|
||||||
end_date: range.end,
|
|
||||||
owner_id: raw.ownerId || undefined,
|
|
||||||
list_id: raw.defaultListId || undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.integration.summary(filters).subscribe({
|
|
||||||
next: (response: ShoppingListSummary) => this.summary.set(response),
|
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError'))
|
|
||||||
});
|
|
||||||
|
|
||||||
this.integration.latest({ ...filters, limit: history.limit }).subscribe({
|
|
||||||
next: (response: { items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }) => this.latestExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)),
|
|
||||||
error: () => this.latestExpenses.set([])
|
|
||||||
});
|
|
||||||
|
|
||||||
|
this.integration.summary(filters).subscribe({ next: (response: ShoppingListSummary) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) });
|
||||||
|
this.integration.latest({ ...filters, limit: history.limit }).subscribe({ next: (response) => this.latestExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.latestExpenses.set([]) });
|
||||||
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
||||||
next: (response: { items?: ShoppingListRef[]; data?: ShoppingListRef[] }) => {
|
next: (response) => {
|
||||||
const items = this.pickItems<ShoppingListRef>(response);
|
const items = this.pickItems<ShoppingListRef>(response);
|
||||||
this.allLists.set(items);
|
this.allLists.set(items);
|
||||||
const visible = this.visibleLists(items);
|
const visible = this.visibleLists(items);
|
||||||
const currentId = String(this.selectedList()?.id ?? '');
|
const currentId = String(this.selectedList()?.id ?? '');
|
||||||
const nextSelected = visible.find((item) => String(item.id) === currentId) ?? visible[0] ?? null;
|
const nextSelected = visible.find((item) => String(item.id) === currentId) ?? visible[0] ?? null;
|
||||||
this.selectedList.set(nextSelected);
|
this.selectedList.set(nextSelected);
|
||||||
if (nextSelected) {
|
if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]);
|
||||||
this.loadListExpenses(nextSelected);
|
|
||||||
} else {
|
|
||||||
this.selectedListExpenses.set([]);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.allLists.set([]);
|
this.allLists.set([]); this.selectedList.set(null); this.selectedListExpenses.set([]);
|
||||||
this.selectedList.set(null);
|
|
||||||
this.selectedListExpenses.set([]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -426,116 +379,45 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
selectList(item: ShoppingListRef) {
|
selectList(item: ShoppingListRef) { this.selectedList.set(item); this.loadListExpenses(item); }
|
||||||
this.selectedList.set(item);
|
|
||||||
this.loadListExpenses(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
importSelectedList() {
|
importSelectedList() {
|
||||||
const list = this.selectedList();
|
const list = this.selectedList();
|
||||||
if (!list || this.importForm.invalid) return;
|
if (!list || this.importForm.invalid) return;
|
||||||
const raw = this.importForm.getRawValue();
|
const raw = this.importForm.getRawValue();
|
||||||
this.integration
|
this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: this.normalizedTags() }).subscribe({
|
||||||
.importList({
|
next: (response) => { this.toast.success(this.ui.t('integrations.importListSuccess')); this.emitWarnings(response.warnings); },
|
||||||
listId: list.id,
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||||
listTitle: this.listTitle(list),
|
});
|
||||||
listCreatedAt: this.listCreatedAt(list),
|
|
||||||
categoryId: raw.categoryId,
|
|
||||||
status: raw.status,
|
|
||||||
merchant: raw.merchant || this.listTitle(list),
|
|
||||||
tags: this.normalizedTags()
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (response: { item: unknown; warnings?: string[] }) => {
|
|
||||||
this.toast.success(this.ui.t('integrations.importListSuccess'));
|
|
||||||
this.emitWarnings(response.warnings);
|
|
||||||
},
|
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
importItem(item: ShoppingListExpenseItem) {
|
importItem(item: ShoppingListExpenseItem) {
|
||||||
if (this.importForm.invalid) return;
|
if (this.importForm.invalid) return;
|
||||||
const raw = this.importForm.getRawValue();
|
const raw = this.importForm.getRawValue();
|
||||||
this.integration
|
this.integration.importItem({ expenseId: item.expense_id ?? item.id ?? null, listId: item.list?.id ?? this.selectedList()?.id ?? null, listTitle: this.listTitle(item.list ?? this.selectedList()), categoryId: raw.categoryId, status: raw.status, title: this.itemTitle(item), amount: this.itemAmount(item), expenseDate: this.itemDate(item), merchant: raw.merchant || this.listTitle(item.list ?? this.selectedList()), ownerName: this.ownerName(item), tags: this.normalizedTags() }).subscribe({
|
||||||
.importItem({
|
next: (response) => { this.toast.success(this.ui.t('integrations.importItemSuccess')); this.emitWarnings(response.warnings); },
|
||||||
expenseId: item.expense_id ?? item.id ?? null,
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||||
listId: item.list?.id ?? this.selectedList()?.id ?? null,
|
|
||||||
listTitle: this.listTitle(item.list ?? this.selectedList()),
|
|
||||||
categoryId: raw.categoryId,
|
|
||||||
status: raw.status,
|
|
||||||
title: this.itemTitle(item),
|
|
||||||
amount: this.itemAmount(item),
|
|
||||||
expenseDate: this.itemDate(item),
|
|
||||||
merchant: raw.merchant || this.listTitle(item.list ?? this.selectedList()),
|
|
||||||
ownerName: this.ownerName(item),
|
|
||||||
tags: this.normalizedTags()
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (response: { item: unknown; warnings?: string[] }) => {
|
|
||||||
this.toast.success(this.ui.t('integrations.importItemSuccess'));
|
|
||||||
this.emitWarnings(response.warnings);
|
|
||||||
},
|
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelectedList(item: ShoppingListRef) {
|
|
||||||
return String(this.selectedList()?.id ?? '') === String(item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
listTitle(item?: ShoppingListRef | null) {
|
|
||||||
return item?.title || item?.name || (item?.id !== undefined ? String(item.id) : '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
listOwner(item?: ShoppingListRef | null) {
|
|
||||||
return item?.owner?.username || item?.owner?.fullName || item?.owner?.name || item?.owner?.email || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
listCreatedAt(item?: ShoppingListRef | null) {
|
|
||||||
return item?.created_at || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
itemTitle(item: ShoppingListExpenseItem) {
|
|
||||||
return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
itemDate(item: ShoppingListExpenseItem) {
|
|
||||||
return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
itemAmount(item: ShoppingListExpenseItem) {
|
|
||||||
return Number(item.amount ?? item.total ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
ownerName(item: ShoppingListExpenseItem) {
|
|
||||||
return item.owner?.fullName || item.owner?.name || item.owner?.username || item.owner?.email || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadListExpenses(item: ShoppingListRef) {
|
|
||||||
const limit = this.historyForm.controls.limit.value;
|
|
||||||
this.integration.listExpenses(item.id, limit).subscribe({
|
|
||||||
next: (response: { items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)),
|
|
||||||
error: () => this.selectedListExpenses.set([])
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSelectedList(item: ShoppingListRef) { return String(this.selectedList()?.id ?? '') === String(item.id); }
|
||||||
|
listTitle(item?: ShoppingListRef | null) { return item?.title || item?.name || (item?.id !== undefined ? String(item.id) : '-'); }
|
||||||
|
listOwner(item?: ShoppingListRef | null) { return item?.owner?.username || item?.owner?.fullName || item?.owner?.name || item?.owner?.email || null; }
|
||||||
|
listCreatedAt(item?: ShoppingListRef | null) { return item?.created_at || null; }
|
||||||
|
itemTitle(item: ShoppingListExpenseItem) { return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`; }
|
||||||
|
itemDate(item: ShoppingListExpenseItem) { return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10); }
|
||||||
|
itemAmount(item: ShoppingListExpenseItem) { return Number(item.amount ?? item.total ?? 0); }
|
||||||
|
ownerName(item: ShoppingListExpenseItem) { return item.owner?.fullName || item.owner?.name || item.owner?.username || item.owner?.email || null; }
|
||||||
|
|
||||||
|
private loadListExpenses(item: ShoppingListRef) {
|
||||||
|
const limit = this.historyForm.controls.limit.value;
|
||||||
|
this.integration.listExpenses(item.id, limit).subscribe({ next: (response) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.selectedListExpenses.set([]) });
|
||||||
|
}
|
||||||
|
|
||||||
private normalizedTags() {
|
private normalizedTags() {
|
||||||
return Array.from(
|
return Array.from(new Set(this.importForm.controls.tags.value.split(',').map((item) => item.trim()).filter(Boolean)));
|
||||||
new Set(
|
|
||||||
this.importForm.controls.tags.value
|
|
||||||
.split(',')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitWarnings(warnings?: string[]) {
|
private emitWarnings(warnings?: string[]) { (warnings ?? []).forEach((message) => this.toast.warning(message)); }
|
||||||
(warnings ?? []).forEach((message) => this.toast.warning(message));
|
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; }
|
||||||
}
|
|
||||||
|
|
||||||
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) {
|
|
||||||
return response.items ?? response.data ?? [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, inject } from '@angular/core';
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
import { AuthService } from '../core/services/auth.service';
|
import { ApiStatusService } from '../core/services/api-status.service';
|
||||||
import { AppSettingsService } from '../core/services/app-settings.service';
|
import { AppSettingsService } from '../core/services/app-settings.service';
|
||||||
|
import { AuthService } from '../core/services/auth.service';
|
||||||
import { UiService } from '../core/services/ui.service';
|
import { UiService } from '../core/services/ui.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -12,8 +13,14 @@ import { UiService } from '../core/services/ui.service';
|
|||||||
template: `
|
template: `
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="navbar navbar-expand-md d-print-none pv-navbar">
|
<header class="navbar navbar-expand-md d-print-none pv-navbar">
|
||||||
<div class="container-xl gap-3">
|
<div class="container-xl gap-3 align-items-center">
|
||||||
<div class="navbar-brand navbar-brand-autodark fw-bold">{{ appSettings.appName() }}</div>
|
<div class="d-flex align-items-center gap-3 flex-grow-1 min-w-0">
|
||||||
|
<button class="btn btn-icon btn-outline-secondary d-md-none" type="button" (click)="toggleMenu()" [attr.aria-label]="ui.t('nav.toggleMenu')">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="navbar-brand navbar-brand-autodark fw-bold text-truncate">{{ appSettings.appName() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
<div class="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')">
|
<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'" (click)="ui.setLanguage('pl')">PL</button>
|
<button class="nav-link" type="button" role="tab" [class.active]="ui.language() === 'pl'" (click)="ui.setLanguage('pl')">PL</button>
|
||||||
@@ -23,27 +30,46 @@ import { UiService } from '../core/services/ui.service';
|
|||||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'dark'" (click)="ui.setTheme('dark')">{{ ui.t('theme.dark') }}</button>
|
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'dark'" (click)="ui.setTheme('dark')">{{ ui.t('theme.dark') }}</button>
|
||||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'light'" (click)="ui.setTheme('light')">{{ ui.t('theme.light') }}</button>
|
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'light'" (click)="ui.setTheme('light')">{{ ui.t('theme.light') }}</button>
|
||||||
</nav>
|
</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>
|
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div>
|
||||||
<button class="btn btn-danger btn-sm px-3 flex-shrink-0" type="button" (click)="logout()">{{ ui.t('action.logout') }}</button>
|
<button class="btn btn-danger btn-sm px-3 flex-shrink-0" type="button" (click)="logout()">{{ ui.t('action.logout') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 flex-wrap gap-1">
|
<div class="pv-subnav" [class.is-open]="menuOpen()">
|
||||||
<a class="nav-link" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">{{ ui.t('nav.dashboard') }}</a>
|
<div class="container-xl">
|
||||||
<a class="nav-link" routerLink="/expenses" routerLinkActive="active">{{ ui.t('nav.expenses') }}</a>
|
<div class="pv-subnav-shell">
|
||||||
<a class="nav-link" routerLink="/stats" routerLinkActive="active">{{ ui.t('nav.stats') }}</a>
|
<div class="pv-subnav-main">
|
||||||
<a class="nav-link" routerLink="/cashflow" routerLinkActive="active">{{ ui.t('nav.cashflow') }}</a>
|
<div class="ec-nav-caption d-md-none">{{ ui.t('nav.navigation') }}</div>
|
||||||
<a class="nav-link" routerLink="/budgets" routerLinkActive="active">{{ ui.t('nav.budgets') }}</a>
|
<nav class="pv-subnav-tabs nav nav-pills gap-1">
|
||||||
<a class="nav-link" routerLink="/recurring" routerLinkActive="active">{{ ui.t('nav.recurring') }}</a>
|
@for (item of navItems(); track item.path) {
|
||||||
<a class="nav-link" routerLink="/merchants" routerLinkActive="active">{{ ui.t('nav.merchants') }}</a>
|
<a class="nav-link" [routerLink]="item.path" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: !!item.exact }" (click)="closeMenu()">{{ item.label }}</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>
|
</nav>
|
||||||
<a class="nav-link" routerLink="/integrations" routerLinkActive="active">{{ ui.t('nav.integrations') }}</a>
|
</div>
|
||||||
@if (auth.isAdmin()) { <a class="nav-link" routerLink="/admin" routerLinkActive="active">{{ ui.t('nav.admin') }}</a> }
|
</div>
|
||||||
</nav></div></div></div></div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-wrapper"><div class="page-body"><div class="container-xl"><router-outlet></router-outlet></div></div></div>
|
<div class="page-wrapper">
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl"><router-outlet></router-outlet></div>
|
||||||
|
</div>
|
||||||
|
<footer class="ec-footer border-top">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="ec-footer-shell">
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="badge" [class.bg-success]="apiStatus.state() === 'online'" [class.bg-danger]="apiStatus.state() === 'offline'">{{ apiStatus.state() === 'online' ? ui.t('footer.apiOnline') : ui.t('footer.apiOffline') }}</span>
|
||||||
|
<span class="text-secondary small">{{ ui.t('footer.selfHosted') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end text-end">
|
||||||
|
<a class="link-secondary" href="https://git.linuxiarz.pl/gru/expense-control" target="_blank" rel="noreferrer">{{ ui.t('footer.source') }}</a>
|
||||||
|
<a class="link-secondary" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('footer.shoppingSource') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -51,6 +77,24 @@ export class ShellComponent {
|
|||||||
readonly auth = inject(AuthService);
|
readonly auth = inject(AuthService);
|
||||||
readonly ui = inject(UiService);
|
readonly ui = inject(UiService);
|
||||||
readonly appSettings = inject(AppSettingsService);
|
readonly appSettings = inject(AppSettingsService);
|
||||||
|
readonly apiStatus = inject(ApiStatusService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
readonly menuOpen = signal(false);
|
||||||
|
readonly navItems = computed(() => [
|
||||||
|
{ path: '/', label: this.ui.t('nav.dashboard'), exact: true },
|
||||||
|
{ path: '/expenses', label: this.ui.t('nav.expenses') },
|
||||||
|
{ path: '/stats', label: this.ui.t('nav.stats') },
|
||||||
|
{ path: '/cashflow', label: this.ui.t('nav.cashflow') },
|
||||||
|
{ path: '/budgets', label: this.ui.t('nav.budgets') },
|
||||||
|
{ path: '/recurring', label: this.ui.t('nav.recurring') },
|
||||||
|
{ path: '/merchants', label: this.ui.t('nav.merchants') },
|
||||||
|
{ path: '/reports', label: this.ui.t('nav.reports') },
|
||||||
|
{ path: '/categories', label: this.ui.t('nav.categories') },
|
||||||
|
{ path: '/integrations', label: this.ui.t('nav.integrations') },
|
||||||
|
...(this.auth.isAdmin() ? [{ path: '/admin', label: this.ui.t('nav.admin') }] : [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
toggleMenu() { this.menuOpen.update((value) => !value); }
|
||||||
|
closeMenu() { this.menuOpen.set(false); }
|
||||||
logout() { this.auth.logout(); this.router.navigate(['/login']); }
|
logout() { this.auth.logout(); this.router.navigate(['/login']); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,35 @@ export interface ShoppingListExpenseItem {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface AdminSystemInfo {
|
||||||
|
appName: string;
|
||||||
|
suiteVersion: string;
|
||||||
|
apiVersion: string;
|
||||||
|
webVersion: string;
|
||||||
|
nodeVersion: string;
|
||||||
|
environment: string;
|
||||||
|
database: string;
|
||||||
|
uploadDir: string;
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
smtpConfigured: boolean;
|
||||||
|
counters: {
|
||||||
|
users: number;
|
||||||
|
expenses: number;
|
||||||
|
categories: number;
|
||||||
|
merchants: number;
|
||||||
|
budgets: number;
|
||||||
|
recurring: number;
|
||||||
|
shoppingIntegrations: number;
|
||||||
|
};
|
||||||
|
sources: {
|
||||||
|
appRepository: string;
|
||||||
|
shoppingListRepository: string;
|
||||||
|
apiBasePath: string;
|
||||||
|
};
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShoppingListTemplate {
|
export interface ShoppingListTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -434,3 +434,75 @@ body {
|
|||||||
[data-bs-theme="dark"] .badge.bg-secondary {
|
[data-bs-theme="dark"] .badge.bg-secondary {
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-w-0 { min-width: 0; }
|
||||||
|
.ec-nav-caption {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tblr-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.ec-footer {
|
||||||
|
background: var(--ec-navbar-bg);
|
||||||
|
border-color: var(--ec-card-border) !important;
|
||||||
|
}
|
||||||
|
.ec-footer-shell {
|
||||||
|
min-height: 4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
}
|
||||||
|
.ec-accent-card { position: relative; }
|
||||||
|
.ec-accent-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
.ec-accent-card-primary::before { background: linear-gradient(90deg, var(--tblr-primary), rgba(var(--tblr-primary-rgb), 0.35)); }
|
||||||
|
.ec-accent-card-success::before { background: linear-gradient(90deg, var(--tblr-success), rgba(var(--tblr-success-rgb), 0.35)); }
|
||||||
|
.ec-accent-card-info::before { background: linear-gradient(90deg, var(--tblr-info), rgba(var(--tblr-info-rgb), 0.35)); }
|
||||||
|
.ec-stat-tile, .ec-mini-kpi {
|
||||||
|
border: 1px solid var(--ec-card-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(var(--tblr-bg-surface-rgb), 0.7);
|
||||||
|
}
|
||||||
|
.ec-stat-tile-primary { background: rgba(var(--tblr-primary-rgb), 0.08); }
|
||||||
|
.ec-stat-tile-success { background: rgba(var(--tblr-success-rgb), 0.08); }
|
||||||
|
.ec-stat-label {
|
||||||
|
color: var(--tblr-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.ec-stat-value {
|
||||||
|
font-size: clamp(1.4rem, 2vw, 2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.ec-mini-kpi {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.ec-mini-kpi span {
|
||||||
|
color: var(--tblr-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.ec-mini-kpi strong {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .bg-body-tertiary {
|
||||||
|
background: rgba(255,255,255,0.03) !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pv-subnav { display: none; }
|
||||||
|
.pv-subnav.is-open { display: block; }
|
||||||
|
.pv-subnav .nav-link { width: 100%; border-radius: 0.85rem; }
|
||||||
|
.pv-subnav-tabs { flex-direction: column; width: 100%; }
|
||||||
|
.ec-footer-shell { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user