This commit is contained in:
Mateusz Gruszczyński
2026-04-07 10:06:48 +02:00
parent deaa6dfe43
commit ca9c78d88d
36 changed files with 1801 additions and 503 deletions

39
api/package-lock.json generated
View File

@@ -604,9 +604,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -621,9 +618,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -638,9 +632,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -655,9 +646,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -672,9 +660,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -689,9 +674,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -706,9 +688,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -723,9 +702,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -740,9 +716,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -757,9 +730,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -774,9 +744,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -791,9 +758,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -808,9 +772,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -36,7 +36,8 @@ const settingsSchema = z.object({
const userUpdateSchema = z.object({ const userUpdateSchema = z.object({
role: z.enum(['ADMIN', 'USER']).optional(), role: z.enum(['ADMIN', 'USER']).optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
defaultCurrency: z.string().min(3).max(8).optional() defaultCurrency: z.string().min(3).max(8).optional(),
integrationsEnabled: z.boolean().optional()
}); });
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@@ -172,6 +173,7 @@ export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
if (parsed.data.role) item.role = parsed.data.role; if (parsed.data.role) item.role = parsed.data.role;
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive; if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency; if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
if (typeof parsed.data.integrationsEnabled === 'boolean') item.integrationsEnabled = parsed.data.integrationsEnabled;
await userRepo().save(item); await userRepo().save(item);
return res.json({ item: sanitizeUser(item) }); return res.json({ item: sanitizeUser(item) });

View File

@@ -49,6 +49,11 @@ const serializeBudget = (item: Budget, spent: number) => {
}; };
const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` }); const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` });
const normalizeNullableText = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : null;
};
export const listBudgets = async (req: AuthenticatedRequest, res: Response) => { export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id); await processDueRecurringExpenses(req.user!.id);
@@ -96,7 +101,7 @@ export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
}; };
export const createBudget = async (req: AuthenticatedRequest, res: Response) => { export const createBudget = async (req: AuthenticatedRequest, res: Response) => {
const parsed = budgetSchema.safeParse(req.body); const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues }); if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
const category = parsed.data.categoryId const category = parsed.data.categoryId
@@ -125,7 +130,7 @@ export const createBudget = async (req: AuthenticatedRequest, res: Response) =>
}; };
export const updateBudget = async (req: AuthenticatedRequest, res: Response) => { export const updateBudget = async (req: AuthenticatedRequest, res: Response) => {
const parsed = budgetSchema.safeParse(req.body); const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues }); if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } }); const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } });

View File

@@ -1,6 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { In } from 'typeorm';
import { z } from 'zod'; import { z } from 'zod';
import { AppDataSource } from '../config/data-source.js'; import { AppDataSource } from '../config/data-source.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
@@ -16,6 +17,10 @@ const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']); const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
const statusSchema = z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']); const statusSchema = z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']);
const duplicateReviewSchema = z.object({ action: z.enum(['CONFIRM', 'DISMISS', 'REOPEN']) }); const duplicateReviewSchema = z.object({ action: z.enum(['CONFIRM', 'DISMISS', 'REOPEN']) });
const expenseStatusUpdateSchema = z.object({ status: statusSchema });
const bulkIdsSchema = z.array(z.string().uuid()).min(1).max(200);
const bulkExpenseStatusUpdateSchema = z.object({ ids: bulkIdsSchema, status: statusSchema });
const bulkExpenseDeleteSchema = z.object({ ids: bulkIdsSchema });
const createExpenseSchema = z.object({ const createExpenseSchema = z.object({
title: z.string().min(2).max(140), title: z.string().min(2).max(140),
@@ -45,7 +50,11 @@ const updateExpenseSchema = z.object({
currency: z.string().min(3).max(8).default('PLN'), currency: z.string().min(3).max(8).default('PLN'),
status: statusSchema.default('PENDING'), status: statusSchema.default('PENDING'),
tags: z.array(z.string().min(1).max(40)).default([]), tags: z.array(z.string().min(1).max(40)).default([]),
customFields: z.record(z.string(), z.string()).default({}) customFields: z.record(z.string(), z.string()).default({}),
proofType: proofTypeSchema.optional(),
proofLabel: z.string().max(150).nullable().optional(),
proofNote: z.string().max(1000).nullable().optional(),
removeProofIds: z.array(z.string().uuid()).default([])
}); });
const addProofSchema = z.object({ const addProofSchema = z.object({
@@ -75,6 +84,53 @@ const removeUploadedFiles = (files: Express.Multer.File[]) => {
files.forEach((file) => removeUploadedFile(file.filename)); files.forEach((file) => removeUploadedFile(file.filename));
}; };
const slugifyForFilename = (value: string) =>
value
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'expense';
const deriveFileExtension = (file: Express.Multer.File) => {
const fromOriginal = path.extname(file.originalname || '').trim();
if (fromOriginal) return fromOriginal.toLowerCase();
const fromStored = path.extname(file.filename || '').trim();
if (fromStored) return fromStored.toLowerCase();
if ((file.mimetype || '').toLowerCase().includes('pdf')) return '.pdf';
if ((file.mimetype || '').toLowerCase().includes('png')) return '.png';
if ((file.mimetype || '').toLowerCase().includes('jpeg') || (file.mimetype || '').toLowerCase().includes('jpg')) return '.jpg';
if ((file.mimetype || '').toLowerCase().includes('webp')) return '.webp';
return '';
};
const renameUploadedFilesForExpense = (files: Express.Multer.File[], expenseTitle: string, proofType?: string | null) => {
const base = [slugifyForFilename(expenseTitle), proofType?.toLowerCase() || null].filter(Boolean).join('-');
files.forEach((file, index) => {
const extension = deriveFileExtension(file);
const nextFilename = `${base || 'expense'}-${Date.now()}-${index + 1}${extension}`.slice(0, 220);
const fromPath = path.resolve(env.UPLOAD_DIR, file.filename);
const toPath = path.resolve(env.UPLOAD_DIR, nextFilename);
if (fromPath !== toPath && fs.existsSync(fromPath)) fs.renameSync(fromPath, toPath);
file.filename = nextFilename;
file.originalname = nextFilename;
});
};
const normalizeIdList = (value: unknown) => {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
if (typeof value === 'string') {
if (!value.trim()) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean);
} catch {}
return value.split(',').map((item) => item.trim()).filter(Boolean);
}
return [];
};
const normalizeTagList = (value: unknown) => { const normalizeTagList = (value: unknown) => {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean); if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
if (typeof value === 'string') { if (typeof value === 'string') {
@@ -116,7 +172,8 @@ const normalizeCustomFields = (value: unknown) => {
const enrichPayload = (body: Record<string, unknown>) => ({ const enrichPayload = (body: Record<string, unknown>) => ({
...body, ...body,
tags: normalizeTagList(body.tags), tags: normalizeTagList(body.tags),
customFields: normalizeCustomFields(body.customFields) customFields: normalizeCustomFields(body.customFields),
removeProofIds: normalizeIdList(body.removeProofIds)
}); });
const serializeExpense = (expense: Expense) => ({ const serializeExpense = (expense: Expense) => ({
@@ -142,7 +199,7 @@ const serializeExpense = (expense: Expense) => ({
isSystem: expense.category.isSystem, isSystem: expense.category.isSystem,
ownerId: expense.category.user?.id ?? null ownerId: expense.category.user?.id ?? null
}, },
proofs: expense.proofs?.map(serializeProof) ?? [], proofs: expense.proofs?.map((proof) => serializeProof(proof, expense.id)) ?? [],
createdAt: expense.createdAt, createdAt: expense.createdAt,
updatedAt: expense.updatedAt updatedAt: expense.updatedAt
}); });
@@ -191,6 +248,9 @@ const hydrateExpense = (id: string) =>
const parseFilterArray = (value: string | undefined) => value?.split(',').map((item) => item.trim()).filter(Boolean) ?? []; const parseFilterArray = (value: string | undefined) => value?.split(',').map((item) => item.trim()).filter(Boolean) ?? [];
const sortBySchema = z.enum(['expenseDate', 'title', 'amount', 'status', 'category', 'merchant', 'createdAt', 'updatedAt']).catch('expenseDate');
const sortDirSchema = z.enum(['asc', 'desc']).catch('desc');
const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED']; const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED'];
const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = { const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
DRAFT: ['DRAFT', 'PENDING', 'REJECTED'], DRAFT: ['DRAFT', 'PENDING', 'REJECTED'],
@@ -200,9 +260,25 @@ const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
}; };
const validateInitialStatus = (nextStatus: ExpenseStatus) => initialStatuses.includes(nextStatus); const validateInitialStatus = (nextStatus: ExpenseStatus) => initialStatuses.includes(nextStatus);
const parsePositiveInt = (value: unknown, fallback: number, max = 100) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), 1), max);
};
const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus); const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus);
const approvalNeedsProof = (nextStatus: ExpenseStatus) => nextStatus === 'APPROVED'; const approvalNeedsProof = (nextStatus: ExpenseStatus) => nextStatus === 'APPROVED';
const loadOwnedExpenses = async (ids: string[], user: AuthenticatedRequest['user']) => {
const where = user?.role === 'ADMIN'
? { id: In(ids) }
: { id: In(ids), user: { id: user!.id } };
return expenseRepo().find({
where,
relations: { user: true, category: { user: true }, proofs: true }
});
};
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => { const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
if (!duplicates.length) { if (!duplicates.length) {
expense.possibleDuplicate = false; expense.possibleDuplicate = false;
@@ -225,9 +301,13 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined; const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined;
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined; const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase().trim() : undefined; const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase().trim() : undefined;
const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase() : undefined; const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase().trim() : undefined;
const tags = parseFilterArray(typeof req.query.tags === 'string' ? req.query.tags : undefined).map((item) => item.toLowerCase()); const tags = parseFilterArray(typeof req.query.tags === 'string' ? req.query.tags : undefined).map((item) => item.toLowerCase());
const duplicatesOnly = String(req.query.duplicatesOnly ?? '') === 'true'; const duplicatesOnly = String(req.query.duplicatesOnly ?? '') === 'true';
const pageSize = parsePositiveInt(req.query.pageSize, 20, 100);
const requestedPage = parsePositiveInt(req.query.page, 1, 100000);
const sortBy = sortBySchema.parse(req.query.sortBy);
const sortDir = sortDirSchema.parse(req.query.sortDir);
const items = await expenseRepo().find({ const items = await expenseRepo().find({
where: { user: { id: req.user!.id } }, where: { user: { id: req.user!.id } },
@@ -255,7 +335,71 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
return true; return true;
}); });
return res.json({ items: filtered.map(serializeExpense) }); const sorted = [...filtered].sort((left, right) => {
const direction = sortDir === 'asc' ? 1 : -1;
const leftValue = (() => {
switch (sortBy) {
case 'title': return left.title ?? '';
case 'amount': return left.amount ?? 0;
case 'status': return left.status ?? '';
case 'category': return left.category?.name ?? '';
case 'merchant': return left.merchant ?? '';
case 'createdAt': return left.createdAt?.toISOString?.() ?? String(left.createdAt ?? '');
case 'updatedAt': return left.updatedAt?.toISOString?.() ?? String(left.updatedAt ?? '');
case 'expenseDate':
default: return left.expenseDate ?? '';
}
})();
const rightValue = (() => {
switch (sortBy) {
case 'title': return right.title ?? '';
case 'amount': return right.amount ?? 0;
case 'status': return right.status ?? '';
case 'category': return right.category?.name ?? '';
case 'merchant': return right.merchant ?? '';
case 'createdAt': return right.createdAt?.toISOString?.() ?? String(right.createdAt ?? '');
case 'updatedAt': return right.updatedAt?.toISOString?.() ?? String(right.updatedAt ?? '');
case 'expenseDate':
default: return right.expenseDate ?? '';
}
})();
if (typeof leftValue === 'number' && typeof rightValue === 'number') return (leftValue - rightValue) * direction;
return String(leftValue).localeCompare(String(rightValue), 'pl', { sensitivity: 'base' }) * direction;
});
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(requestedPage, totalPages);
const startIndex = (page - 1) * pageSize;
const paged = sorted.slice(startIndex, startIndex + pageSize);
return res.json({
items: paged.map(serializeExpense),
pagination: {
page,
pageSize,
total,
totalPages,
hasPrev: page > 1,
hasNext: page < totalPages
}
});
};
export const getExpense = async (req: AuthenticatedRequest, res: Response) => {
const id = String(req.params.id || '').trim();
const where = req.user?.role === 'ADMIN'
? { id }
: { id, user: { id: req.user!.id } };
const item = await expenseRepo().findOne({
where,
relations: { category: { user: true }, proofs: true, user: true }
});
if (!item) return res.status(404).json({ message: 'Expense not found' });
return res.json({ item: serializeExpense(item) });
}; };
export const listDuplicates = async (req: AuthenticatedRequest, res: Response) => { export const listDuplicates = async (req: AuthenticatedRequest, res: Response) => {
@@ -323,6 +467,8 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
customFields: parsed.data.customFields customFields: parsed.data.customFields
}); });
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
const proofs: Proof[] = uploadedFiles.map((file, index) => const proofs: Proof[] = uploadedFiles.map((file, index) =>
proofRepo().create({ proofRepo().create({
type: parsed.data.proofType ?? 'OTHER', type: parsed.data.proofType ?? 'OTHER',
@@ -377,8 +523,10 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
}; };
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => { export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
const uploadedFiles = getUploadedFiles(req);
const parsed = updateExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>)); const parsed = updateExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
if (!parsed.success) { if (!parsed.success) {
removeUploadedFiles(uploadedFiles);
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues }); return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
} }
@@ -386,24 +534,37 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
where: { id: String(req.params.id) }, where: { id: String(req.params.id) },
relations: { user: true, category: { user: true }, proofs: true } relations: { user: true, category: { user: true }, proofs: true }
}); });
if (!item) return res.status(404).json({ message: 'Expense not found' }); if (!item) {
removeUploadedFiles(uploadedFiles);
return res.status(404).json({ message: 'Expense not found' });
}
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) { if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
removeUploadedFiles(uploadedFiles);
return res.status(403).json({ message: 'You cannot edit this expense' }); return res.status(403).json({ message: 'You cannot edit this expense' });
} }
if (!validateStatusTransition(item.status, parsed.data.status)) { if (!validateStatusTransition(item.status, parsed.data.status)) {
removeUploadedFiles(uploadedFiles);
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` }); return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
} }
if (approvalNeedsProof(parsed.data.status) && item.proofs.length === 0) {
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
}
const category = await categoryRepo().findOne({ const category = await categoryRepo().findOne({
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }], where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
relations: { user: true } relations: { user: true }
}); });
if (!category) return res.status(404).json({ message: 'Category not found' }); if (!category) {
removeUploadedFiles(uploadedFiles);
return res.status(404).json({ message: 'Category not found' });
}
const proofIdsToRemove = new Set(parsed.data.removeProofIds);
const remainingProofs = item.proofs.filter((proof) => !proofIdsToRemove.has(proof.id));
if (approvalNeedsProof(parsed.data.status) && remainingProofs.length + uploadedFiles.length === 0) {
removeUploadedFiles(uploadedFiles);
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
}
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
const duplicates = await findDuplicateMatches({ const duplicates = await findDuplicateMatches({
userId: req.user!.id, userId: req.user!.id,
@@ -428,11 +589,122 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
item.category = category; item.category = category;
applyDuplicateState(item, duplicates); applyDuplicateState(item, duplicates);
if (proofIdsToRemove.size) {
const proofsToDelete = item.proofs.filter((proof) => proofIdsToRemove.has(proof.id));
proofsToDelete.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined));
if (proofsToDelete.length) await proofRepo().remove(proofsToDelete);
item.proofs = remainingProofs;
}
const createdProofs = uploadedFiles.map((file, index) =>
proofRepo().create({
type: parsed.data.proofType ?? 'OTHER',
label: uploadedFiles.length === 1 ? (parsed.data.proofLabel ?? file.originalname ?? 'Attachment') : file.originalname,
note: uploadedFiles.length === 1 && index === 0 ? (parsed.data.proofNote ?? null) : null,
originalName: file.originalname ?? null,
storedName: file.filename ?? null,
mimeType: file.mimetype ?? null,
fileSize: file.size ?? null,
expense: item
})
);
if (!createdProofs.length && !item.proofs.length && (parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType)) {
createdProofs.push(
proofRepo().create({
type: parsed.data.proofType ?? 'OTHER',
label: parsed.data.proofLabel ?? 'Attachment',
note: parsed.data.proofNote ?? null,
originalName: null,
storedName: null,
mimeType: null,
fileSize: null,
expense: item
})
);
}
await expenseRepo().save(item); await expenseRepo().save(item);
if (createdProofs.length) await proofRepo().save(createdProofs);
const refreshed = await hydrateExpense(item.id); const refreshed = await hydrateExpense(item.id);
return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) }); return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
}; };
export const bulkUpdateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
const parsed = bulkExpenseStatusUpdateSchema.safeParse({
ids: Array.isArray(req.body?.ids) ? req.body.ids : [],
status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status
});
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense status payload', issues: parsed.error.issues });
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
const invalidTransition = items.find((item) => !validateStatusTransition(item.status, parsed.data.status));
if (invalidTransition) {
return res.status(400).json({ message: `Status transition from ${invalidTransition.status} to ${parsed.data.status} is not allowed for ${invalidTransition.title}.` });
}
const missingProof = items.find((item) => approvalNeedsProof(parsed.data.status) && !item.proofs.length);
if (missingProof) {
return res.status(400).json({ message: `Add at least one attachment before approving ${missingProof.title}.` });
}
items.forEach((item) => {
item.status = parsed.data.status;
});
await expenseRepo().save(items);
const refreshed = await expenseRepo().find({
where: { id: In(items.map((item) => item.id)) },
relations: { category: { user: true }, proofs: true, user: true }
});
return res.json({ items: refreshed.map(serializeExpense), updated: refreshed.length });
};
export const bulkDeleteExpenses = async (req: AuthenticatedRequest, res: Response) => {
const parsed = bulkExpenseDeleteSchema.safeParse({ ids: Array.isArray(req.body?.ids) ? req.body.ids : [] });
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense delete payload', issues: parsed.error.issues });
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
items.forEach((item) => item.proofs.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined)));
await expenseRepo().remove(items);
return res.json({ deleted: items.length });
};
export const updateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
const parsed = expenseStatusUpdateSchema.safeParse({ status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status });
if (!parsed.success) return res.status(400).json({ message: 'Invalid expense status payload', issues: parsed.error.issues });
const item = await expenseRepo().findOne({
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' });
}
if (!validateStatusTransition(item.status, parsed.data.status)) {
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
}
if (approvalNeedsProof(parsed.data.status) && !item.proofs.length) {
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
}
item.status = parsed.data.status;
await expenseRepo().save(item);
const refreshed = await hydrateExpense(item.id);
return res.json({ item: serializeExpense(refreshed) });
};
export const reviewDuplicate = async (req: AuthenticatedRequest, res: Response) => { export const reviewDuplicate = async (req: AuthenticatedRequest, res: Response) => {
const parsed = duplicateReviewSchema.safeParse(req.body ?? {}); const parsed = duplicateReviewSchema.safeParse(req.body ?? {});
if (!parsed.success) return res.status(400).json({ message: 'Invalid duplicate review payload', issues: parsed.error.issues }); if (!parsed.success) return res.status(400).json({ message: 'Invalid duplicate review payload', issues: parsed.error.issues });
@@ -494,6 +766,47 @@ export const deleteExpense = async (req: AuthenticatedRequest, res: Response) =>
return res.status(204).send(); return res.status(204).send();
}; };
export const deleteProof = async (req: AuthenticatedRequest, res: Response) => {
const expense = await expenseRepo().findOne({
where: { id: String(req.params.id) },
relations: { user: true, proofs: true, category: { user: true } }
});
if (!expense) return res.status(404).json({ message: 'Expense not found' });
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
return res.status(403).json({ message: 'You cannot delete proof from this expense' });
}
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
if (!proof) return res.status(404).json({ message: 'Proof not found' });
removeUploadedFile(proof.storedName ?? undefined);
await proofRepo().remove(proof);
const refreshed = await hydrateExpense(expense.id);
return res.json({ item: serializeExpense(refreshed) });
};
export const getProofFile = async (req: AuthenticatedRequest, res: Response) => {
const expense = await expenseRepo().findOne({
where: { id: String(req.params.id) },
relations: { user: true, proofs: true }
});
if (!expense) return res.status(404).json({ message: 'Expense not found' });
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
return res.status(403).json({ message: 'You cannot access files for this expense' });
}
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
if (!proof || !proof.storedName) return res.status(404).json({ message: 'Proof file not found' });
const filePath = path.resolve(env.UPLOAD_DIR, proof.storedName);
if (!fs.existsSync(filePath)) return res.status(404).json({ message: 'Proof file not found' });
res.setHeader('Content-Disposition', `inline; filename="${proof.originalName || proof.storedName}"`);
if (proof.mimeType) res.type(proof.mimeType);
return res.sendFile(filePath);
};
export const addProof = async (req: AuthenticatedRequest, res: Response) => { export const addProof = async (req: AuthenticatedRequest, res: Response) => {
const uploadedFiles = getUploadedFiles(req); const uploadedFiles = getUploadedFiles(req);
const parsed = addProofSchema.safeParse(req.body ?? {}); const parsed = addProofSchema.safeParse(req.body ?? {});
@@ -515,6 +828,8 @@ export const addProof = async (req: AuthenticatedRequest, res: Response) => {
return res.status(403).json({ message: 'You cannot add proof to this expense' }); return res.status(403).json({ message: 'You cannot add proof to this expense' });
} }
renameUploadedFilesForExpense(uploadedFiles, expense.title, parsed.data.type);
const createdProofs = uploadedFiles.length const createdProofs = uploadedFiles.length
? uploadedFiles.map((file) => ? uploadedFiles.map((file) =>
proofRepo().create({ proofRepo().create({
@@ -543,5 +858,5 @@ export const addProof = async (req: AuthenticatedRequest, res: Response) => {
await proofRepo().save(createdProofs); await proofRepo().save(createdProofs);
const refreshed = await hydrateExpense(expense.id); const refreshed = await hydrateExpense(expense.id);
return res.status(201).json({ proofs: createdProofs.map(serializeProof), expense: serializeExpense(refreshed) }); return res.status(201).json({ proofs: createdProofs.map((proof) => serializeProof(proof, expense.id)), expense: serializeExpense(refreshed) });
}; };

View File

@@ -54,6 +54,13 @@ const importItemSchema = z.object({
tags: z.array(z.string().min(1).max(40)).default([]) tags: z.array(z.string().min(1).max(40)).default([])
}); });
const importPeriodSchema = z.object({
period: z.string().regex(/^\d{4}-\d{2}$/),
categoryId: z.string().uuid(),
status: importStatusSchema,
merchant: z.string().max(120).nullable().optional()
});
const userRepo = () => AppDataSource.getRepository(User); const userRepo = () => AppDataSource.getRepository(User);
const expenseRepo = () => AppDataSource.getRepository(Expense); const expenseRepo = () => AppDataSource.getRepository(Expense);
const categoryRepo = () => AppDataSource.getRepository(Category); const categoryRepo = () => AppDataSource.getRepository(Category);
@@ -72,6 +79,13 @@ const getSettings = async (userId: string) => {
return user ?? null; return user ?? null;
}; };
const ensureIntegrationsAllowed = (user: User) => {
if (user.role === 'ADMIN' || user.integrationsEnabled) return;
const error = new Error('Integrations are disabled for this user.') as Error & { status?: number };
error.status = 403;
throw error;
};
const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({ const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({
enabled: Boolean(value?.enabled), enabled: Boolean(value?.enabled),
baseUrl: value?.baseUrl ?? '', baseUrl: value?.baseUrl ?? '',
@@ -93,6 +107,7 @@ const buildHeaders = (config: ShoppingListConfig) => {
const requireConfig = async (userId: string) => { const requireConfig = async (userId: string) => {
const user = await getSettings(userId); const user = await getSettings(userId);
if (!user) throw new Error('User not found'); if (!user) throw new Error('User not found');
ensureIntegrationsAllowed(user);
const config = user.shoppingListIntegration; const config = user.shoppingListIntegration;
if (!config?.enabled || !config.baseUrl || !config.apiToken) throw new Error('Shopping list integration is not configured for this user'); if (!config?.enabled || !config.baseUrl || !config.apiToken) throw new Error('Shopping list integration is not configured for this user');
return { user, config }; return { user, config };
@@ -179,6 +194,16 @@ const deriveListDate = (items: Record<string, unknown>[], listCreatedAt?: string
return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10); return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10);
}; };
const monthRange = (period: string) => {
const [yearText, monthText] = period.split('-');
const year = Number(yearText);
const month = Number(monthText);
const start = `${period}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${period}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
};
const resolveCategory = async (userId: string, categoryId: string) => const resolveCategory = async (userId: string, categoryId: string) =>
categoryRepo().findOne({ categoryRepo().findOne({
where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }], where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }],
@@ -312,6 +337,7 @@ const createImportedExpense = async (input: {
export const getShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => { export const getShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => {
const user = await getSettings(req.user!.id); const user = await getSettings(req.user!.id);
if (!user) return res.status(404).json({ message: 'User not found' }); if (!user) return res.status(404).json({ message: 'User not found' });
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) }); return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
}; };
@@ -321,6 +347,7 @@ export const updateShoppingListSettings = async (req: AuthenticatedRequest, res:
const user = await getSettings(req.user!.id); const user = await getSettings(req.user!.id);
if (!user) return res.status(404).json({ message: 'User not found' }); if (!user) return res.status(404).json({ message: 'User not found' });
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
const current = user.shoppingListIntegration ?? {}; const current = user.shoppingListIntegration ?? {};
user.shoppingListIntegration = { user.shoppingListIntegration = {
@@ -433,7 +460,7 @@ export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res
const title = trimToNull(parsed.data.title) ?? `Shopping list: ${trimToNull(parsed.data.listTitle) ?? listId}`; const title = trimToNull(parsed.data.title) ?? `Shopping list: ${trimToNull(parsed.data.listTitle) ?? listId}`;
const description = trimToNull(parsed.data.description) ?? `Imported aggregate from shopping list API (${items.length} item${items.length > 1 ? 's' : ''}).`; const description = trimToNull(parsed.data.description) ?? `Imported aggregate from shopping list API (${items.length} item${items.length > 1 ? 's' : ''}).`;
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API'; const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
const tags = normalizeTags([...parsed.data.tags, 'shopping-list', 'external-import']); const tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
const result = await createImportedExpense({ const result = await createImportedExpense({
userId: user.id, userId: user.id,
@@ -461,6 +488,64 @@ export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res
} }
}; };
export const importShoppingPeriodAsExpense = async (req: AuthenticatedRequest, res: Response) => {
const parsed = importPeriodSchema.safeParse(req.body ?? {});
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping period import payload', issues: parsed.error.issues });
try {
const { user, config } = await requireConfig(req.user!.id);
const range = monthRange(parsed.data.period);
const existing = await getExistingExpenses(user.id);
if (hasExternalImport(existing, 'externalShoppingListPeriod', parsed.data.period)) {
return res.status(409).json({ message: 'This shopping month has already been imported as a local expense.' });
}
const payload = await proxyRequest(config, '/api/expenses/summary', {
start_date: range.start,
end_date: range.end,
owner_id: config.ownerId ?? undefined,
list_id: config.defaultListId ?? undefined
});
const totalAmount = readNumber((payload as { total?: unknown }).total, (payload as { amount?: unknown }).amount, (payload as { meta?: { total_amount?: unknown } }).meta?.total_amount);
const totalCount = readNumber((payload as { count?: unknown }).count, (payload as { records?: unknown }).records, (payload as { meta?: { total_count?: unknown } }).meta?.total_count);
const listGroups = [
(payload as { lists?: unknown }).lists,
(payload as { totals?: unknown }).totals,
(payload as { aggregates?: unknown }).aggregates
].find((value) => Array.isArray(value));
const listCount = Array.isArray(listGroups) ? listGroups.length : 0;
if (totalAmount <= 0) {
return res.status(400).json({ message: 'The selected shopping month does not contain any importable expenses.' });
}
const result = await createImportedExpense({
userId: user.id,
categoryId: parsed.data.categoryId,
title: `Zakupy ${parsed.data.period}`,
description: `Zbiorczy import zakupów za okres ${parsed.data.period}.`,
amount: Number(totalAmount.toFixed(2)),
expenseDate: range.end,
merchant: trimToNull(parsed.data.merchant) ?? 'Zakupy',
status: parsed.data.status,
tags: ['shopping-list'],
customFields: {
externalSource: 'shopping-list-api',
externalShoppingListImportType: 'PERIOD',
externalShoppingListPeriod: parsed.data.period,
externalShoppingListListCount: String(listCount),
externalShoppingListItemCount: String(totalCount)
}
});
return res.status(201).json(result);
} catch (error) {
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
}
};
export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest, res: Response) => { export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest, res: Response) => {
const parsed = importItemSchema.safeParse(req.body ?? {}); const parsed = importItemSchema.safeParse(req.body ?? {});
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list item import payload', issues: parsed.error.issues }); if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list item import payload', issues: parsed.error.issues });
@@ -475,7 +560,7 @@ export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest,
const title = parsed.data.title.trim(); const title = parsed.data.title.trim();
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API'; const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
const tags = normalizeTags([...parsed.data.tags, 'shopping-list', 'external-import']); const tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
const description = trimToNull(parsed.data.description) ?? `Imported from shopping list API${parsed.data.listTitle ? ` (${parsed.data.listTitle})` : ''}.`; const description = trimToNull(parsed.data.description) ?? `Imported from shopping list API${parsed.data.listTitle ? ` (${parsed.data.listTitle})` : ''}.`;
const result = await createImportedExpense({ const result = await createImportedExpense({

View File

@@ -32,6 +32,18 @@ const require = createRequire(import.meta.url);
const settingsRepo = () => AppDataSource.getRepository(AppSetting); const settingsRepo = () => AppDataSource.getRepository(AppSetting);
const expenseRepo = () => AppDataSource.getRepository(Expense); const expenseRepo = () => AppDataSource.getRepository(Expense);
const normalizeOptionalString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : undefined;
};
const normalizeNullableString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : null;
};
const defaultPrefs = (email: string) => ({ const defaultPrefs = (email: string) => ({
enabled: false, enabled: false,
frequency: 'monthly' as const, frequency: 'monthly' as const,
@@ -196,7 +208,7 @@ export const getPreferences = async (req: AuthenticatedRequest, res: Response) =
}; };
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => { export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
const parsed = preferencesSchema.safeParse(req.body); const parsed = preferencesSchema.safeParse({ ...req.body, sendToEmail: normalizeNullableString(req.body?.sendToEmail) });
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues }); return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
} }
@@ -282,7 +294,7 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
export const exportReport = async (req: AuthenticatedRequest, res: Response) => { export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id); await processDueRecurringExpenses(req.user!.id);
const parsed = exportQuerySchema.safeParse(req.query); const parsed = exportQuerySchema.safeParse({ ...req.query, format: normalizeOptionalString(req.query.format), startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), status: normalizeOptionalString(req.query.status), tag: normalizeOptionalString(req.query.tag) });
if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues }); if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues });
const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? []; const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? [];

View File

@@ -4,6 +4,12 @@ import { getCashflowSummary, getStatistics } from '../services/statistics.servic
import { processDueRecurringExpenses } from '../services/recurring.service.js'; import { processDueRecurringExpenses } from '../services/recurring.service.js';
import type { AuthenticatedRequest } from '../types/express.js'; import type { AuthenticatedRequest } from '../types/express.js';
const normalizeOptionalString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : undefined;
};
const querySchema = z.object({ const querySchema = z.object({
startDate: z.string().optional(), startDate: z.string().optional(),
endDate: z.string().optional(), endDate: z.string().optional(),
@@ -15,11 +21,15 @@ const querySchema = z.object({
export const getOverview = async (req: AuthenticatedRequest, res: Response) => { export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id); await processDueRecurringExpenses(req.user!.id);
const parsed = querySchema.safeParse(req.query); const parsed = querySchema.safeParse({ ...req.query, startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), bucket: normalizeOptionalString(req.query.bucket), tag: normalizeOptionalString(req.query.tag), status: normalizeOptionalString(req.query.status) });
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues }); return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
} }
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : []; const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
return res.json( return res.json(
await getStatistics( await getStatistics(
@@ -38,5 +48,8 @@ export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
export const getCashflow = async (req: AuthenticatedRequest, res: Response) => { export const getCashflow = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id); await processDueRecurringExpenses(req.user!.id);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
return res.json(await getCashflowSummary(req.user!.id)); return res.json(await getCashflowSummary(req.user!.id));
}; };

View File

@@ -29,6 +29,9 @@ export class User {
@Column({ type: 'varchar', length: 8, default: 'PLN' }) @Column({ type: 'varchar', length: 8, default: 'PLN' })
defaultCurrency!: string; defaultCurrency!: string;
@Column({ type: 'boolean', default: false })
integrationsEnabled!: boolean;
@Column({ type: 'simple-json', nullable: true }) @Column({ type: 'simple-json', nullable: true })
reportPreferences!: { reportPreferences!: {
enabled?: boolean; enabled?: boolean;

View File

@@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { addProof, createExpense, deleteExpense, listDuplicates, listExpenses, reviewDuplicate, updateExpense } from '../controllers/expense.controller.js'; import { addProof, bulkDeleteExpenses, bulkUpdateExpenseStatus, createExpense, deleteExpense, deleteProof, getExpense, getProofFile, listDuplicates, listExpenses, reviewDuplicate, updateExpense, updateExpenseStatus } from '../controllers/expense.controller.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { uploadProofFiles } from '../middleware/upload.js'; import { uploadProofFiles } from '../middleware/upload.js';
@@ -7,8 +7,15 @@ export const expenseRouter = Router();
expenseRouter.use(requireAuth); expenseRouter.use(requireAuth);
expenseRouter.get('/', listExpenses); expenseRouter.get('/', listExpenses);
expenseRouter.get('/duplicates', listDuplicates); expenseRouter.get('/duplicates', listDuplicates);
expenseRouter.patch('/bulk/status', bulkUpdateExpenseStatus);
expenseRouter.post('/bulk/delete', bulkDeleteExpenses);
expenseRouter.get('/item/:id', getExpense);
expenseRouter.get('/:id', getExpense);
expenseRouter.post('/', uploadProofFiles, createExpense); expenseRouter.post('/', uploadProofFiles, createExpense);
expenseRouter.put('/:id', updateExpense); expenseRouter.put('/:id', uploadProofFiles, updateExpense);
expenseRouter.patch('/:id/status', updateExpenseStatus);
expenseRouter.post('/:id/duplicate-review', reviewDuplicate); expenseRouter.post('/:id/duplicate-review', reviewDuplicate);
expenseRouter.delete('/:id', deleteExpense); expenseRouter.delete('/:id', deleteExpense);
expenseRouter.get('/:id/proofs/:proofId/file', getProofFile);
expenseRouter.post('/:id/proofs', uploadProofFiles, addProof); expenseRouter.post('/:id/proofs', uploadProofFiles, addProof);
expenseRouter.delete('/:id/proofs/:proofId', deleteProof);

View File

@@ -7,6 +7,7 @@ import {
getShoppingLists, getShoppingLists,
importShoppingListAsExpense, importShoppingListAsExpense,
importShoppingListItemAsExpense, importShoppingListItemAsExpense,
importShoppingPeriodAsExpense,
testShoppingListConnection, testShoppingListConnection,
updateShoppingListSettings updateShoppingListSettings
} from '../controllers/integration.controller.js'; } from '../controllers/integration.controller.js';
@@ -22,4 +23,5 @@ integrationRouter.get('/shopping-list/latest', getShoppingListLatestExpenses);
integrationRouter.get('/shopping-list/lists', getShoppingLists); integrationRouter.get('/shopping-list/lists', getShoppingLists);
integrationRouter.get('/shopping-list/lists/:id/expenses', getShoppingListExpenses); integrationRouter.get('/shopping-list/lists/:id/expenses', getShoppingListExpenses);
integrationRouter.post('/shopping-list/import-list', importShoppingListAsExpense); integrationRouter.post('/shopping-list/import-list', importShoppingListAsExpense);
integrationRouter.post('/shopping-list/import-period', importShoppingPeriodAsExpense);
integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense); integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense);

View File

@@ -13,6 +13,7 @@ export const sanitizeUser = (user: User) => ({
role: user.role, role: user.role,
isActive: user.isActive, isActive: user.isActive,
defaultCurrency: user.defaultCurrency, defaultCurrency: user.defaultCurrency,
integrationsEnabled: Boolean(user.integrationsEnabled),
reportPreferences: user.reportPreferences ?? { reportPreferences: user.reportPreferences ?? {
enabled: false, enabled: false,
frequency: 'monthly', frequency: 'monthly',
@@ -47,6 +48,7 @@ export const createUser = async (input: {
passwordHash: await hashPassword(input.password), passwordHash: await hashPassword(input.password),
role: input.role ?? 'USER', role: input.role ?? 'USER',
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY, defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
integrationsEnabled: false,
reportPreferences: { reportPreferences: {
enabled: false, enabled: false,
frequency: 'monthly', frequency: 'monthly',

View File

@@ -113,7 +113,13 @@ const currentMonthKey = () => {
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`; return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
}; };
const monthRange = (monthKey: string) => ({ startDate: `${monthKey}-01`, endDate: `${monthKey}-31` }); const monthRange = (monthKey: string) => {
const [yearText, monthText] = monthKey.split('-');
const year = Number(yearText);
const month = Number(monthText);
const lastDay = new Date(year, month, 0).getDate();
return { startDate: `${monthKey}-01`, endDate: `${monthKey}-${String(lastDay).padStart(2, '0')}` };
};
export const getCashflowSummary = async (userId: string) => { export const getCashflowSummary = async (userId: string) => {
const expenseRepo = AppDataSource.getRepository(Expense); const expenseRepo = AppDataSource.getRepository(Expense);

View File

@@ -1,6 +1,6 @@
import type { Proof } from '../entities/Proof.js'; import type { Proof } from '../entities/Proof.js';
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null; export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
export const serializeProof = (proof: Proof) => ({ export const serializeProof = (proof: Proof, _expenseId?: string) => ({
id: proof.id, id: proof.id,
type: proof.type, type: proof.type,
label: proof.label, label: proof.label,
@@ -9,5 +9,6 @@ export const serializeProof = (proof: Proof) => ({
mimeType: proof.mimeType, mimeType: proof.mimeType,
fileSize: proof.fileSize, fileSize: proof.fileSize,
fileUrl: buildProofUrl(proof.storedName), fileUrl: buildProofUrl(proof.storedName),
previewUrl: buildProofUrl(proof.storedName),
createdAt: proof.createdAt createdAt: proof.createdAt
}); });

View File

@@ -4,7 +4,11 @@ server {
server_tokens off; server_tokens off;
etag off; etag off;
#client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE}; gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1024;
client_max_body_size 100M;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -13,19 +17,25 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 60s;
location /api/ { location /api/ {
proxy_pass http://api:4000/api/; proxy_pass http://api:4000/api/;
add_header Cache-Control "no-store, no-cache" always;
} }
location /uploads/ { location /uploads/ {
alias /srv/uploads/; alias /srv/uploads/;
access_log off; access_log off;
expires 30d; expires 30d;
add_header Cache-Control "public"; add_header Cache-Control "public, max-age=86400, immutable" always;
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
proxy_pass http://web:80/; proxy_pass http://web:80/;
add_header Cache-Control "no-store, no-cache" always;
} }
} }

0
start_dev.sh Executable file → Normal file
View File

View File

@@ -3,6 +3,7 @@ server {
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
server_tokens off; server_tokens off;
etag off;
index index.html; index index.html;
location / { location / {

90
web/package-lock.json generated
View File

@@ -2452,9 +2452,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2472,9 +2469,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2492,9 +2486,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2512,9 +2503,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2532,9 +2520,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2552,9 +2537,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2572,9 +2554,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3018,9 +2997,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3042,9 +3018,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3066,9 +3039,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3090,9 +3060,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3114,9 +3081,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3138,9 +3102,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3328,9 +3289,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3348,9 +3306,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3368,9 +3323,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3388,9 +3340,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3567,9 +3516,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3584,9 +3530,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3601,9 +3544,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3618,9 +3558,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3635,9 +3572,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3652,9 +3586,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3669,9 +3600,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3686,9 +3614,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3703,9 +3628,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3720,9 +3642,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3737,9 +3656,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3754,9 +3670,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3771,9 +3684,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -1,6 +1,7 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { adminGuard } from './core/guards/admin.guard'; import { adminGuard } from './core/guards/admin.guard';
import { authGuard } from './core/guards/auth.guard'; import { authGuard } from './core/guards/auth.guard';
import { integrationsGuard } from './core/guards/integrations.guard';
import { AdminComponent } from './features/admin/admin.component'; import { AdminComponent } from './features/admin/admin.component';
import { LoginComponent } from './features/auth/login.component'; import { LoginComponent } from './features/auth/login.component';
import { BudgetsComponent } from './features/budgets/budgets.component'; import { BudgetsComponent } from './features/budgets/budgets.component';
@@ -8,6 +9,8 @@ import { CashflowComponent } from './features/cashflow/cashflow.component';
import { CategoriesComponent } from './features/categories/categories.component'; import { CategoriesComponent } from './features/categories/categories.component';
import { DashboardComponent } from './features/dashboard/dashboard.component'; import { DashboardComponent } from './features/dashboard/dashboard.component';
import { ExpensesComponent } from './features/expenses/expenses.component'; import { ExpensesComponent } from './features/expenses/expenses.component';
import { ExpenseListComponent } from './features/expenses/expense-list.component';
import { ExpenseDetailComponent } from './features/expenses/expense-detail.component';
import { IntegrationsComponent } from './features/integrations/integrations.component'; import { IntegrationsComponent } from './features/integrations/integrations.component';
import { MerchantsComponent } from './features/merchants/merchants.component'; import { MerchantsComponent } from './features/merchants/merchants.component';
import { RecurringComponent } from './features/recurring/recurring.component'; import { RecurringComponent } from './features/recurring/recurring.component';
@@ -23,7 +26,15 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
children: [ children: [
{ path: '', component: DashboardComponent }, { path: '', component: DashboardComponent },
{ path: 'expenses', component: ExpensesComponent }, {
path: 'expenses',
children: [
{ path: '', redirectTo: 'list', pathMatch: 'full' },
{ path: 'add', component: ExpensesComponent },
{ path: 'list', component: ExpenseListComponent },
{ path: ':id', component: ExpenseDetailComponent }
]
},
{ path: 'stats', component: StatsComponent }, { path: 'stats', component: StatsComponent },
{ path: 'cashflow', component: CashflowComponent }, { path: 'cashflow', component: CashflowComponent },
{ path: 'budgets', component: BudgetsComponent }, { path: 'budgets', component: BudgetsComponent },
@@ -31,7 +42,7 @@ export const routes: Routes = [
{ path: 'merchants', component: MerchantsComponent }, { path: 'merchants', component: MerchantsComponent },
{ path: 'reports', component: ReportsComponent }, { path: 'reports', component: ReportsComponent },
{ path: 'categories', component: CategoriesComponent }, { path: 'categories', component: CategoriesComponent },
{ path: 'integrations', component: IntegrationsComponent }, { path: 'integrations', component: IntegrationsComponent, canActivate: [integrationsGuard] },
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] } { path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
] ]
}, },

View File

@@ -0,0 +1,17 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { ToastService } from '../services/toast.service';
import { UiService } from '../services/ui.service';
export const integrationsGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
const toast = inject(ToastService);
const ui = inject(UiService);
if (auth.currentUser()?.integrationsEnabled) return true;
toast.warning(ui.t('integrations.disabledForUser'));
return router.createUrlTree(['/']);
};

View File

@@ -19,7 +19,7 @@ export class AdminService {
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`); return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
} }
updateUser(id: string, payload: Partial<User>) { updateUser(id: string, payload: Partial<User> & { integrationsEnabled?: boolean }) {
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload); return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
} }

View File

@@ -1,18 +1,23 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import type { DuplicateGroup, Expense, Proof } from '../../shared/models'; import type { DuplicateGroup, Expense, ExpenseListResponse, Proof } from '../../shared/models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ExpensesService { export class ExpensesService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string; status?: string; tags?: string; duplicatesOnly?: boolean } = {}) { list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string; status?: string; tags?: string; duplicatesOnly?: boolean; page?: number; pageSize?: number; sortBy?: string; sortDir?: 'asc' | 'desc' } = {}) {
let params = new HttpParams(); let params = new HttpParams();
Object.entries(filters).forEach(([key, value]) => { Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value)); if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
}); });
return this.http.get<{ items: Expense[] }>(`${environment.apiBaseUrl}/expenses`, { params }); return this.http.get<ExpenseListResponse>(`${environment.apiBaseUrl}/expenses`, { params });
}
getById(id: string) {
return this.http.get<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/item/${id}`);
} }
duplicates() { duplicates() {
@@ -23,8 +28,20 @@ export class ExpensesService {
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses`, formData); return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses`, formData);
} }
update(id: string, payload: Partial<Expense> & { categoryId: string }) { update(id: string, formData: FormData) {
return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, payload); return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, formData);
}
updateStatus(id: string, status: Expense['status']) {
return this.http.patch<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/status`, { status });
}
bulkUpdateStatus(ids: string[], status: Expense['status']) {
return this.http.patch<{ items: Expense[]; updated: number }>(`${environment.apiBaseUrl}/expenses/bulk/status`, { ids, status });
}
bulkDelete(ids: string[]) {
return this.http.post<{ deleted: number }>(`${environment.apiBaseUrl}/expenses/bulk/delete`, { ids });
} }
reviewDuplicate(id: string, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') { reviewDuplicate(id: string, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
@@ -38,4 +55,8 @@ export class ExpensesService {
addProof(id: string, formData: FormData) { addProof(id: string, formData: FormData) {
return this.http.post<{ proofs: Proof[]; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData); return this.http.post<{ proofs: Proof[]; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData);
} }
deleteProof(expenseId: string, proofId: string) {
return this.http.delete<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${expenseId}/proofs/${proofId}`);
}
} }

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import type { Expense, ShoppingListExpenseItem, ShoppingListIntegrationSettings, ShoppingListRef, ShoppingListSummary } from '../../shared/models'; import type { Expense, ShoppingListExpenseItem, ShoppingListIntegrationSettings, ShoppingListPeriodImportResponse, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ShoppingListIntegrationService { export class ShoppingListIntegrationService {
@@ -48,6 +48,15 @@ export class ShoppingListIntegrationService {
return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists/${id}/expenses`, { params }); return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists/${id}/expenses`, { params });
} }
importPeriod(payload: {
period: string;
categoryId: string;
status: 'DRAFT' | 'PENDING';
merchant?: string | null;
}) {
return this.http.post<ShoppingListPeriodImportResponse>(`${environment.apiBaseUrl}/integrations/shopping-list/import-period`, payload);
}
importList(payload: { importList(payload: {
listId: string | number; listId: string | number;
listTitle?: string | null; listTitle?: string | null;

View File

@@ -8,7 +8,7 @@ export class StatsService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year'; tag?: string; status?: string }) { overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year'; tag?: string; status?: string }) {
let params = new HttpParams(); let params = new HttpParams().set('_ts', String(Date.now()));
Object.entries(filters).forEach(([key, value]) => { Object.entries(filters).forEach(([key, value]) => {
if (value) params = params.set(key, value); if (value) params = params.set(key, value);
}); });
@@ -16,6 +16,7 @@ export class StatsService {
} }
cashflow() { cashflow() {
return this.http.get<CashflowResponse>(`${environment.apiBaseUrl}/statistics/cashflow`); const params = new HttpParams().set('_ts', String(Date.now()));
return this.http.get<CashflowResponse>(`${environment.apiBaseUrl}/statistics/cashflow`, { params });
} }
} }

View File

@@ -31,6 +31,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'action.cancel': 'Anuluj', 'action.cancel': 'Anuluj',
'action.reset': 'Reset', 'action.reset': 'Reset',
'action.show': 'Pokaż', 'action.show': 'Pokaż',
'action.view': 'Szczegóły',
'action.backToList': 'Wróć do listy',
'action.clearSelection': 'Wyczyść zaznaczenie',
'action.filter': 'Filtruj', 'action.filter': 'Filtruj',
'action.refreshPreview': 'Odśwież podgląd', 'action.refreshPreview': 'Odśwież podgląd',
'action.sendNow': 'Wyślij teraz', 'action.sendNow': 'Wyślij teraz',
@@ -45,6 +48,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'action.setUser': 'Ustaw USER', 'action.setUser': 'Ustaw USER',
'action.setAdmin': 'Ustaw ADMIN', 'action.setAdmin': 'Ustaw ADMIN',
'action.import': 'Importuj', 'action.import': 'Importuj',
'action.enableIntegrations': 'Włącz integracje',
'action.disableIntegrations': 'Wyłącz integracje',
'theme.label': 'Motyw', 'theme.label': 'Motyw',
'theme.dark': 'Ciemny', 'theme.dark': 'Ciemny',
@@ -94,9 +99,12 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'stats.noCategoryChart': 'Brak danych do wykresu kategorii.', 'stats.noCategoryChart': 'Brak danych do wykresu kategorii.',
'stats.noTrendChart': 'Brak danych do wykresu trendu.', 'stats.noTrendChart': 'Brak danych do wykresu trendu.',
'stats.expensesLabel': 'Wydatki', 'stats.expensesLabel': 'Wydatki',
'table.createdAt': 'Utworzono',
'table.updatedAt': 'Zmieniono',
'expenses.title': 'Wydatki', 'expenses.title': 'Wydatki',
'expenses.subtitle': 'Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.', 'expenses.subtitle': 'Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.',
'expenses.listSubtitle': 'Przeglądaj, filtruj i sortuj zapisane wydatki.',
'expenses.new': 'Nowy wydatek', 'expenses.new': 'Nowy wydatek',
'expenses.edit': 'Edytuj wydatek', 'expenses.edit': 'Edytuj wydatek',
'expenses.requiredHint': 'Uzupełnij wymagane pola oznaczone *.', 'expenses.requiredHint': 'Uzupełnij wymagane pola oznaczone *.',
@@ -125,18 +133,33 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'expenses.filters': 'Filtry i ostatnie wydatki', 'expenses.filters': 'Filtry i ostatnie wydatki',
'expenses.noMerchant': 'Brak kontrahenta', 'expenses.noMerchant': 'Brak kontrahenta',
'expenses.noItems': 'Brak wydatków do wyświetlenia.', 'expenses.noItems': 'Brak wydatków do wyświetlenia.',
'expenses.listTitle': 'Lista wydatków',
'expenses.detailTitle': 'Szczegóły wydatku',
'expenses.meta': 'Metadane',
'expenses.noProofs': 'Brak załączników.',
'expenses.selectedCount': 'Zaznaczone',
'expenses.bulkUpdated': 'Zbiorcza zmiana statusu została zapisana.',
'expenses.bulkDeleted': 'Wybrane wydatki zostały usunięte.',
'expenses.bulkActionError': 'Nie udało się wykonać operacji zbiorczej.',
'expenses.bulkDeleteConfirm': 'Usunąć zaznaczone wydatki?',
'expenses.totalItems': 'Łącznie',
'expenses.perPage': 'na stronę',
'expenses.proof': 'Potwierdzenie', 'expenses.proof': 'Potwierdzenie',
'expenses.saving': 'Zapisywanie...', 'expenses.saving': 'Zapisywanie...',
'expenses.added': 'Wydatek został dodany.', 'expenses.added': 'Wydatek został dodany.',
'expenses.saved': 'Wydatek został zapisany.', 'expenses.saved': 'Wydatek został zapisany.',
'expenses.deleted': 'Wydatek został usunięty.', 'expenses.deleted': 'Wydatek został usunięty.',
'expenses.statusUpdated': 'Status wydatku został zmieniony.',
'expenses.statusUpdateError': 'Nie udało się zmienić statusu wydatku.',
'expenses.addError': 'Nie udało się dodać wydatku.', 'expenses.addError': 'Nie udało się dodać wydatku.',
'expenses.saveError': 'Nie udało się zapisać wydatku.', 'expenses.saveError': 'Nie udało się zapisać wydatku.',
'expenses.loadError': 'Nie udało się pobrać wydatku.',
'expenses.deleteError': 'Nie udało się usunąć wydatku.', 'expenses.deleteError': 'Nie udało się usunąć wydatku.',
'expenses.validation.title': 'Podaj tytuł wydatku.', 'expenses.validation.title': 'Podaj tytuł wydatku.',
'expenses.validation.amount': 'Podaj poprawną kwotę większą od 0.', 'expenses.validation.amount': 'Podaj poprawną kwotę większą od 0.',
'expenses.validation.date': 'Wybierz datę wydatku.', 'expenses.validation.date': 'Wybierz datę wydatku.',
'expenses.validation.category': 'Wybierz kategorię.', 'expenses.validation.category': 'Wybierz kategorię.',
'expenses.existingProofs': 'Obecne załączniki',
'proof.receipt': 'Paragon', 'proof.receipt': 'Paragon',
'proof.invoice': 'Faktura', 'proof.invoice': 'Faktura',
@@ -227,6 +250,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'admin.roleError': 'Nie udało się zmienić roli.', 'admin.roleError': 'Nie udało się zmienić roli.',
'admin.statusUpdated': 'Status konta został zaktualizowany.', 'admin.statusUpdated': 'Status konta został zaktualizowany.',
'admin.statusError': 'Nie udało się zmienić statusu.', 'admin.statusError': 'Nie udało się zmienić statusu.',
'admin.integrationsAccess': 'Integracje',
'admin.integrationsUpdated': 'Dostęp do integracji został zaktualizowany.',
'nav.cashflow': 'Cashflow', 'nav.cashflow': 'Cashflow',
@@ -310,6 +335,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'cashflow.statusSummary': 'Statusy wydatków', 'cashflow.statusSummary': 'Statusy wydatków',
'cashflow.upcomingRecurring': 'Nadchodzące cykliczne', 'cashflow.upcomingRecurring': 'Nadchodzące cykliczne',
'common.none': 'Brak', 'common.none': 'Brak',
'common.loading': 'Ładowanie...',
'common.select': 'Wybierz', 'common.select': 'Wybierz',
'common.noData': 'Brak danych.', 'common.noData': 'Brak danych.',
'common.noExpenses': 'Brak wydatków.', 'common.noExpenses': 'Brak wydatków.',
@@ -343,6 +369,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'integrations.selfHostedHint': 'Tutaj ustawiasz URL i token do osobnej, samodzielnie hostowanej aplikacji list zakupowych.', 'integrations.selfHostedHint': 'Tutaj ustawiasz URL i token do osobnej, samodzielnie hostowanej aplikacji list zakupowych.',
'integrations.importExplainTitle': 'Jak działa import', 'integrations.importExplainTitle': 'Jak działa import',
'integrations.importExplainBody': 'Import z list zakupowych zapisuje dane jako zwykły lokalny wydatek w tej aplikacji. Możesz zaimportować całą listę jako 1 wydatek albo pojedyncze pozycje osobno.', 'integrations.importExplainBody': 'Import z list zakupowych zapisuje dane jako zwykły lokalny wydatek w tej aplikacji. Możesz zaimportować całą listę jako 1 wydatek albo pojedyncze pozycje osobno.',
'integrations.importExplainBodySimple': 'Masz dwa proste tryby: import całego miesiąca jako jeden wydatek albo import wybranej listy jako jeden wydatek.',
'footer.apiOnline': 'API online', 'footer.apiOnline': 'API online',
'footer.apiOffline': 'API offline', 'footer.apiOffline': 'API offline',
@@ -380,10 +407,17 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'integrations.summary': 'Podsumowanie zewnętrzne', 'integrations.summary': 'Podsumowanie zewnętrzne',
'integrations.latest': 'Wydatki z wybranego okresu', 'integrations.latest': 'Wydatki z wybranego okresu',
'integrations.lists': 'Listy zakupowe z okresu', 'integrations.lists': 'Listy zakupowe z okresu',
'integrations.externalLists': 'Liczba list',
'integrations.summaryLists': 'List',
'integrations.summarySpend': 'Kwota',
'integrations.listExpenses': 'Pozycje wybranej listy', 'integrations.listExpenses': 'Pozycje wybranej listy',
'integrations.importTitle': 'Import do lokalnych wydatków', 'integrations.importTitle': 'Import do lokalnych wydatków',
'integrations.importMonthTitle': 'Import zbiorczy miesiąca',
'integrations.importMonthHint': 'Doda jeden wydatek zakupowy dla wybranego miesiąca.',
'integrations.importListTitle': 'Import wybranej listy',
'integrations.importSelectedList': 'Importuj wybraną listę jako 1 wydatek', 'integrations.importSelectedList': 'Importuj wybraną listę jako 1 wydatek',
'integrations.selectListHint': 'Wybierz listę po lewej, aby podejrzeć pozycje i zaimportować całą listę lub pojedyncze wydatki.', 'integrations.selectListHint': 'Wybierz listę po lewej, aby podejrzeć pozycje i zaimportować całą listę lub pojedyncze wydatki.',
'integrations.selectListHintSimple': 'Wybierz listę po lewej stronie.',
'integrations.selectedListSummary': 'Pozycje / suma', 'integrations.selectedListSummary': 'Pozycje / suma',
'integrations.tags': 'Tagi importu', 'integrations.tags': 'Tagi importu',
'integrations.tagsHint': 'Oddzielaj tagi przecinkami.', 'integrations.tagsHint': 'Oddzielaj tagi przecinkami.',
@@ -396,6 +430,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'integrations.testError': 'Nie udało się połączyć z zewnętrznym API.', 'integrations.testError': 'Nie udało się połączyć z zewnętrznym API.',
'integrations.loadError': 'Nie udało się pobrać danych integracji.', 'integrations.loadError': 'Nie udało się pobrać danych integracji.',
'integrations.importListSuccess': 'Lista zakupowa została zaimportowana jako lokalny wydatek.', 'integrations.importListSuccess': 'Lista zakupowa została zaimportowana jako lokalny wydatek.',
'integrations.importPeriod': 'Importuj miesiąc',
'integrations.importPeriodSuccess': 'Miesiąc zakupów został zaimportowany.',
'integrations.disabledForUser': 'Integracje są wyłączone dla tego użytkownika.',
'integrations.importItemSuccess': 'Pozycja z listy zakupowej została zaimportowana.', 'integrations.importItemSuccess': 'Pozycja z listy zakupowej została zaimportowana.',
'integrations.importError': 'Nie udało się zaimportować danych z list zakupowych.', 'integrations.importError': 'Nie udało się zaimportować danych z list zakupowych.',
@@ -441,6 +478,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'action.cancel': 'Cancel', 'action.cancel': 'Cancel',
'action.reset': 'Reset', 'action.reset': 'Reset',
'action.show': 'Show', 'action.show': 'Show',
'action.view': 'Details',
'action.backToList': 'Back to list',
'action.clearSelection': 'Clear selection',
'action.filter': 'Filter', 'action.filter': 'Filter',
'action.refreshPreview': 'Refresh preview', 'action.refreshPreview': 'Refresh preview',
'action.sendNow': 'Send now', 'action.sendNow': 'Send now',
@@ -455,6 +495,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'action.setUser': 'Set USER', 'action.setUser': 'Set USER',
'action.setAdmin': 'Set ADMIN', 'action.setAdmin': 'Set ADMIN',
'action.import': 'Import', 'action.import': 'Import',
'action.enableIntegrations': 'Enable integrations',
'action.disableIntegrations': 'Disable integrations',
'theme.label': 'Theme', 'theme.label': 'Theme',
'theme.dark': 'Dark', 'theme.dark': 'Dark',
@@ -504,8 +546,11 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'stats.noCategoryChart': 'No category chart data available.', 'stats.noCategoryChart': 'No category chart data available.',
'stats.noTrendChart': 'No trend chart data available.', 'stats.noTrendChart': 'No trend chart data available.',
'stats.expensesLabel': 'Expenses', 'stats.expensesLabel': 'Expenses',
'table.createdAt': 'Created',
'table.updatedAt': 'Updated',
'expenses.title': 'Expenses', 'expenses.title': 'Expenses',
'expenses.listSubtitle': 'Browse, filter, and sort saved expenses.',
'expenses.subtitle': 'Add expenses, store proofs and pick merchants from the list.', 'expenses.subtitle': 'Add expenses, store proofs and pick merchants from the list.',
'expenses.new': 'New expense', 'expenses.new': 'New expense',
'expenses.edit': 'Edit expense', 'expenses.edit': 'Edit expense',
@@ -535,6 +580,17 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'expenses.filters': 'Filters and recent expenses', 'expenses.filters': 'Filters and recent expenses',
'expenses.noMerchant': 'No merchant', 'expenses.noMerchant': 'No merchant',
'expenses.noItems': 'No expenses to display.', 'expenses.noItems': 'No expenses to display.',
'expenses.listTitle': 'Expense list',
'expenses.detailTitle': 'Expense details',
'expenses.meta': 'Metadata',
'expenses.noProofs': 'No attachments.',
'expenses.selectedCount': 'Selected',
'expenses.bulkUpdated': 'Bulk status update saved.',
'expenses.bulkDeleted': 'Selected expenses were deleted.',
'expenses.bulkActionError': 'Bulk action failed.',
'expenses.bulkDeleteConfirm': 'Delete selected expenses?',
'expenses.totalItems': 'Total',
'expenses.perPage': 'per page',
'expenses.proof': 'Proof', 'expenses.proof': 'Proof',
'expenses.saving': 'Saving...', 'expenses.saving': 'Saving...',
'expenses.added': 'Expense added successfully.', 'expenses.added': 'Expense added successfully.',
@@ -542,11 +598,13 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'expenses.deleted': 'Expense deleted successfully.', 'expenses.deleted': 'Expense deleted successfully.',
'expenses.addError': 'Failed to add the expense.', 'expenses.addError': 'Failed to add the expense.',
'expenses.saveError': 'Failed to save the expense.', 'expenses.saveError': 'Failed to save the expense.',
'expenses.loadError': 'Failed to load the expense.',
'expenses.deleteError': 'Failed to delete the expense.', 'expenses.deleteError': 'Failed to delete the expense.',
'expenses.validation.title': 'Enter an expense title.', 'expenses.validation.title': 'Enter an expense title.',
'expenses.validation.amount': 'Enter a valid amount greater than 0.', 'expenses.validation.amount': 'Enter a valid amount greater than 0.',
'expenses.validation.date': 'Select an expense date.', 'expenses.validation.date': 'Select an expense date.',
'expenses.validation.category': 'Select a category.', 'expenses.validation.category': 'Select a category.',
'expenses.existingProofs': 'Current attachments',
'proof.receipt': 'Receipt', 'proof.receipt': 'Receipt',
'proof.invoice': 'Invoice', 'proof.invoice': 'Invoice',
@@ -637,6 +695,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'admin.roleError': 'Failed to change the role.', 'admin.roleError': 'Failed to change the role.',
'admin.statusUpdated': 'Account status updated successfully.', 'admin.statusUpdated': 'Account status updated successfully.',
'admin.statusError': 'Failed to change the account status.', 'admin.statusError': 'Failed to change the account status.',
'admin.integrationsAccess': 'Integrations',
'admin.integrationsUpdated': 'Integrations access has been updated.',
'nav.cashflow': 'Cashflow', 'nav.cashflow': 'Cashflow',
@@ -720,6 +780,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'cashflow.statusSummary': 'Expense statuses', 'cashflow.statusSummary': 'Expense statuses',
'cashflow.upcomingRecurring': 'Upcoming recurring', 'cashflow.upcomingRecurring': 'Upcoming recurring',
'common.none': 'None', 'common.none': 'None',
'common.loading': 'Loading...',
'common.select': 'Select', 'common.select': 'Select',
'common.noData': 'No data.', 'common.noData': 'No data.',
'common.noExpenses': 'No expenses.', 'common.noExpenses': 'No expenses.',
@@ -753,6 +814,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'integrations.selfHostedHint': 'Set the URL and token for the separate self-hosted shopping list application here.', 'integrations.selfHostedHint': 'Set the URL and token for the separate self-hosted shopping list application here.',
'integrations.importExplainTitle': 'How import works', 'integrations.importExplainTitle': 'How import works',
'integrations.importExplainBody': 'Importing from shopping lists creates a normal local expense in this app. You can import the whole list as one expense or import single entries separately.', 'integrations.importExplainBody': 'Importing from shopping lists creates a normal local expense in this app. You can import the whole list as one expense or import single entries separately.',
'integrations.importExplainBodySimple': 'You have two simple modes: import the whole month as one expense or import one selected list as one expense.',
'footer.apiOnline': 'API online', 'footer.apiOnline': 'API online',
'footer.apiOffline': 'API offline', 'footer.apiOffline': 'API offline',
@@ -790,10 +852,17 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'integrations.summary': 'External summary', 'integrations.summary': 'External summary',
'integrations.latest': 'Expenses for selected period', 'integrations.latest': 'Expenses for selected period',
'integrations.lists': 'Shopping lists for period', 'integrations.lists': 'Shopping lists for period',
'integrations.externalLists': 'Lists',
'integrations.summaryLists': 'Lists',
'integrations.summarySpend': 'Amount',
'integrations.listExpenses': 'Entries for selected list', 'integrations.listExpenses': 'Entries for selected list',
'integrations.importTitle': 'Import into local expenses', 'integrations.importTitle': 'Import into local expenses',
'integrations.importMonthTitle': 'Import whole month',
'integrations.importMonthHint': 'Adds one shopping expense for the selected month.',
'integrations.importListTitle': 'Import selected list',
'integrations.importSelectedList': 'Import selected list as 1 expense', 'integrations.importSelectedList': 'Import selected list as 1 expense',
'integrations.selectListHint': 'Select a list on the left to preview entries and import the whole list or individual expenses.', 'integrations.selectListHint': 'Select a list on the left to preview entries and import the whole list or individual expenses.',
'integrations.selectListHintSimple': 'Select a list on the left.',
'integrations.selectedListSummary': 'Entries / total', 'integrations.selectedListSummary': 'Entries / total',
'integrations.tags': 'Import tags', 'integrations.tags': 'Import tags',
'integrations.tagsHint': 'Separate tags with commas.', 'integrations.tagsHint': 'Separate tags with commas.',
@@ -806,6 +875,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'integrations.testError': 'Failed to connect to the external API.', 'integrations.testError': 'Failed to connect to the external API.',
'integrations.loadError': 'Failed to load integration data.', 'integrations.loadError': 'Failed to load integration data.',
'integrations.importListSuccess': 'The shopping list was imported as a local expense.', 'integrations.importListSuccess': 'The shopping list was imported as a local expense.',
'integrations.importPeriod': 'Import month',
'integrations.importPeriodSuccess': 'Shopping month imported successfully.',
'integrations.disabledForUser': 'Integrations are disabled for this user.',
'integrations.importItemSuccess': 'The shopping list entry was imported.', 'integrations.importItemSuccess': 'The shopping list entry was imported.',
'integrations.importError': 'Failed to import data from the shopping list API.', 'integrations.importError': 'Failed to import data from the shopping list API.',

View File

@@ -140,7 +140,7 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
</div> </div>
<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><tr><th>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.date') }}</th><th class="w-1"></th></tr></thead> <thead><tr><th>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.integrationsAccess') }}</th><th>{{ ui.t('admin.date') }}</th><th class="w-1"></th></tr></thead>
<tbody> <tbody>
@for (user of users(); track user.id) { @for (user of users(); track user.id) {
<tr> <tr>
@@ -154,20 +154,24 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
{{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }} {{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }}
</span> </span>
</td> </td>
<td><span class="badge" [class.bg-success]="user.integrationsEnabled" [class.bg-secondary]="!user.integrationsEnabled">{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td>
<td>{{ user.createdAt | date:'short' }}</td> <td>{{ user.createdAt | date:'short' }}</td>
<td> <td>
<div class="btn-list flex-nowrap"> <div class="btn-list flex-wrap">
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)"> <button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }} {{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
</button> </button>
<button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)"> <button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)">
{{ user.isActive ? ui.t('action.block') : ui.t('action.unblock') }} {{ user.isActive ? ui.t('action.block') : ui.t('action.unblock') }}
</button> </button>
<button class="btn btn-sm" [class.btn-outline-primary]="!user.integrationsEnabled" [class.btn-primary]="user.integrationsEnabled" type="button" (click)="toggleIntegrations(user)">
{{ user.integrationsEnabled ? ui.t('action.disableIntegrations') : ui.t('action.enableIntegrations') }}
</button>
</div> </div>
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="5" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr> <tr><td colspan="6" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
} }
</tbody> </tbody>
</table> </table>
@@ -296,4 +300,15 @@ export class AdminComponent implements OnInit {
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError')) error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError'))
}); });
} }
toggleIntegrations(user: User) {
this.admin.updateUser(user.id, { integrationsEnabled: !user.integrationsEnabled }).subscribe({
next: (response) => {
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
this.toast.success(this.ui.t('admin.integrationsUpdated'));
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError'))
});
}
} }

View File

@@ -1,5 +1,5 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { AfterViewChecked, Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js'; import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
import { StatsService } from '../../core/services/stats.service'; import { StatsService } from '../../core/services/stats.service';
import { UiService } from '../../core/services/ui.service'; import { UiService } from '../../core/services/ui.service';
@@ -17,9 +17,9 @@ Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearS
</div> </div>
<div class="row row-cards"> <div class="row row-cards">
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ data()?.actualCurrent || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div> <div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ (data()?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ data()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div> <div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ (data()?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ data()?.forecastCurrentMonth || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div> <div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (data()?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div> <div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div>
<div class="col-lg-8 d-flex align-items-stretch"> <div class="col-lg-8 d-flex align-items-stretch">
@@ -56,21 +56,29 @@ Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearS
</div> </div>
` `
}) })
export class CashflowComponent implements OnInit, AfterViewChecked, OnDestroy { export class CashflowComponent implements OnInit, OnDestroy {
readonly ui = inject(UiService); readonly ui = inject(UiService);
private readonly statsService = inject(StatsService); private readonly statsService = inject(StatsService);
readonly data = signal<CashflowResponse | null>(null); readonly data = signal<CashflowResponse | null>(null);
private chart?: Chart; private chart?: Chart;
private chartPending = false; ngOnInit() {
this.statsService.cashflow().subscribe({
next: (response) => {
this.data.set(response);
requestAnimationFrame(() => this.renderChart());
}
});
}
ngOnInit() { this.statsService.cashflow().subscribe({ next: (response) => { this.data.set(response); this.chartPending = true; } }); }
ngAfterViewChecked() { if (this.chartPending) { this.chartPending = false; this.renderChart(); } }
ngOnDestroy() { this.chart?.destroy(); } ngOnDestroy() { this.chart?.destroy(); }
private renderChart() { private renderChart() {
const canvas = document.getElementById('cashflowTrendChart') as HTMLCanvasElement | null; const canvas = document.getElementById('cashflowTrendChart') as HTMLCanvasElement | null;
const data = this.data(); const data = this.data();
if (!canvas || !data?.trend?.length) return; if (!canvas || !data?.trend?.length) {
this.chart?.destroy();
return;
}
this.chart?.destroy(); this.chart?.destroy();
this.chart = new Chart(canvas, { this.chart = new Chart(canvas, {
type: 'line', type: 'line',

View File

@@ -180,6 +180,10 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
}[status] || 'text-bg-secondary'; }[status] || 'text-bg-secondary';
} }
private scheduleChartRender() {
requestAnimationFrame(() => this.renderChart());
}
private renderChart() { private renderChart() {
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null; const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
if (!canvas || !this.stats?.byCategory?.length) { if (!canvas || !this.stats?.byCategory?.length) {

View File

@@ -0,0 +1,195 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { Location } from '@angular/common';
import { ExpensesService } from '../../core/services/expenses.service';
import { ToastService } from '../../core/services/toast.service';
import { UiService } from '../../core/services/ui.service';
import type { Expense, Proof } from '../../shared/models';
@Component({
selector: 'app-expense-detail',
standalone: true,
imports: [CommonModule, RouterLink, CurrencyPipe, DatePipe],
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">{{ ui.t('expenses.detailTitle') }}</h2>
<div class="text-secondary">{{ expense()?.title || ui.t('expenses.title') }}</div>
</div>
<div class="col-auto ms-auto">
<div class="btn-list">
<button class="btn btn-outline-secondary" type="button" (click)="goBack()">{{ ui.t('action.backToList') }}</button>
@if (expense()) {
<button class="btn btn-primary" type="button" (click)="editExpense()">{{ ui.t('action.edit') }}</button>
}
</div>
</div>
</div>
</div>
@if (loadError()) {
<div class="alert alert-danger">{{ loadError() }}</div>
} @else if (loading()) {
<div class="card"><div class="card-body text-secondary">{{ ui.t('common.loading') }}</div></div>
} @else if (expense(); as item) {
<div class="row row-cards">
<div class="col-12 col-xl-8">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title mb-0">{{ item.title }}</h3></div>
<div class="card-body d-grid gap-3">
<div class="row g-3">
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.date') }}</div><div class="fw-semibold">{{ item.expenseDate | date:'yyyy-MM-dd' }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.amount') }}</div><div class="fw-semibold">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.status') }}</div><div><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.category') }}</div><div class="fw-semibold">{{ item.category.name }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.payment') }}</div><div>{{ paymentLabel(item.paymentMethod) }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.merchantName') }}</div><div>{{ item.merchant || ui.t('expenses.noMerchant') }}</div></div>
</div>
@if (item.description) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.description') }}</div>
<div>{{ item.description }}</div>
</div>
}
@if (item.tags.length) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.tags') }}</div>
<div class="d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
</div>
}
@if (customFieldEntries(item).length) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.customFields') }}</div>
<div class="row g-2">@for (field of customFieldEntries(item); track field[0]) { <div class="col-sm-6"><div class="border rounded-3 p-2 h-100"><div class="text-secondary small">{{ field[0] }}</div><div class="fw-semibold">{{ field[1] }}</div></div></div> }</div>
</div>
}
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.existingProofs') }}</h3></div>
<div class="card-body">
@if (item.proofs.length) {
<div class="d-grid gap-2">
@for (proof of item.proofs; track proof.id) {
<button class="btn btn-outline-secondary text-start" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
}
</div>
} @else {
<div class="text-secondary">{{ ui.t('expenses.noProofs') }}</div>
}
</div>
</div>
<div class="card overflow-hidden mt-3">
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.meta') }}</h3></div>
<div class="card-body d-grid gap-2 small">
<div><span class="text-secondary">ID:</span> {{ item.id }}</div>
<div><span class="text-secondary">{{ ui.t('table.createdAt') || 'Utworzono' }}:</span> {{ item.createdAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div><span class="text-secondary">{{ ui.t('table.updatedAt') || 'Zmieniono' }}:</span> {{ item.updatedAt | date:'yyyy-MM-dd HH:mm' }}</div>
</div>
</div>
</div>
</div>
}
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body ec-proof-modal-body">
@if (isPdf(proofPreview()!)) {
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
} @else {
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}
`
})
export class ExpenseDetailComponent implements OnInit {
readonly ui = inject(UiService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly sanitizer = inject(DomSanitizer);
private readonly expensesService = inject(ExpensesService);
private readonly toast = inject(ToastService);
readonly expense = signal<Expense | null>(null);
readonly loading = signal(true);
readonly loadError = signal('');
readonly proofPreview = signal<Proof | null>(null);
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
const proof = this.proofPreview();
if (!proof || !this.isPdf(proof)) return null;
const previewUrl = proof.previewUrl || proof.fileUrl;
if (!previewUrl) return null;
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
});
ngOnInit() {
this.route.paramMap.subscribe((params) => {
const id = params.get('id');
if (!id) return;
this.loading.set(true);
this.loadError.set('');
this.expense.set(null);
this.expensesService.getById(id).subscribe({
next: (response) => {
this.expense.set(response.item);
this.loading.set(false);
},
error: (error) => {
const message = error.error?.message ?? this.ui.t('expenses.loadError');
this.loadError.set(message);
this.loading.set(false);
this.toast.error(message);
}
});
});
}
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
openProof(proof: Proof) { this.proofPreview.set(proof); }
closeProofPreview() { this.proofPreview.set(null); }
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
goBack() { this.location.back(); }
editExpense() {
const item = this.expense();
if (!item) return;
this.router.navigate(['/expenses/add'], { queryParams: { edit: item.id } });
}
paymentLabel(value: Expense['paymentMethod']) {
if (!value) return this.ui.t('expenses.payment.none');
return ({
CARD: this.ui.t('expenses.payment.card'),
CASH: this.ui.t('expenses.payment.cash'),
TRANSFER: this.ui.t('expenses.payment.transfer'),
BLIK: 'BLIK',
OTHER: this.ui.t('expenses.payment.other')
} as Record<string, string>)[value] || value;
}
statusBadgeClass(status: string) {
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
}
}

View File

@@ -0,0 +1,551 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { CategoriesService } from '../../core/services/categories.service';
import { ExpensesService } from '../../core/services/expenses.service';
import { ToastService } from '../../core/services/toast.service';
import { UiService } from '../../core/services/ui.service';
import type { DuplicateGroup, Expense, PaginationMeta, Proof } from '../../shared/models';
type SortColumn = 'expenseDate' | 'title' | 'amount' | 'status' | 'category';
type ListState = {
startDate: string;
endDate: string;
categoryId: string;
search: string;
status: string;
tags: string;
duplicatesOnly: boolean;
page: number;
pageSize: number;
sortBy: SortColumn;
sortDir: 'asc' | 'desc';
};
const defaultState: ListState = {
startDate: '',
endDate: '',
categoryId: '',
search: '',
status: '',
tags: '',
duplicatesOnly: false,
page: 1,
pageSize: 20,
sortBy: 'expenseDate',
sortDir: 'desc'
};
@Component({
selector: 'app-expense-list',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe],
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">{{ ui.t('expenses.title') }}</h2>
<div class="text-secondary">{{ ui.t('expenses.listSubtitle') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<nav class="nav nav-pills gap-2">
<a class="nav-link" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
<a class="nav-link active" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
</nav>
</div>
@if (duplicateGroups().length) {
<div class="alert alert-warning">
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
<div class="d-grid gap-1">
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
<div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div>
}
</div>
</div>
}
<div class="card overflow-hidden mb-3">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ ui.t('expenses.filters') }}</h3>
@if (hasActiveFilters()) {
<span class="badge text-bg-primary">{{ ui.t('action.filter') }}</span>
}
</div>
<div class="card-body">
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()" class="row g-3 align-items-end">
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
</form>
</div>
</div>
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h3 class="card-title mb-0">{{ ui.t('expenses.listTitle') }}</h3>
<div class="small text-secondary">{{ ui.t('expenses.totalItems') }}: {{ pagination().total }}</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<select class="form-select form-select-sm w-auto" [value]="pagination().pageSize" (change)="changePageSize($any($event.target).value)">
@for (size of pageSizeOptions; track size) {
<option [value]="size">{{ size }} / {{ ui.t('expenses.perPage') }}</option>
}
</select>
</div>
</div>
@if (selectedIds().length) {
<div class="card-body border-bottom bg-body-tertiary py-3">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="fw-semibold">{{ ui.t('expenses.selectedCount') }}: {{ selectedIds().length }}</div>
<div class="btn-list flex-wrap">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="clearSelection()">{{ ui.t('action.clearSelection') }}</button>
<button class="btn btn-sm btn-outline-warning" type="button" (click)="bulkUpdateStatus('PENDING')">{{ ui.t('status.pending') }}</button>
<button class="btn btn-sm btn-outline-success" type="button" (click)="bulkUpdateStatus('APPROVED')">{{ ui.t('status.approved') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="bulkDelete()">{{ ui.t('action.delete') }}</button>
</div>
</div>
</div>
}
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr>
<th class="w-1">
<input class="form-check-input" type="checkbox" [checked]="allVisibleSelected()" [indeterminate]="someVisibleSelected()" (change)="toggleAllVisible($any($event.target).checked)" />
</th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('expenseDate')">{{ ui.t('expenses.field.date') }} {{ sortIndicator('expenseDate') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('title')">{{ ui.t('table.title') }} {{ sortIndicator('title') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('category')">{{ ui.t('expenses.field.category') }} {{ sortIndicator('category') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('status')">{{ ui.t('expenses.field.status') }} {{ sortIndicator('status') }}</button></th>
<th class="text-end"><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('amount')">{{ ui.t('table.amount') }} {{ sortIndicator('amount') }}</button></th>
<th class="text-end">{{ ui.t('table.actions') }}</th>
</tr>
</thead>
<tbody>
@for (item of expenses(); track item.id) {
<tr>
<td><input class="form-check-input" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleSelection(item.id, $any($event.target).checked)" /></td>
<td>{{ item.expenseDate | date:'yyyy-MM-dd' }}</td>
<td>
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
<a class="link-body-emphasis text-decoration-none" [routerLink]="['/expenses', item.id]">{{ item.title }}</a>
@if (item.possibleDuplicate || item.duplicateStatus) {
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
}
@if (item.recurringSourceId) {
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
}
</div>
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
</td>
<td>{{ item.category.name }}</td>
<td>
<div class="d-grid gap-2">
<span class="badge d-inline-flex align-items-center justify-content-center" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span>
<select class="form-select form-select-sm" [value]="item.status" [disabled]="statusSavingId() === item.id" (change)="quickChangeStatus(item, $any($event.target).value)">
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
<option value="PENDING">{{ ui.t('status.pending') }}</option>
<option value="APPROVED">{{ ui.t('status.approved') }}</option>
<option value="REJECTED">{{ ui.t('status.rejected') }}</option>
</select>
</div>
</td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
<td class="text-end">
<div class="btn-list justify-content-end flex-wrap">
<a class="btn btn-sm btn-outline-secondary" [routerLink]="['/expenses', item.id]">{{ ui.t('action.view') }}</a>
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
}
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
}
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
}
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
</div>
</td>
</tr>
} @empty { <tr><td colspan="7" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="small text-secondary">{{ pageStart() }}-{{ pageEnd() }} / {{ pagination().total }}</div>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(1)">«</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(pagination().page - 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" disabled>{{ pagination().page }} / {{ pagination().totalPages }}</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().page + 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().totalPages)">»</button>
</div>
</div>
</div>
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body ec-proof-modal-body">
@if (isPdf(proofPreview()!)) {
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
} @else {
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}
`
})
export class ExpenseListComponent implements OnInit {
readonly ui = inject(UiService);
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly sanitizer = inject(DomSanitizer);
private readonly categoriesService = inject(CategoriesService);
private readonly expensesService = inject(ExpensesService);
private readonly toast = inject(ToastService);
readonly categories = this.categoriesService.items;
readonly expenses = signal<Expense[]>([]);
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
readonly proofPreview = signal<Proof | null>(null);
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
const proof = this.proofPreview();
if (!proof || !this.isPdf(proof)) return null;
const previewUrl = proof.previewUrl || proof.fileUrl;
if (!previewUrl) return null;
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
});
readonly statusSavingId = signal<string | null>(null);
readonly pagination = signal<PaginationMeta>({ page: 1, pageSize: 20, total: 0, totalPages: 1, hasPrev: false, hasNext: false });
readonly pageSizeOptions = [10, 20, 50];
readonly sortBy = signal<SortColumn>('expenseDate');
readonly sortDir = signal<'asc' | 'desc'>('desc');
readonly selectedIds = signal<string[]>([]);
readonly visibleIds = computed(() => this.expenses().map((item) => item.id));
readonly allVisibleSelected = computed(() => this.visibleIds().length > 0 && this.visibleIds().every((id) => this.selectedIds().includes(id)));
readonly someVisibleSelected = computed(() => !this.allVisibleSelected() && this.visibleIds().some((id) => this.selectedIds().includes(id)));
readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] });
ngOnInit() {
this.categoriesService.ensureLoaded(true);
this.route.queryParamMap.subscribe((params) => {
const state: ListState = {
startDate: params.get('startDate') ?? defaultState.startDate,
endDate: params.get('endDate') ?? defaultState.endDate,
categoryId: params.get('categoryId') ?? defaultState.categoryId,
search: params.get('search') ?? defaultState.search,
status: params.get('status') ?? defaultState.status,
tags: params.get('tags') ?? defaultState.tags,
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
page: this.parsePositiveInt(params.get('page'), defaultState.page),
pageSize: this.parsePositiveInt(params.get('pageSize'), defaultState.pageSize),
sortBy: this.parseSortColumn(params.get('sortBy')),
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
};
this.filterForm.patchValue({
startDate: state.startDate,
endDate: state.endDate,
categoryId: state.categoryId,
search: state.search,
status: state.status,
tags: state.tags,
duplicatesOnly: state.duplicatesOnly
}, { emitEvent: false });
this.sortBy.set(state.sortBy);
this.sortDir.set(state.sortDir);
this.pagination.update((current) => ({ ...current, page: state.page, pageSize: state.pageSize }));
this.loadExpenses(state);
this.loadDuplicates();
});
}
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
hasActiveFilters() {
const raw = this.filterForm.getRawValue();
return Boolean(raw.startDate || raw.endDate || raw.categoryId || raw.search || raw.status || raw.tags || raw.duplicatesOnly);
}
private loadExpenses(state: ListState) {
this.expensesService.list({
startDate: state.startDate || undefined,
endDate: state.endDate || undefined,
categoryId: state.categoryId || undefined,
search: state.search || undefined,
status: state.status || undefined,
tags: state.tags || undefined,
duplicatesOnly: state.duplicatesOnly || undefined,
page: state.page,
pageSize: state.pageSize,
sortBy: state.sortBy,
sortDir: state.sortDir
}).subscribe({
next: (response) => {
this.expenses.set(response.items);
this.pagination.set(response.pagination ?? { page: state.page, pageSize: state.pageSize, total: response.items.length, totalPages: 1, hasPrev: false, hasNext: false });
this.selectedIds.update((ids) => ids.filter((id) => response.items.some((item) => item.id === id)));
}
});
}
private loadDuplicates() {
this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) });
}
private buildQueryParams(overrides: Partial<ListState> = {}) {
const raw = this.filterForm.getRawValue();
const state: ListState = {
startDate: raw.startDate,
endDate: raw.endDate,
categoryId: raw.categoryId,
search: raw.search,
status: raw.status,
tags: raw.tags,
duplicatesOnly: raw.duplicatesOnly,
page: this.pagination().page,
pageSize: this.pagination().pageSize,
sortBy: this.sortBy(),
sortDir: this.sortDir(),
...overrides
};
return {
startDate: state.startDate || null,
endDate: state.endDate || null,
categoryId: state.categoryId || null,
search: state.search || null,
status: state.status || null,
tags: state.tags || null,
duplicatesOnly: state.duplicatesOnly ? '1' : null,
page: state.page !== defaultState.page ? state.page : null,
pageSize: state.pageSize !== defaultState.pageSize ? state.pageSize : null,
sortBy: state.sortBy !== defaultState.sortBy ? state.sortBy : null,
sortDir: state.sortDir !== defaultState.sortDir ? state.sortDir : null
};
}
private updateUrl(overrides: Partial<ListState> = {}) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: this.buildQueryParams(overrides),
replaceUrl: true
});
}
applyFilters() {
this.clearSelection();
this.updateUrl({ page: 1 });
}
resetFilters() {
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
this.clearSelection();
this.updateUrl({ ...defaultState });
}
setSort(column: SortColumn) {
if (this.sortBy() === column) this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
else {
this.sortBy.set(column);
this.sortDir.set(column === 'amount' || column === 'title' || column === 'category' ? 'asc' : 'desc');
}
this.clearSelection();
this.updateUrl({ page: 1, sortBy: this.sortBy(), sortDir: this.sortDir() });
}
sortIndicator(column: SortColumn) {
if (this.sortBy() !== column) return '';
return this.sortDir() === 'asc' ? '↑' : '↓';
}
changePage(page: number) {
if (page < 1 || page > this.pagination().totalPages) return;
this.updateUrl({ page });
}
changePageSize(value: string | number) {
const pageSize = Number(value);
if (!Number.isFinite(pageSize) || pageSize <= 0) return;
this.clearSelection();
this.updateUrl({ page: 1, pageSize });
}
pageStart() {
if (!this.pagination().total) return 0;
return (this.pagination().page - 1) * this.pagination().pageSize + 1;
}
pageEnd() {
if (!this.pagination().total) return 0;
return Math.min(this.pagination().page * this.pagination().pageSize, this.pagination().total);
}
isSelected(id: string) { return this.selectedIds().includes(id); }
toggleSelection(id: string, checked: boolean) {
this.selectedIds.update((ids) => checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id));
}
toggleAllVisible(checked: boolean) {
const visibleIds = this.visibleIds();
this.selectedIds.update((ids) => {
if (checked) return Array.from(new Set([...ids, ...visibleIds]));
return ids.filter((id) => !visibleIds.includes(id));
});
}
clearSelection() { this.selectedIds.set([]); }
startEdit(item: Expense) {
this.router.navigate(['/expenses/add'], { queryParams: { edit: item.id } });
}
removeExpense(item: Expense) {
this.expensesService.delete(item.id).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.deleted'));
this.clearSelection();
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
});
}
quickChangeStatus(item: Expense, nextStatus: string) {
if (!nextStatus || nextStatus === item.status) return;
this.statusSavingId.set(item.id);
this.expensesService.updateStatus(item.id, nextStatus as Expense['status']).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.statusUpdated'));
this.statusSavingId.set(null);
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => {
this.statusSavingId.set(null);
this.toast.error(error.error?.message ?? this.ui.t('expenses.statusUpdateError'));
this.loadExpensesFromCurrentRoute();
}
});
}
bulkUpdateStatus(status: Expense['status']) {
if (!this.selectedIds().length) return;
this.expensesService.bulkUpdateStatus(this.selectedIds(), status).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.bulkUpdated'));
this.clearSelection();
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
});
}
bulkDelete() {
if (!this.selectedIds().length) return;
if (!globalThis.confirm(this.ui.t('expenses.bulkDeleteConfirm'))) return;
this.expensesService.bulkDelete(this.selectedIds()).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.bulkDeleted'));
this.clearSelection();
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
});
}
reviewDuplicate(item: Expense, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
this.expensesService.reviewDuplicate(item.id, action).subscribe({
next: () => {
if (action === 'CONFIRM') this.toast.success(this.ui.t('expenses.duplicateConfirmed'));
if (action === 'DISMISS') this.toast.success(this.ui.t('expenses.duplicateDismissed'));
if (action === 'REOPEN') this.toast.success(this.ui.t('expenses.duplicateReopened'));
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error'))
});
}
openProof(proof: Proof) { this.proofPreview.set(proof); }
closeProofPreview() { this.proofPreview.set(null); }
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
statusBadgeClass(status: string) {
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
}
duplicateBadgeClass(item: Expense) {
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
return ({ OPEN: 'text-bg-warning', CONFIRMED: 'text-bg-danger', DISMISSED: 'text-bg-secondary' } as Record<string, string>)[state || 'OPEN'] || 'text-bg-warning';
}
duplicateLabel(item: Expense) {
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
if (state === 'CONFIRMED') return this.ui.t('expenses.duplicateStatus.confirmed');
if (state === 'DISMISSED') return this.ui.t('expenses.duplicateStatus.dismissed');
return this.ui.t('expenses.duplicateStatus.open');
}
private loadExpensesFromCurrentRoute() {
const params = this.route.snapshot.queryParamMap;
this.loadExpenses({
startDate: params.get('startDate') ?? defaultState.startDate,
endDate: params.get('endDate') ?? defaultState.endDate,
categoryId: params.get('categoryId') ?? defaultState.categoryId,
search: params.get('search') ?? defaultState.search,
status: params.get('status') ?? defaultState.status,
tags: params.get('tags') ?? defaultState.tags,
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
page: this.parsePositiveInt(params.get('page'), this.pagination().page),
pageSize: this.parsePositiveInt(params.get('pageSize'), this.pagination().pageSize),
sortBy: this.parseSortColumn(params.get('sortBy')),
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
});
}
private parsePositiveInt(value: string | null, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
}
private parseSortColumn(value: string | null): SortColumn {
return (['expenseDate', 'title', 'amount', 'status', 'category'] as const).includes((value ?? '') as SortColumn)
? (value as SortColumn)
: defaultState.sortBy;
}
}

View File

@@ -1,5 +1,7 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper'; import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
import { CategoriesService } from '../../core/services/categories.service'; import { CategoriesService } from '../../core/services/categories.service';
@@ -7,7 +9,7 @@ import { ExpensesService } from '../../core/services/expenses.service';
import { MerchantsService } from '../../core/services/merchants.service'; import { MerchantsService } from '../../core/services/merchants.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
import { UiService } from '../../core/services/ui.service'; import { UiService } from '../../core/services/ui.service';
import type { DuplicateGroup, Expense, Merchant, Proof } from '../../shared/models'; import type { Expense, Merchant, Proof } from '../../shared/models';
const formatLocalDate = (date: Date) => { const formatLocalDate = (date: Date) => {
const year = date.getFullYear(); const year = date.getFullYear();
@@ -21,26 +23,38 @@ const today = formatLocalDate(new Date());
@Component({ @Component({
selector: 'app-expenses', selector: 'app-expenses',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent], imports: [CommonModule, ReactiveFormsModule, RouterLink, ImageCropperComponent],
template: ` template: `
<div class="page-header d-print-none mb-3 ec-page-header"> <div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2><div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div></div></div> <div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
</div>
</div>
</div> </div>
@if (duplicateGroups().length) { <div class="mb-3">
<div class="alert alert-warning"> <nav class="nav nav-pills gap-2">
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div> <a class="nav-link active" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
<div class="d-grid gap-1">@for (group of duplicateGroups().slice(0, 3); track group.source.id) { <div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div> }</div> <a class="nav-link" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
</div> </nav>
} </div>
<div class="row row-cards align-items-start"> <div class="row row-cards align-items-start">
<div class="col-xl-7"> <div class="col-12">
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center"><h3 class="card-title">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>@if (editingExpenseId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }</div> <div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
@if (editingExpenseId()) {
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
}
</div>
<div class="card-body"> <div class="card-body">
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate> <form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
@if (submitted() && expenseForm.invalid) { <div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div> } @if (submitted() && expenseForm.invalid) {
<div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div>
}
<div class="row g-3"> <div class="row g-3">
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div> <div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div>
@@ -70,25 +84,38 @@ const today = formatLocalDate(new Date());
</div> </div>
</div></div> </div></div>
@if (!editingExpenseId()) { <div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3"> <div class="row g-3">
<div class="row g-3"> <div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div> <div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div> <div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div> <div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div> </div>
@if (editingExpenseId() && editingProofs().length) {
<div>
<div class="form-label">{{ ui.t('expenses.existingProofs') }}</div>
<div class="d-grid gap-2">
@for (proof of editingProofs(); track proof.id) {
<div class="d-flex justify-content-between align-items-center gap-2 border rounded-3 p-2 bg-white">
<button class="btn btn-link text-start p-0 text-decoration-none flex-grow-1" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="markProofForRemoval(proof)">{{ ui.t('action.delete') }}</button>
</div>
}
</div>
</div> </div>
@if (showCropper()) { }
<div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
} @if (showCropper()) {
@if (croppedPreview()) { <div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
<div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div> }
} @if (croppedPreview()) {
@if (selectedFiles().length) { <div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div> }
} @if (selectedFiles().length) {
</div></div> <div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
} }
</div></div>
<div class="btn-list flex-wrap"> <div class="btn-list flex-wrap">
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button> <button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
@@ -99,75 +126,12 @@ const today = formatLocalDate(new Date());
</div> </div>
</div> </div>
<div class="col-xl-5">
<div class="card overflow-hidden mb-3">
<div class="card-header"><h3 class="card-title">{{ ui.t('expenses.filters') }}</h3></div>
<div class="card-body"><form [formGroup]="filterForm" (ngSubmit)="loadExpenses()" class="row g-3 align-items-end">
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
</form></div>
</div>
<div class="card overflow-hidden">
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
<tbody>
@for (item of expenses(); track item.id) {
<tr>
<td>
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
{{ item.title }}
@if (item.possibleDuplicate || item.duplicateStatus) {
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
}
@if (item.recurringSourceId) {
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
}
</div>
<div class="text-secondary small">{{ item.expenseDate | date:'yyyy-MM-dd' }} · {{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}</div>
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
</td>
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
<td class="text-end">
<div class="btn-list justify-content-end flex-wrap">
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
}
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
}
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
}
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
</div>
</td>
</tr>
} @empty { <tr><td colspan="4" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
</tbody>
</table>
</div>
</div>
</div>
</div>
@if (merchantModalOpen()) { @if (merchantModalOpen()) {
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close" type="button" (click)="closeMerchantModal()"></button></div><form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()"><div class="modal-body"><div class="d-grid gap-3"><div><label class="form-label">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</label><textarea class="form-control" rows="3" formControlName="notes"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div> <div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeMerchantModal()"></button></div><form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()"><div class="modal-body"><div class="d-grid gap-3"><div><label class="form-label">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</label><textarea class="form-control" rows="3" formControlName="notes"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div>
} }
@if (proofPreview()) { @if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5><button class="btn-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body">@if (isPdf(proofPreview()!)) { <embed [attr.src]="proofPreview()?.fileUrl" type="application/pdf" style="width:100%;height:75vh;" /> } @else { <img class="img-fluid" [src]="proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show"></div> <div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body ec-proof-modal-body">@if (isPdf(proofPreview()!)) { <iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe> } @else { <img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
} }
` `
}) })
@@ -178,22 +142,33 @@ export class ExpensesComponent implements OnInit {
private readonly merchantsService = inject(MerchantsService); private readonly merchantsService = inject(MerchantsService);
private readonly expensesService = inject(ExpensesService); private readonly expensesService = inject(ExpensesService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly sanitizer = inject(DomSanitizer);
readonly categories = this.categoriesService.items; readonly categories = this.categoriesService.items;
readonly merchants = this.merchantsService.items; readonly merchants = this.merchantsService.items;
readonly expenses = signal<Expense[]>([]);
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
readonly selectedMerchantId = signal(''); readonly selectedMerchantId = signal('');
readonly editingExpenseId = signal<string | null>(null); readonly editingExpenseId = signal<string | null>(null);
readonly saving = signal(false); readonly saving = signal(false);
readonly submitted = signal(false); readonly submitted = signal(false);
readonly merchantModalOpen = signal(false); readonly merchantModalOpen = signal(false);
readonly proofPreview = signal<Proof | null>(null); readonly proofPreview = signal<Proof | null>(null);
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
const proof = this.proofPreview();
if (!proof || !this.isPdf(proof)) return null;
const previewUrl = proof.previewUrl || proof.fileUrl;
if (!previewUrl) return null;
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
});
readonly selectedFiles = signal<File[]>([]); readonly selectedFiles = signal<File[]>([]);
readonly imageChangedEvent = signal<Event | null>(null); readonly imageChangedEvent = signal<Event | null>(null);
readonly croppedFile = signal<File | null>(null); readonly croppedFile = signal<File | null>(null);
readonly croppedPreview = signal<string | null>(null); readonly croppedPreview = signal<string | null>(null);
readonly showCropper = signal(false); readonly showCropper = signal(false);
readonly editingProofs = signal<Proof[]>([]);
readonly removedProofIds = signal<string[]>([]);
readonly expenseForm = this.fb.nonNullable.group({ readonly expenseForm = this.fb.nonNullable.group({
title: ['', [Validators.required, Validators.minLength(2)]], title: ['', [Validators.required, Validators.minLength(2)]],
@@ -211,7 +186,6 @@ export class ExpensesComponent implements OnInit {
customFields: this.fb.array([]) customFields: this.fb.array([])
}); });
readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] });
readonly merchantForm = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], kind: ['MERCHANT' as Merchant['kind'], Validators.required], notes: [''] }); readonly merchantForm = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], kind: ['MERCHANT' as Merchant['kind'], Validators.required], notes: [''] });
get customFields() { return this.expenseForm.controls.customFields as FormArray; } get customFields() { return this.expenseForm.controls.customFields as FormArray; }
@@ -220,26 +194,24 @@ export class ExpensesComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.categoriesService.ensureLoaded(true); this.categoriesService.ensureLoaded(true);
this.merchantsService.ensureLoaded(true); this.merchantsService.ensureLoaded(true);
this.loadExpenses(); this.route.queryParamMap.subscribe((params) => {
this.loadDuplicates(); const editId = params.get('edit');
if (editId) this.loadExpenseForEdit(editId);
else this.cancelEdit(false);
});
} }
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); } addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
removeCustomField(index: number) { this.customFields.removeAt(index); } removeCustomField(index: number) { this.customFields.removeAt(index); }
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
loadExpenses() { private loadExpenseForEdit(id: string) {
const raw = this.filterForm.getRawValue(); this.expensesService.getById(id).subscribe({
this.expensesService.list({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryId: raw.categoryId || undefined, search: raw.search || undefined, status: raw.status || undefined, tags: raw.tags || undefined, duplicatesOnly: raw.duplicatesOnly || undefined }).subscribe({ next: (response) => this.expenses.set(response.items) }); next: (response) => this.startEdit(response.item),
} error: (error) => {
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
loadDuplicates() { this.cancelEdit();
this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) }); }
} });
resetFilters() {
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
this.loadExpenses();
} }
selectMerchant(id: string) { selectMerchant(id: string) {
@@ -283,6 +255,11 @@ export class ExpensesComponent implements OnInit {
this.croppedPreview.set(event.objectUrl ?? null); this.croppedPreview.set(event.objectUrl ?? null);
} }
markProofForRemoval(proof: Proof) {
this.removedProofIds.update((ids) => Array.from(new Set([...ids, proof.id])));
this.editingProofs.update((items) => items.filter((item) => item.id !== proof.id));
}
submitExpense(forcedStatus?: Expense['status']) { submitExpense(forcedStatus?: Expense['status']) {
this.submitted.set(true); this.submitted.set(true);
this.expenseForm.markAllAsTouched(); this.expenseForm.markAllAsTouched();
@@ -295,21 +272,6 @@ export class ExpensesComponent implements OnInit {
const status = forcedStatus ?? (raw.status as Expense['status']); const status = forcedStatus ?? (raw.status as Expense['status']);
this.saving.set(true); 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', status, tags, customFields }).subscribe({
next: (response) => {
this.finishSave(response.warnings);
this.toast.success(this.ui.t('expenses.saved'));
this.cancelEdit();
},
error: (error) => {
this.saving.set(false);
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
}
});
return;
}
const formData = new FormData(); const formData = new FormData();
formData.set('title', raw.title); formData.set('title', raw.title);
formData.set('amount', String(raw.amount)); formData.set('amount', String(raw.amount));
@@ -325,6 +287,7 @@ export class ExpensesComponent implements OnInit {
formData.set('proofType', raw.proofType); formData.set('proofType', raw.proofType);
formData.set('proofLabel', raw.proofLabel); formData.set('proofLabel', raw.proofLabel);
formData.set('proofNote', raw.proofNote); formData.set('proofNote', raw.proofNote);
formData.set('removeProofIds', JSON.stringify(this.removedProofIds()));
const selected = this.selectedFiles(); const selected = this.selectedFiles();
if (this.croppedFile()) { if (this.croppedFile()) {
@@ -334,39 +297,42 @@ export class ExpensesComponent implements OnInit {
selected.forEach((file) => formData.append('proofFiles', file)); selected.forEach((file) => formData.append('proofFiles', file));
} }
this.expensesService.create(formData).subscribe({ const request = this.editingExpenseId()
? this.expensesService.update(this.editingExpenseId()!, formData)
: this.expensesService.create(formData);
request.subscribe({
next: (response) => { next: (response) => {
this.finishSave(response.warnings); this.saving.set(false);
this.toast.success(status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added')); this.submitted.set(false);
response.warnings?.forEach((warning) => this.toast.warning(warning));
const wasEditing = Boolean(this.editingExpenseId());
this.toast.success(wasEditing ? this.ui.t('expenses.saved') : status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added'));
this.resetForm();
if (wasEditing) this.router.navigate(['/expenses/add']);
}, },
error: (error) => { error: (error) => {
this.saving.set(false); this.saving.set(false);
this.toast.error(error.error?.message ?? this.ui.t('expenses.addError')); this.toast.error(error.error?.message ?? (this.editingExpenseId() ? this.ui.t('expenses.saveError') : this.ui.t('expenses.addError')));
} }
}); });
} }
private finishSave(warnings?: string[]) {
this.saving.set(false);
this.submitted.set(false);
warnings?.forEach((warning) => this.toast.warning(warning));
this.resetForm();
this.loadExpenses();
this.loadDuplicates();
}
startEdit(item: Expense) { startEdit(item: Expense) {
this.editingExpenseId.set(item.id); this.editingExpenseId.set(item.id);
this.editingProofs.set(item.proofs || []);
this.removedProofIds.set([]);
this.submitted.set(false); this.submitted.set(false);
this.customFields.clear(); this.customFields.clear();
Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value)); Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value));
this.expenseForm.patchValue({ title: item.title, amount: item.amount, expenseDate: item.expenseDate, categoryId: item.category.id, merchant: item.merchant ?? '', paymentMethod: item.paymentMethod ?? '', description: item.description ?? '', status: item.status, tagsText: (item.tags || []).join(', '), proofType: 'RECEIPT', proofLabel: '', proofNote: '' }); this.expenseForm.patchValue({ title: item.title, amount: item.amount, expenseDate: item.expenseDate, categoryId: item.category.id, merchant: item.merchant ?? '', paymentMethod: item.paymentMethod ?? '', description: item.description ?? '', status: item.status, tagsText: (item.tags || []).join(', '), proofType: 'RECEIPT', proofLabel: '', proofNote: '' });
} }
cancelEdit() { cancelEdit(navigate = true) {
this.editingExpenseId.set(null); this.editingExpenseId.set(null);
this.submitted.set(false); this.submitted.set(false);
this.resetForm(); this.resetForm();
if (navigate) this.router.navigate(['/expenses/add']);
} }
private resetForm() { private resetForm() {
@@ -376,51 +342,14 @@ export class ExpensesComponent implements OnInit {
this.selectedFiles.set([]); this.selectedFiles.set([]);
this.croppedFile.set(null); this.croppedFile.set(null);
this.croppedPreview.set(null); this.croppedPreview.set(null);
this.imageChangedEvent.set(null);
this.showCropper.set(false); this.showCropper.set(false);
} this.editingProofs.set([]);
this.removedProofIds.set([]);
removeExpense(item: Expense) {
this.expensesService.delete(item.id).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.deleted'));
this.loadExpenses();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
});
}
reviewDuplicate(item: Expense, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
this.expensesService.reviewDuplicate(item.id, action).subscribe({
next: () => {
if (action === 'CONFIRM') this.toast.success(this.ui.t('expenses.duplicateConfirmed'));
if (action === 'DISMISS') this.toast.success(this.ui.t('expenses.duplicateDismissed'));
if (action === 'REOPEN') this.toast.success(this.ui.t('expenses.duplicateReopened'));
this.loadExpenses();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error'))
});
} }
openProof(proof: Proof) { this.proofPreview.set(proof); } openProof(proof: Proof) { this.proofPreview.set(proof); }
closeMerchantModal() { this.merchantModalOpen.set(false); } closeMerchantModal() { this.merchantModalOpen.set(false); }
closeProofPreview() { this.proofPreview.set(null); } closeProofPreview() { this.proofPreview.set(null); }
isPdf(proof: Proof) { return (proof.mimeType || '').includes('pdf'); } isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
statusBadgeClass(status: string) {
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
}
duplicateBadgeClass(item: Expense) {
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
return ({ OPEN: 'text-bg-warning', CONFIRMED: 'text-bg-danger', DISMISSED: 'text-bg-secondary' } as Record<string, string>)[state || 'OPEN'] || 'text-bg-warning';
}
duplicateLabel(item: Expense) {
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
if (state === 'CONFIRMED') return this.ui.t('expenses.duplicateStatus.confirmed');
if (state === 'DISMISSED') return this.ui.t('expenses.duplicateStatus.dismissed');
return this.ui.t('expenses.duplicateStatus.open');
}
} }

View File

@@ -14,9 +14,8 @@ const monthRange = (period: string) => {
const [yearText, monthText] = safe.split('-'); const [yearText, monthText] = safe.split('-');
const year = Number(yearText); const year = Number(yearText);
const month = Number(monthText); const month = Number(monthText);
const nextMonth = month === 12 ? new Date(year + 1, 0, 1) : new Date(year, month, 1); const lastDay = new Date(year, month, 0).getDate();
const end = new Date(nextMonth.getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10); return { start: `${safe}-01`, end: `${safe}-${String(lastDay).padStart(2, '0')}` };
return { start: `${safe}-01`, end };
}; };
@Component({ @Component({
@@ -97,7 +96,7 @@ const monthRange = (period: string) => {
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">{{ ui.t('integrations.limit') }}</label> <label class="form-label">{{ ui.t('integrations.limit') }}</label>
<input class="form-control" type="number" min="1" max="200" formControlName="limit" /> <input class="form-control" type="number" min="1" max="300" formControlName="limit" />
</div> </div>
<div class="col-md-5"> <div class="col-md-5">
<div class="btn-list justify-content-md-end"> <div class="btn-list justify-content-md-end">
@@ -107,14 +106,23 @@ const monthRange = (period: string) => {
</form> </form>
<div class="row row-cards"> <div class="row row-cards">
<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="col-md-4"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalLists') }}</div><div class="ec-stat-value">{{ summaryListCount() }}</div></div></div>
<div class="col-md-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="col-md-4"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
<div class="col-md-4"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
</div> </div>
<div class="border rounded-3 p-3 bg-body-tertiary"> <div class="border rounded-3 p-3 bg-body-tertiary">
@if (configured()) { @if (configured()) {
<div class="small text-secondary mb-2">{{ ui.t('integrations.summary') }}</div> <div class="d-flex justify-content-between gap-2 flex-wrap align-items-center">
<pre class="mb-0 small">{{ summaryText() }}</pre> <div>
<div class="fw-semibold">{{ ui.t('integrations.summary') }}</div>
<div class="text-secondary small">{{ historyForm.controls.period.value }}</div>
</div>
<div class="text-end small text-secondary">
{{ ui.t('integrations.summaryLists') }}: <strong>{{ summaryListCount() }}</strong> ·
{{ ui.t('integrations.summarySpend') }}: <strong>{{ summaryAmount() | number:'1.2-2' }} PLN</strong>
</div>
</div>
} @else { } @else {
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div> <div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
} }
@@ -126,18 +134,16 @@ const monthRange = (period: string) => {
<div class="row row-cards mb-3"> <div class="row row-cards mb-3">
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card overflow-hidden h-100"> <div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.lists') }}</h3></div> <div class="card-header d-flex justify-content-between align-items-center gap-2">
<div class="list-group list-group-flush"> <h3 class="card-title mb-0">{{ ui.t('integrations.lists') }}</h3>
<span class="badge text-bg-secondary">{{ visibleLists().length }}</span>
</div>
<div class="list-group list-group-flush ec-scroll-list">
@for (item of visibleLists(); track item.id) { @for (item of visibleLists(); track item.id) {
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)"> <button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
<div class="d-flex justify-content-between gap-2 align-items-start"> <div class="fw-semibold">{{ listTitle(item) }}</div>
<div> <div class="small text-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }} · {{ listOwner(item) || ui.t('common.none') }}</div>
<div class="fw-semibold">{{ listTitle(item) }}</div>
<div class="text-secondary small">#{{ item.id }} · {{ listOwner(item) || ui.t('common.none') }}</div>
</div>
<span class="badge text-bg-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }}</span>
</div>
</button> </button>
} @empty { } @empty {
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div> <div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
@@ -147,12 +153,12 @@ const monthRange = (period: string) => {
</div> </div>
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card overflow-hidden ec-accent-card ec-accent-card-success h-100"> <div class="card overflow-hidden ec-accent-card ec-accent-card-success">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div> <div class="card-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="alert alert-warning mb-0">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div> <div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
<div>{{ ui.t('integrations.importExplainBody') }}</div> <div>{{ ui.t('integrations.importExplainBodySimple') }}</div>
</div> </div>
<form [formGroup]="importForm" class="row g-3"> <form [formGroup]="importForm" class="row g-3">
@@ -176,91 +182,60 @@ const monthRange = (period: string) => {
<label class="form-label">{{ ui.t('table.merchant') }}</label> <label class="form-label">{{ ui.t('table.merchant') }}</label>
<input class="form-control" formControlName="merchant" /> <input class="form-control" formControlName="merchant" />
</div> </div>
<div class="col-12">
<label class="form-label">{{ ui.t('integrations.tags') }}</label>
<input class="form-control" formControlName="tags" />
<div class="form-hint">{{ ui.t('integrations.tagsHint') }}</div>
</div>
</form> </form>
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importMonthTitle') }}</div>
<div class="text-secondary small mb-3">{{ ui.t('integrations.importMonthHint') }}</div>
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || !configured()" (click)="importPeriod()">{{ ui.t('integrations.importPeriod') }}</button>
</div>
</div>
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importListTitle') }}</div>
<div class="text-secondary small mb-3">{{ selectedList() ? listTitle(selectedList()) : ui.t('integrations.selectListHintSimple') }}</div>
<button class="btn btn-success" type="button" [disabled]="importForm.invalid || !selectedList()" (click)="importSelectedList()">{{ ui.t('integrations.importSelectedList') }}</button>
</div>
</div>
</div>
@if (selectedList()) { @if (selectedList()) {
<div class="border rounded-3 p-3 bg-body-tertiary"> <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-2 flex-wrap align-items-start mb-2">
<div> <div>
<div class="fw-semibold">{{ listTitle(selectedList()!) }}</div> <div class="fw-semibold">{{ listTitle(selectedList()) }}</div>
<div class="text-secondary small">#{{ selectedList()!.id }} · {{ listCreatedAt(selectedList()!) | date:'yyyy-MM-dd' }}</div> <div class="small text-secondary">{{ listCreatedAt(selectedList()) | date:'yyyy-MM-dd' }} · {{ listOwner(selectedList()) || ui.t('common.none') }}</div>
<div class="text-secondary small">{{ ui.t('integrations.selectedListSummary') }}: {{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
</div> </div>
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || selectedListCount() === 0" (click)="importSelectedList()"> <div class="text-end">
{{ ui.t('integrations.importSelectedList') }} <div class="small text-secondary">{{ ui.t('integrations.selectedListSummary') }}</div>
</button> <div class="fw-semibold">{{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
<tbody>
@for (item of selectedListExpenses(); track $index) {
<tr>
<td>{{ itemTitle(item) }}</td>
<td>{{ itemDate(item) }}</td>
<td class="text-end">{{ itemAmount(item) | currency:'PLN':'symbol':'1.2-2' }}</td>
</tr>
} @empty {
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
}
</tbody>
</table>
</div> </div>
</div> </div>
} @else {
<div class="alert alert-info mb-0">{{ ui.t('integrations.selectListHint') }}</div>
} }
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="row row-cards">
<div class="col-lg-6">
<div class="card overflow-hidden h-100">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.latest') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.actions') }}</th></tr>
</thead>
<tbody>
@for (item of latestExpenses(); track $index) {
<tr>
<td>
<div class="fw-semibold">{{ itemTitle(item) }}</div>
<div class="text-secondary small">{{ listTitle(item.list) }} · {{ ownerName(item) || ui.t('common.none') }}</div>
</td>
<td>{{ itemDate(item) }}</td>
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
</tr>
} @empty {
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden h-100">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.listExpenses') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.actions') }}</th></tr>
</thead>
<tbody>
@for (item of selectedListExpenses(); track $index) {
<tr>
<td>
<div class="fw-semibold">{{ itemTitle(item) }}</div>
<div class="text-secondary small">{{ ownerName(item) || ui.t('common.none') }}</div>
</td>
<td>{{ itemDate(item) }}</td>
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
</tr>
} @empty {
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
` `
}) })
export class IntegrationsComponent implements OnInit { export class IntegrationsComponent implements OnInit {
@@ -274,7 +249,6 @@ export class IntegrationsComponent implements OnInit {
readonly configured = signal(false); readonly configured = signal(false);
readonly summary = signal<ShoppingListSummary | null>(null); readonly summary = signal<ShoppingListSummary | null>(null);
readonly allLists = signal<ShoppingListRef[]>([]); readonly allLists = signal<ShoppingListRef[]>([]);
readonly latestExpenses = signal<ShoppingListExpenseItem[]>([]);
readonly selectedList = signal<ShoppingListRef | null>(null); readonly selectedList = signal<ShoppingListRef | null>(null);
readonly selectedListExpenses = signal<ShoppingListExpenseItem[]>([]); readonly selectedListExpenses = signal<ShoppingListExpenseItem[]>([]);
@@ -289,19 +263,22 @@ export class IntegrationsComponent implements OnInit {
readonly historyForm = this.fb.nonNullable.group({ readonly historyForm = this.fb.nonNullable.group({
period: [currentMonth(), Validators.required], period: [currentMonth(), Validators.required],
limit: [50, [Validators.required, Validators.min(1), Validators.max(200)]] limit: [80, [Validators.required, Validators.min(1), Validators.max(300)]]
}); });
readonly importForm = this.fb.nonNullable.group({ readonly importForm = this.fb.nonNullable.group({
categoryId: ['', Validators.required], categoryId: ['', Validators.required],
status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required], status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required],
merchant: ['Shopping list API'], merchant: ['Zakupy']
tags: ['shopping-list, external-import']
}); });
readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0)); readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0));
readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0)); readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0));
readonly summaryText = computed(() => JSON.stringify(this.summary(), null, 2)); readonly summaryListCount = computed(() => {
const summary = this.summary();
const groups = [summary?.lists, summary?.totals, summary?.aggregates].find((value) => Array.isArray(value));
return Array.isArray(groups) ? groups.length : this.visibleLists().length;
});
readonly selectedListTotal = computed(() => this.selectedListExpenses().reduce((sum: number, item: ShoppingListExpenseItem) => sum + this.itemAmount(item), 0)); readonly selectedListTotal = computed(() => this.selectedListExpenses().reduce((sum: number, item: ShoppingListExpenseItem) => sum + this.itemAmount(item), 0));
readonly selectedListCount = computed(() => this.selectedListExpenses().length); readonly selectedListCount = computed(() => this.selectedListExpenses().length);
@@ -315,7 +292,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) => {
const item = response.item; const item = response.item;
this.form.reset({ enabled: item.enabled, baseUrl: item.baseUrl || '', apiToken: '', authMode: item.authMode, ownerId: item.ownerId || '', defaultListId: item.defaultListId || '' }); this.form.reset({ enabled: item.enabled, baseUrl: item.baseUrl || '', apiToken: '', authMode: item.authMode, ownerId: item.ownerId || '', defaultListId: item.defaultListId || '' });
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken)); this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
@@ -329,7 +306,7 @@ export class IntegrationsComponent implements OnInit {
if (this.form.invalid) return; if (this.form.invalid) return;
const raw = this.form.getRawValue(); const raw = this.form.getRawValue();
this.integration.updateSettings({ enabled: raw.enabled, baseUrl: raw.baseUrl || null, apiToken: raw.apiToken || undefined, authMode: raw.authMode, ownerId: raw.ownerId || null, defaultListId: raw.defaultListId || null }).subscribe({ this.integration.updateSettings({ enabled: raw.enabled, baseUrl: raw.baseUrl || null, apiToken: raw.apiToken || undefined, authMode: raw.authMode, ownerId: raw.ownerId || null, defaultListId: raw.defaultListId || null }).subscribe({
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean } }) => { next: (response) => {
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken)); this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
this.toast.success(this.ui.t('integrations.saveSuccess')); this.toast.success(this.ui.t('integrations.saveSuccess'));
if (this.configured()) this.refresh(); if (this.configured()) this.refresh();
@@ -352,8 +329,7 @@ export class IntegrationsComponent implements OnInit {
const range = monthRange(history.period); const range = monthRange(history.period);
const filters = { start_date: range.start, end_date: range.end, owner_id: raw.ownerId || undefined, list_id: raw.defaultListId || undefined }; const filters = { start_date: range.start, end_date: range.end, owner_id: raw.ownerId || undefined, list_id: raw.defaultListId || undefined };
this.integration.summary(filters).subscribe({ next: (response: ShoppingListSummary) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) }); this.integration.summary(filters).subscribe({ next: (response) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) });
this.integration.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) => { next: (response) => {
const items = this.pickItems<ShoppingListRef>(response); const items = this.pickItems<ShoppingListRef>(response);
@@ -365,7 +341,9 @@ export class IntegrationsComponent implements OnInit {
if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]); if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]);
}, },
error: () => { error: () => {
this.allLists.set([]); this.selectedList.set(null); this.selectedListExpenses.set([]); this.allLists.set([]);
this.selectedList.set(null);
this.selectedListExpenses.set([]);
} }
}); });
} }
@@ -381,21 +359,27 @@ export class IntegrationsComponent implements OnInit {
selectList(item: ShoppingListRef) { this.selectedList.set(item); this.loadListExpenses(item); } selectList(item: ShoppingListRef) { this.selectedList.set(item); this.loadListExpenses(item); }
importSelectedList() { importPeriod() {
const list = this.selectedList(); if (this.importForm.invalid) return;
if (!list || this.importForm.invalid) return;
const raw = this.importForm.getRawValue(); const raw = this.importForm.getRawValue();
this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: this.normalizedTags() }).subscribe({ this.integration.importPeriod({ period: this.historyForm.controls.period.value, categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || 'Zakupy' }).subscribe({
next: (response) => { this.toast.success(this.ui.t('integrations.importListSuccess')); this.emitWarnings(response.warnings); }, next: (response) => {
this.toast.success(this.ui.t('integrations.importPeriodSuccess'));
this.emitWarnings(response.warnings);
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError')) error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
}); });
} }
importItem(item: ShoppingListExpenseItem) { importSelectedList() {
if (this.importForm.invalid) return; const list = this.selectedList();
if (!list || this.importForm.invalid) return;
const raw = this.importForm.getRawValue(); const raw = this.importForm.getRawValue();
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({ this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: ['shopping-list'] }).subscribe({
next: (response) => { this.toast.success(this.ui.t('integrations.importItemSuccess')); this.emitWarnings(response.warnings); }, next: (response) => {
this.toast.success(this.ui.t('integrations.importListSuccess'));
this.emitWarnings(response.warnings);
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError')) error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
}); });
} }
@@ -407,17 +391,12 @@ export class IntegrationsComponent implements OnInit {
itemTitle(item: ShoppingListExpenseItem) { return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`; } 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); } 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); } 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) { private loadListExpenses(item: ShoppingListRef) {
const limit = this.historyForm.controls.limit.value; 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([]) }); this.integration.listExpenses(item.id, limit).subscribe({ next: (response) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.selectedListExpenses.set([]) });
} }
private normalizedTags() {
return Array.from(new Set(this.importForm.controls.tags.value.split(',').map((item) => item.trim()).filter(Boolean)));
}
private emitWarnings(warnings?: string[]) { (warnings ?? []).forEach((message) => this.toast.warning(message)); } private emitWarnings(warnings?: string[]) { (warnings ?? []).forEach((message) => this.toast.warning(message)); }
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; } private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; }
} }

View File

@@ -1,5 +1,5 @@
import { CommonModule, CurrencyPipe } from '@angular/common'; import { CommonModule, CurrencyPipe } from '@angular/common';
import { AfterViewChecked, Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { Chart, DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js'; import { Chart, DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js';
import { CategoriesService } from '../../core/services/categories.service'; import { CategoriesService } from '../../core/services/categories.service';
@@ -41,7 +41,7 @@ const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4
</div> </div>
` `
}) })
export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy { export class StatsComponent implements OnInit, OnDestroy {
readonly ui = inject(UiService); readonly ui = inject(UiService);
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly categoriesService = inject(CategoriesService); private readonly categoriesService = inject(CategoriesService);
@@ -51,12 +51,9 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
readonly stats = signal<StatsResponse | null>(null); readonly stats = signal<StatsResponse | null>(null);
private categoryChart?: Chart; private categoryChart?: Chart;
private lineChart?: Chart; private lineChart?: Chart;
private chartsPending = false;
readonly form = this.fb.nonNullable.group({ bucket: ['month' as 'month' | 'quarter' | 'year'], startDate: [''], endDate: [''], categoryIds: [[] as string[]], status: [''], tag: [''] }); readonly form = this.fb.nonNullable.group({ bucket: ['month' as 'month' | 'quarter' | 'year'], startDate: [''], endDate: [''], categoryIds: [[] as string[]], status: [''], tag: [''] });
ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); } ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); }
ngAfterViewChecked() { if (this.chartsPending) { this.chartsPending = false; this.renderCharts(); } }
ngOnDestroy() { this.categoryChart?.destroy(); this.lineChart?.destroy(); } ngOnDestroy() { this.categoryChart?.destroy(); this.lineChart?.destroy(); }
setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); } setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); }
hasCategoryData() { return Boolean(this.stats()?.byCategory?.length); } hasCategoryData() { return Boolean(this.stats()?.byCategory?.length); }
@@ -64,11 +61,20 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
load() { load() {
const raw = this.form.getRawValue(); const raw = this.form.getRawValue();
this.statsService.overview({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryIds: raw.categoryIds.join(',') || undefined, bucket: raw.bucket, status: raw.status || undefined, tag: raw.tag || undefined }).subscribe({ next: (response) => { this.stats.set(response); this.chartsPending = true; } }); this.statsService.overview({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryIds: raw.categoryIds.join(',') || undefined, bucket: raw.bucket, status: raw.status || undefined, tag: raw.tag || undefined }).subscribe({
next: (response) => {
this.stats.set(response);
this.scheduleChartRender();
}
});
} }
reset() { this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [], status: '', tag: '' }); this.load(); } reset() { this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [], status: '', tag: '' }); this.load(); }
private scheduleChartRender() {
requestAnimationFrame(() => this.renderCharts());
}
private renderCharts() { private renderCharts() {
const current = this.stats(); const current = this.stats();
const categoryCanvas = document.getElementById('statsCategoryChart') as HTMLCanvasElement | null; const categoryCanvas = document.getElementById('statsCategoryChart') as HTMLCanvasElement | null;

View File

@@ -1,6 +1,8 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, effect, inject, signal } from '@angular/core';
import { NavigationEnd } from '@angular/router';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs/operators';
import { ApiStatusService } from '../core/services/api-status.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 { AuthService } from '../core/services/auth.service';
@@ -13,7 +15,7 @@ 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 align-items-center"> <div class="container-xl d-flex align-items-center justify-content-between gap-3 flex-wrap">
<div class="d-flex align-items-center gap-3 flex-grow-1 min-w-0"> <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')"> <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> <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>
@@ -22,16 +24,33 @@ import { UiService } from '../core/services/ui.service';
</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')"> <div class="ec-toolbar-toggle d-inline-flex align-items-center">
<button class="nav-link" type="button" role="tab" [class.active]="ui.language() === 'pl'" (click)="ui.setLanguage('pl')">PL</button> <button class="btn btn-icon btn-ghost-secondary"
<button class="nav-link" type="button" role="tab" [class.active]="ui.language() === 'en'" (click)="ui.setLanguage('en')">EN</button> type="button"
</nav> [attr.aria-label]="ui.t('theme.label')"
<nav class="nav nav-segmented ec-segmented-control" role="tablist" [attr.aria-label]="ui.t('theme.label')"> [attr.title]="ui.t('theme.label')"
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'dark'" (click)="ui.setTheme('dark')">{{ ui.t('theme.dark') }}</button> (click)="ui.toggleTheme()">
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'light'" (click)="ui.setTheme('light')">{{ ui.t('theme.light') }}</button> @if (ui.theme() === 'dark') {
</nav> <svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
}
</button>
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="currentLanguageLabel()"
[attr.title]="currentLanguageLabel()"
(click)="toggleLanguage()">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
</button>
</div>
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div> <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-primary btn-sm px-3 flex-shrink-0 pv-logout-btn" type="button" (click)="logout()" [attr.aria-label]="ui.t('action.logout')" [attr.title]="ui.t('action.logout')">
<span class="pv-logout-btn__content">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M13 12v.01"/><path d="M3 21h18"/><path d="M5 21v-14a2 2 0 0 1 2 -2h5m4 0h1a2 2 0 0 1 2 2v14"/><path d="M14 7l3 -3l3 3"/></svg>
<span>{{ ui.t('action.logout') }}</span>
</span>
</button>
</div> </div>
</div> </div>
</header> </header>
@@ -60,11 +79,9 @@ import { UiService } from '../core/services/ui.service';
<div class="ec-footer-shell"> <div class="ec-footer-shell">
<div class="d-flex align-items-center gap-2 flex-wrap"> <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="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>
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end text-end"> <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 fw-semibold" href="https://linuxiarz.pl" target="_blank" rel="noreferrer">linuxiarz.pl</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> </div>
</div> </div>
@@ -80,6 +97,8 @@ export class ShellComponent {
readonly apiStatus = inject(ApiStatusService); readonly apiStatus = inject(ApiStatusService);
private readonly router = inject(Router); private readonly router = inject(Router);
readonly menuOpen = signal(false); readonly menuOpen = signal(false);
readonly currentLanguageLabel = computed(() => this.ui.language() === 'pl' ? 'Polski' : 'English');
readonly canAccessIntegrations = computed(() => Boolean(this.auth.currentUser()?.integrationsEnabled));
readonly navItems = computed(() => [ readonly navItems = computed(() => [
{ path: '/', label: this.ui.t('nav.dashboard'), exact: true }, { path: '/', label: this.ui.t('nav.dashboard'), exact: true },
{ path: '/expenses', label: this.ui.t('nav.expenses') }, { path: '/expenses', label: this.ui.t('nav.expenses') },
@@ -90,11 +109,33 @@ export class ShellComponent {
{ path: '/merchants', label: this.ui.t('nav.merchants') }, { path: '/merchants', label: this.ui.t('nav.merchants') },
{ path: '/reports', label: this.ui.t('nav.reports') }, { path: '/reports', label: this.ui.t('nav.reports') },
{ path: '/categories', label: this.ui.t('nav.categories') }, { path: '/categories', label: this.ui.t('nav.categories') },
{ path: '/integrations', label: this.ui.t('nav.integrations') }, ...(this.canAccessIntegrations() ? [{ path: '/integrations', label: this.ui.t('nav.integrations') }] : []),
...(this.auth.isAdmin() ? [{ path: '/admin', label: this.ui.t('nav.admin') }] : []) ...(this.auth.isAdmin() ? [{ path: '/admin', label: this.ui.t('nav.admin') }] : [])
]); ]);
constructor() {
effect(() => {
if (!this.canAccessIntegrations() && this.router.url.startsWith('/integrations')) {
this.router.navigate(['/']);
}
});
if (this.auth.isAuthenticated()) {
this.auth.fetchMe().subscribe({ error: () => undefined });
this.router.events.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)).subscribe(() => {
this.auth.fetchMe().subscribe({ error: () => undefined });
});
}
}
toggleLanguage() { this.ui.setLanguage(this.ui.language() === 'pl' ? 'en' : 'pl'); }
toggleMenu() { this.menuOpen.update((value) => !value); } toggleMenu() { this.menuOpen.update((value) => !value); }
closeMenu() { this.menuOpen.set(false); } closeMenu() { this.menuOpen.set(false); }
logout() { this.auth.logout(); this.router.navigate(['/login']); } logout() {
this.closeMenu();
this.auth.logout();
void this.router.navigateByUrl('/login', { replaceUrl: true }).catch(() => {
globalThis.location.href = '/login';
});
}
} }

View File

@@ -5,6 +5,7 @@ export interface User {
role: 'ADMIN' | 'USER'; role: 'ADMIN' | 'USER';
isActive: boolean; isActive: boolean;
defaultCurrency: string; defaultCurrency: string;
integrationsEnabled?: boolean;
reportPreferences?: ReportPreferences; reportPreferences?: ReportPreferences;
createdAt: string; createdAt: string;
} }
@@ -36,6 +37,7 @@ export interface Proof {
mimeType: string | null; mimeType: string | null;
fileSize: number | null; fileSize: number | null;
fileUrl: string | null; fileUrl: string | null;
previewUrl?: string | null;
createdAt: string; createdAt: string;
} }
@@ -69,6 +71,20 @@ export interface DuplicateGroup {
matches: Expense[]; matches: Expense[];
} }
export interface PaginationMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasPrev: boolean;
hasNext: boolean;
}
export interface ExpenseListResponse {
items: Expense[];
pagination?: PaginationMeta;
}
export interface StatsResponse { export interface StatsResponse {
total: number; total: number;
count: number; count: number;
@@ -148,6 +164,7 @@ export interface AppSettings {
id: string; id: string;
appName: string; appName: string;
defaultCurrency: string; defaultCurrency: string;
integrationsEnabled?: boolean;
registrationEnabled: boolean; registrationEnabled: boolean;
allowedProofTypes: string[]; allowedProofTypes: string[];
uiPreferences: Record<string, string | number | boolean>; uiPreferences: Record<string, string | number | boolean>;
@@ -181,6 +198,7 @@ export interface ShoppingListIntegrationSettings {
} }
export interface ShoppingListSummary { export interface ShoppingListSummary {
period?: string;
total?: number; total?: number;
amount?: number; amount?: number;
count?: number; count?: number;
@@ -257,3 +275,8 @@ export interface ShoppingListTemplate {
title?: string; title?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export interface ShoppingListPeriodImportResponse {
item: Expense;
warnings?: string[];
}

View File

@@ -239,7 +239,7 @@ body {
.toast-host { .toast-host {
z-index: 1080; z-index: 1080;
width: min(420px, 100vw); width: min(420px, 100vw);
top: 5rem !important; top: 1rem !important;
} }
.toast-host .toast { .toast-host .toast {
@@ -386,7 +386,7 @@ body {
} }
.toast-host { .toast-host {
top: 4.5rem !important; top: 0.75rem !important;
left: 0; left: 0;
right: 0; right: 0;
width: 100%; width: 100%;
@@ -506,3 +506,84 @@ body {
.pv-subnav-tabs { flex-direction: column; width: 100%; } .pv-subnav-tabs { flex-direction: column; width: 100%; }
.ec-footer-shell { flex-direction: column; align-items: flex-start; } .ec-footer-shell { flex-direction: column; align-items: flex-start; }
} }
.ec-modal-close {
min-width: 2.75rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
flex: 0 0 auto;
}
.ec-proof-modal-body {
min-height: min(70vh, 720px);
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
}
.ec-proof-frame {
width: 100%;
min-height: min(72vh, 760px);
border: 0;
border-radius: 0.75rem;
background: #fff;
}
.ec-scroll-list {
max-height: 36rem;
overflow: auto;
}
.ec-toolbar-toggle {
border: 1px solid var(--ec-card-border);
border-radius: 999px;
padding: 0.125rem;
background: rgba(var(--tblr-bg-surface-rgb), 0.75);
box-shadow: var(--ec-card-shadow);
}
.ec-toolbar-toggle .btn {
border-radius: 999px;
}
.pv-logout-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.375rem;
cursor: pointer;
position: relative;
z-index: 3;
}
.pv-logout-btn__content {
display: inline-flex;
align-items: center;
gap: 0.5rem;
pointer-events: none;
}
.ec-proof-modal-body {
padding: 0.75rem;
}
.ec-proof-frame {
display: block;
width: 100%;
min-height: 78vh;
border: 0;
border-radius: 0.75rem;
background: rgba(15, 23, 42, 0.04);
}
.ec-modal-close {
cursor: pointer;
position: relative;
z-index: 2;
}

0
web/todo.txt Normal file
View File