diff --git a/api/package-lock.json b/api/package-lock.json index ee4820a..ea3bdf6 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -604,9 +604,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -621,9 +618,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +632,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -655,9 +646,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -672,9 +660,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -689,9 +674,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +688,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +702,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -740,9 +716,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +730,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -774,9 +744,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -791,9 +758,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -808,9 +772,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/api/src/controllers/admin.controller.ts b/api/src/controllers/admin.controller.ts index 62b6082..3634c07 100644 --- a/api/src/controllers/admin.controller.ts +++ b/api/src/controllers/admin.controller.ts @@ -36,7 +36,8 @@ const settingsSchema = z.object({ const userUpdateSchema = z.object({ role: z.enum(['ADMIN', 'USER']).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); @@ -172,6 +173,7 @@ export const updateUser = async (req: AuthenticatedRequest, res: Response) => { if (parsed.data.role) item.role = parsed.data.role; if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive; if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency; + if (typeof parsed.data.integrationsEnabled === 'boolean') item.integrationsEnabled = parsed.data.integrationsEnabled; await userRepo().save(item); return res.json({ item: sanitizeUser(item) }); diff --git a/api/src/controllers/budget.controller.ts b/api/src/controllers/budget.controller.ts index 0e3a619..bc4e4f1 100644 --- a/api/src/controllers/budget.controller.ts +++ b/api/src/controllers/budget.controller.ts @@ -49,6 +49,11 @@ const serializeBudget = (item: Budget, spent: number) => { }; const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` }); +const normalizeNullableText = (value: unknown) => { + if (value === null || value === undefined) return undefined; + const normalized = String(value).trim(); + return normalized ? normalized : null; +}; export const listBudgets = async (req: AuthenticatedRequest, res: Response) => { await processDueRecurringExpenses(req.user!.id); @@ -96,7 +101,7 @@ export const listBudgets = 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 }); 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) => { - 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 }); const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } }); diff --git a/api/src/controllers/expense.controller.ts b/api/src/controllers/expense.controller.ts index 6ac6c7a..bc6044a 100644 --- a/api/src/controllers/expense.controller.ts +++ b/api/src/controllers/expense.controller.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Request, Response } from 'express'; +import { In } from 'typeorm'; import { z } from 'zod'; import { AppDataSource } from '../config/data-source.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 statusSchema = z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']); const duplicateReviewSchema = z.object({ action: z.enum(['CONFIRM', 'DISMISS', 'REOPEN']) }); +const expenseStatusUpdateSchema = z.object({ status: statusSchema }); +const bulkIdsSchema = z.array(z.string().uuid()).min(1).max(200); +const bulkExpenseStatusUpdateSchema = z.object({ ids: bulkIdsSchema, status: statusSchema }); +const bulkExpenseDeleteSchema = z.object({ ids: bulkIdsSchema }); const createExpenseSchema = z.object({ title: z.string().min(2).max(140), @@ -45,7 +50,11 @@ const updateExpenseSchema = z.object({ currency: z.string().min(3).max(8).default('PLN'), status: statusSchema.default('PENDING'), tags: z.array(z.string().min(1).max(40)).default([]), - customFields: z.record(z.string(), z.string()).default({}) + 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({ @@ -75,6 +84,53 @@ const removeUploadedFiles = (files: Express.Multer.File[]) => { files.forEach((file) => removeUploadedFile(file.filename)); }; +const slugifyForFilename = (value: string) => + value + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'expense'; + +const deriveFileExtension = (file: Express.Multer.File) => { + const fromOriginal = path.extname(file.originalname || '').trim(); + if (fromOriginal) return fromOriginal.toLowerCase(); + const fromStored = path.extname(file.filename || '').trim(); + if (fromStored) return fromStored.toLowerCase(); + if ((file.mimetype || '').toLowerCase().includes('pdf')) return '.pdf'; + if ((file.mimetype || '').toLowerCase().includes('png')) return '.png'; + if ((file.mimetype || '').toLowerCase().includes('jpeg') || (file.mimetype || '').toLowerCase().includes('jpg')) return '.jpg'; + if ((file.mimetype || '').toLowerCase().includes('webp')) return '.webp'; + return ''; +}; + +const renameUploadedFilesForExpense = (files: Express.Multer.File[], expenseTitle: string, proofType?: string | null) => { + const base = [slugifyForFilename(expenseTitle), proofType?.toLowerCase() || null].filter(Boolean).join('-'); + files.forEach((file, index) => { + const extension = deriveFileExtension(file); + const nextFilename = `${base || 'expense'}-${Date.now()}-${index + 1}${extension}`.slice(0, 220); + const fromPath = path.resolve(env.UPLOAD_DIR, file.filename); + const toPath = path.resolve(env.UPLOAD_DIR, nextFilename); + if (fromPath !== toPath && fs.existsSync(fromPath)) fs.renameSync(fromPath, toPath); + file.filename = nextFilename; + file.originalname = nextFilename; + }); +}; + +const normalizeIdList = (value: unknown) => { + if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean); + if (typeof value === 'string') { + if (!value.trim()) return []; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean); + } catch {} + return value.split(',').map((item) => item.trim()).filter(Boolean); + } + return []; +}; + const normalizeTagList = (value: unknown) => { if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean); if (typeof value === 'string') { @@ -116,7 +172,8 @@ const normalizeCustomFields = (value: unknown) => { const enrichPayload = (body: Record) => ({ ...body, tags: normalizeTagList(body.tags), - customFields: normalizeCustomFields(body.customFields) + customFields: normalizeCustomFields(body.customFields), + removeProofIds: normalizeIdList(body.removeProofIds) }); const serializeExpense = (expense: Expense) => ({ @@ -142,7 +199,7 @@ const serializeExpense = (expense: Expense) => ({ isSystem: expense.category.isSystem, ownerId: expense.category.user?.id ?? null }, - proofs: expense.proofs?.map(serializeProof) ?? [], + proofs: expense.proofs?.map((proof) => serializeProof(proof, expense.id)) ?? [], createdAt: expense.createdAt, 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 sortBySchema = z.enum(['expenseDate', 'title', 'amount', 'status', 'category', 'merchant', 'createdAt', 'updatedAt']).catch('expenseDate'); +const sortDirSchema = z.enum(['asc', 'desc']).catch('desc'); + const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED']; const transitionMap: Record = { DRAFT: ['DRAFT', 'PENDING', 'REJECTED'], @@ -200,9 +260,25 @@ const transitionMap: Record = { }; 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 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[]) => { if (!duplicates.length) { 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 categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : 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 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({ where: { user: { id: req.user!.id } }, @@ -255,7 +335,71 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) => 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) => { @@ -323,6 +467,8 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) => customFields: parsed.data.customFields }); + renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType); + const proofs: Proof[] = uploadedFiles.map((file, index) => proofRepo().create({ type: parsed.data.proofType ?? 'OTHER', @@ -377,8 +523,10 @@ export const createExpense = 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)); if (!parsed.success) { + removeUploadedFiles(uploadedFiles); 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) }, 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) { + removeUploadedFiles(uploadedFiles); return res.status(403).json({ message: 'You cannot edit this expense' }); } if (!validateStatusTransition(item.status, parsed.data.status)) { + removeUploadedFiles(uploadedFiles); return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` }); } - 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({ where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }], relations: { user: true } }); - if (!category) return res.status(404).json({ message: 'Category not found' }); + 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({ userId: req.user!.id, @@ -428,11 +589,122 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) => item.category = category; applyDuplicateState(item, duplicates); + if (proofIdsToRemove.size) { + const proofsToDelete = item.proofs.filter((proof) => proofIdsToRemove.has(proof.id)); + proofsToDelete.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined)); + if (proofsToDelete.length) await proofRepo().remove(proofsToDelete); + item.proofs = remainingProofs; + } + + const createdProofs = uploadedFiles.map((file, index) => + proofRepo().create({ + type: parsed.data.proofType ?? 'OTHER', + label: uploadedFiles.length === 1 ? (parsed.data.proofLabel ?? file.originalname ?? 'Attachment') : file.originalname, + note: uploadedFiles.length === 1 && index === 0 ? (parsed.data.proofNote ?? null) : null, + originalName: file.originalname ?? null, + storedName: file.filename ?? null, + mimeType: file.mimetype ?? null, + fileSize: file.size ?? null, + expense: item + }) + ); + + if (!createdProofs.length && !item.proofs.length && (parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType)) { + createdProofs.push( + proofRepo().create({ + type: parsed.data.proofType ?? 'OTHER', + label: parsed.data.proofLabel ?? 'Attachment', + note: parsed.data.proofNote ?? null, + originalName: null, + storedName: null, + mimeType: null, + fileSize: null, + expense: item + }) + ); + } + await expenseRepo().save(item); + if (createdProofs.length) await proofRepo().save(createdProofs); + const refreshed = await hydrateExpense(item.id); return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) }); }; +export const bulkUpdateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => { + const parsed = bulkExpenseStatusUpdateSchema.safeParse({ + ids: Array.isArray(req.body?.ids) ? req.body.ids : [], + status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status + }); + if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense status payload', issues: parsed.error.issues }); + + const items = await loadOwnedExpenses(parsed.data.ids, req.user); + if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' }); + + const invalidTransition = items.find((item) => !validateStatusTransition(item.status, parsed.data.status)); + if (invalidTransition) { + return res.status(400).json({ message: `Status transition from ${invalidTransition.status} to ${parsed.data.status} is not allowed for ${invalidTransition.title}.` }); + } + + const missingProof = items.find((item) => approvalNeedsProof(parsed.data.status) && !item.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) => { const parsed = duplicateReviewSchema.safeParse(req.body ?? {}); 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(); }; +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) => { const uploadedFiles = getUploadedFiles(req); 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' }); } + renameUploadedFilesForExpense(uploadedFiles, expense.title, parsed.data.type); + const createdProofs = uploadedFiles.length ? uploadedFiles.map((file) => proofRepo().create({ @@ -543,5 +858,5 @@ export const addProof = async (req: AuthenticatedRequest, res: Response) => { await proofRepo().save(createdProofs); 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) }); +}; \ No newline at end of file diff --git a/api/src/controllers/integration.controller.ts b/api/src/controllers/integration.controller.ts index fb631b0..40745ea 100644 --- a/api/src/controllers/integration.controller.ts +++ b/api/src/controllers/integration.controller.ts @@ -54,6 +54,13 @@ const importItemSchema = z.object({ tags: z.array(z.string().min(1).max(40)).default([]) }); +const importPeriodSchema = z.object({ + period: z.string().regex(/^\d{4}-\d{2}$/), + categoryId: z.string().uuid(), + status: importStatusSchema, + merchant: z.string().max(120).nullable().optional() +}); + const userRepo = () => AppDataSource.getRepository(User); const expenseRepo = () => AppDataSource.getRepository(Expense); const categoryRepo = () => AppDataSource.getRepository(Category); @@ -72,6 +79,13 @@ const getSettings = async (userId: string) => { return user ?? null; }; +const ensureIntegrationsAllowed = (user: User) => { + if (user.role === 'ADMIN' || user.integrationsEnabled) return; + const error = new Error('Integrations are disabled for this user.') as Error & { status?: number }; + error.status = 403; + throw error; +}; + const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({ enabled: Boolean(value?.enabled), baseUrl: value?.baseUrl ?? '', @@ -93,6 +107,7 @@ const buildHeaders = (config: ShoppingListConfig) => { const requireConfig = async (userId: string) => { const user = await getSettings(userId); if (!user) throw new Error('User not found'); + ensureIntegrationsAllowed(user); const config = user.shoppingListIntegration; if (!config?.enabled || !config.baseUrl || !config.apiToken) throw new Error('Shopping list integration is not configured for this user'); return { user, config }; @@ -179,6 +194,16 @@ const deriveListDate = (items: Record[], listCreatedAt?: string return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10); }; +const monthRange = (period: string) => { + const [yearText, monthText] = period.split('-'); + const year = Number(yearText); + const month = Number(monthText); + const start = `${period}-01`; + const lastDay = new Date(year, month, 0).getDate(); + const end = `${period}-${String(lastDay).padStart(2, '0')}`; + return { start, end }; +}; + const resolveCategory = async (userId: string, categoryId: string) => categoryRepo().findOne({ where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }], @@ -312,6 +337,7 @@ const createImportedExpense = async (input: { export const getShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => { const user = await getSettings(req.user!.id); if (!user) return res.status(404).json({ message: 'User not found' }); + try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); } return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) }); }; @@ -321,6 +347,7 @@ export const updateShoppingListSettings = async (req: AuthenticatedRequest, res: const user = await getSettings(req.user!.id); if (!user) return res.status(404).json({ message: 'User not found' }); + try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); } const current = user.shoppingListIntegration ?? {}; user.shoppingListIntegration = { @@ -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 description = trimToNull(parsed.data.description) ?? `Imported aggregate from shopping list API (${items.length} item${items.length > 1 ? 's' : ''}).`; const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API'; - const tags = normalizeTags([...parsed.data.tags, 'shopping-list', 'external-import']); + const tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']); const result = await createImportedExpense({ 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) => { 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 }); @@ -475,7 +560,7 @@ export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest, const title = parsed.data.title.trim(); const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API'; - const tags = normalizeTags([...parsed.data.tags, '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 result = await createImportedExpense({ diff --git a/api/src/controllers/report.controller.ts b/api/src/controllers/report.controller.ts index 2c7f5dd..ab56384 100644 --- a/api/src/controllers/report.controller.ts +++ b/api/src/controllers/report.controller.ts @@ -32,6 +32,18 @@ const require = createRequire(import.meta.url); const settingsRepo = () => AppDataSource.getRepository(AppSetting); const expenseRepo = () => AppDataSource.getRepository(Expense); +const normalizeOptionalString = (value: unknown) => { + if (value === null || value === undefined) return undefined; + const normalized = String(value).trim(); + return normalized ? normalized : undefined; +}; + +const normalizeNullableString = (value: unknown) => { + if (value === null || value === undefined) return undefined; + const normalized = String(value).trim(); + return normalized ? normalized : null; +}; + const defaultPrefs = (email: string) => ({ enabled: false, frequency: 'monthly' as const, @@ -196,7 +208,7 @@ export const getPreferences = 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) { 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) => { 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 }); const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? []; diff --git a/api/src/controllers/statistics.controller.ts b/api/src/controllers/statistics.controller.ts index e2bb213..3f3bdb1 100644 --- a/api/src/controllers/statistics.controller.ts +++ b/api/src/controllers/statistics.controller.ts @@ -4,6 +4,12 @@ import { getCashflowSummary, getStatistics } from '../services/statistics.servic import { processDueRecurringExpenses } from '../services/recurring.service.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({ startDate: z.string().optional(), endDate: z.string().optional(), @@ -15,11 +21,15 @@ const querySchema = z.object({ export const getOverview = async (req: AuthenticatedRequest, res: Response) => { 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) { 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) : []; return res.json( await getStatistics( @@ -38,5 +48,8 @@ export const getOverview = async (req: AuthenticatedRequest, res: Response) => { export const getCashflow = async (req: AuthenticatedRequest, res: Response) => { await processDueRecurringExpenses(req.user!.id); + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.set('Pragma', 'no-cache'); + res.set('Expires', '0'); return res.json(await getCashflowSummary(req.user!.id)); }; diff --git a/api/src/entities/User.ts b/api/src/entities/User.ts index 4a4b983..20ae689 100644 --- a/api/src/entities/User.ts +++ b/api/src/entities/User.ts @@ -29,6 +29,9 @@ export class User { @Column({ type: 'varchar', length: 8, default: 'PLN' }) defaultCurrency!: string; + @Column({ type: 'boolean', default: false }) + integrationsEnabled!: boolean; + @Column({ type: 'simple-json', nullable: true }) reportPreferences!: { enabled?: boolean; diff --git a/api/src/routes/expense.routes.ts b/api/src/routes/expense.routes.ts index 3c2d05d..4a59334 100644 --- a/api/src/routes/expense.routes.ts +++ b/api/src/routes/expense.routes.ts @@ -1,5 +1,5 @@ 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 { uploadProofFiles } from '../middleware/upload.js'; @@ -7,8 +7,15 @@ export const expenseRouter = Router(); expenseRouter.use(requireAuth); expenseRouter.get('/', listExpenses); 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.put('/:id', updateExpense); +expenseRouter.put('/:id', uploadProofFiles, updateExpense); +expenseRouter.patch('/:id/status', updateExpenseStatus); expenseRouter.post('/:id/duplicate-review', reviewDuplicate); expenseRouter.delete('/:id', deleteExpense); +expenseRouter.get('/:id/proofs/:proofId/file', getProofFile); expenseRouter.post('/:id/proofs', uploadProofFiles, addProof); +expenseRouter.delete('/:id/proofs/:proofId', deleteProof); diff --git a/api/src/routes/integration.routes.ts b/api/src/routes/integration.routes.ts index 622dce1..300b0bc 100644 --- a/api/src/routes/integration.routes.ts +++ b/api/src/routes/integration.routes.ts @@ -7,6 +7,7 @@ import { getShoppingLists, importShoppingListAsExpense, importShoppingListItemAsExpense, + importShoppingPeriodAsExpense, testShoppingListConnection, updateShoppingListSettings } 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/:id/expenses', getShoppingListExpenses); integrationRouter.post('/shopping-list/import-list', importShoppingListAsExpense); +integrationRouter.post('/shopping-list/import-period', importShoppingPeriodAsExpense); integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense); diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index 5575873..5085f4f 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -13,6 +13,7 @@ export const sanitizeUser = (user: User) => ({ role: user.role, isActive: user.isActive, defaultCurrency: user.defaultCurrency, + integrationsEnabled: Boolean(user.integrationsEnabled), reportPreferences: user.reportPreferences ?? { enabled: false, frequency: 'monthly', @@ -47,6 +48,7 @@ export const createUser = async (input: { passwordHash: await hashPassword(input.password), role: input.role ?? 'USER', defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY, + integrationsEnabled: false, reportPreferences: { enabled: false, frequency: 'monthly', diff --git a/api/src/services/statistics.service.ts b/api/src/services/statistics.service.ts index 72b26b5..7c7306e 100644 --- a/api/src/services/statistics.service.ts +++ b/api/src/services/statistics.service.ts @@ -113,7 +113,13 @@ const currentMonthKey = () => { 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) => { const expenseRepo = AppDataSource.getRepository(Expense); diff --git a/api/src/utils/http.ts b/api/src/utils/http.ts index 9486be5..a6130dc 100644 --- a/api/src/utils/http.ts +++ b/api/src/utils/http.ts @@ -1,6 +1,6 @@ import type { Proof } from '../entities/Proof.js'; 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, type: proof.type, label: proof.label, @@ -9,5 +9,6 @@ export const serializeProof = (proof: Proof) => ({ mimeType: proof.mimeType, fileSize: proof.fileSize, fileUrl: buildProofUrl(proof.storedName), + previewUrl: buildProofUrl(proof.storedName), createdAt: proof.createdAt }); diff --git a/reverse-proxy/nginx.conf.template b/reverse-proxy/nginx.conf.template index b3668b3..3500e56 100644 --- a/reverse-proxy/nginx.conf.template +++ b/reverse-proxy/nginx.conf.template @@ -4,7 +4,11 @@ server { server_tokens 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_set_header Host $host; @@ -13,19 +17,25 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 60s; + location /api/ { proxy_pass http://api:4000/api/; + add_header Cache-Control "no-store, no-cache" always; } location /uploads/ { alias /srv/uploads/; access_log off; expires 30d; - add_header Cache-Control "public"; + add_header Cache-Control "public, max-age=86400, immutable" always; try_files $uri =404; } location / { proxy_pass http://web:80/; + add_header Cache-Control "no-store, no-cache" always; } } diff --git a/start_dev.sh b/start_dev.sh old mode 100755 new mode 100644 diff --git a/web/nginx.conf b/web/nginx.conf index 875868c..ce90291 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -3,6 +3,7 @@ server { server_name _; root /usr/share/nginx/html; server_tokens off; + etag off; index index.html; location / { diff --git a/web/package-lock.json b/web/package-lock.json index f2fc782..f71b36a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2452,9 +2452,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2472,9 +2469,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2492,9 +2486,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2512,9 +2503,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2532,9 +2520,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2552,9 +2537,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2572,9 +2554,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3018,9 +2997,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3042,9 +3018,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3066,9 +3039,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3090,9 +3060,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3114,9 +3081,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3138,9 +3102,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3328,9 +3289,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3348,9 +3306,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3368,9 +3323,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3388,9 +3340,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3567,9 +3516,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3584,9 +3530,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3601,9 +3544,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3618,9 +3558,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3635,9 +3572,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3652,9 +3586,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3669,9 +3600,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3686,9 +3614,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3703,9 +3628,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3720,9 +3642,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3737,9 +3656,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3754,9 +3670,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3771,9 +3684,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 4795317..31258d9 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { adminGuard } from './core/guards/admin.guard'; import { authGuard } from './core/guards/auth.guard'; +import { integrationsGuard } from './core/guards/integrations.guard'; import { AdminComponent } from './features/admin/admin.component'; import { LoginComponent } from './features/auth/login.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 { DashboardComponent } from './features/dashboard/dashboard.component'; import { ExpensesComponent } from './features/expenses/expenses.component'; +import { ExpenseListComponent } from './features/expenses/expense-list.component'; +import { ExpenseDetailComponent } from './features/expenses/expense-detail.component'; import { IntegrationsComponent } from './features/integrations/integrations.component'; import { MerchantsComponent } from './features/merchants/merchants.component'; import { RecurringComponent } from './features/recurring/recurring.component'; @@ -23,7 +26,15 @@ export const routes: Routes = [ canActivate: [authGuard], children: [ { 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: 'cashflow', component: CashflowComponent }, { path: 'budgets', component: BudgetsComponent }, @@ -31,7 +42,7 @@ export const routes: Routes = [ { path: 'merchants', component: MerchantsComponent }, { path: 'reports', component: ReportsComponent }, { path: 'categories', component: CategoriesComponent }, - { path: 'integrations', component: IntegrationsComponent }, + { path: 'integrations', component: IntegrationsComponent, canActivate: [integrationsGuard] }, { path: 'admin', component: AdminComponent, canActivate: [adminGuard] } ] }, diff --git a/web/src/app/core/guards/integrations.guard.ts b/web/src/app/core/guards/integrations.guard.ts new file mode 100644 index 0000000..7295e53 --- /dev/null +++ b/web/src/app/core/guards/integrations.guard.ts @@ -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(['/']); +}; diff --git a/web/src/app/core/services/admin.service.ts b/web/src/app/core/services/admin.service.ts index f280c2f..ce75fd9 100644 --- a/web/src/app/core/services/admin.service.ts +++ b/web/src/app/core/services/admin.service.ts @@ -19,7 +19,7 @@ export class AdminService { return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`); } - updateUser(id: string, payload: Partial) { + updateUser(id: string, payload: Partial & { integrationsEnabled?: boolean }) { return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload); } diff --git a/web/src/app/core/services/expenses.service.ts b/web/src/app/core/services/expenses.service.ts index 3c48d21..65e23b1 100644 --- a/web/src/app/core/services/expenses.service.ts +++ b/web/src/app/core/services/expenses.service.ts @@ -1,18 +1,23 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; 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' }) export class ExpensesService { 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(); Object.entries(filters).forEach(([key, 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(`${environment.apiBaseUrl}/expenses`, { params }); + } + + + getById(id: string) { + return this.http.get<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/item/${id}`); } duplicates() { @@ -23,8 +28,20 @@ export class ExpensesService { return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses`, formData); } - update(id: string, payload: Partial & { categoryId: string }) { - return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, payload); + update(id: string, formData: FormData) { + return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, formData); + } + + updateStatus(id: string, status: Expense['status']) { + return this.http.patch<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/status`, { status }); + } + + bulkUpdateStatus(ids: string[], status: Expense['status']) { + return this.http.patch<{ items: Expense[]; updated: number }>(`${environment.apiBaseUrl}/expenses/bulk/status`, { ids, status }); + } + + bulkDelete(ids: string[]) { + return this.http.post<{ deleted: number }>(`${environment.apiBaseUrl}/expenses/bulk/delete`, { ids }); } reviewDuplicate(id: string, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') { @@ -38,4 +55,8 @@ export class ExpensesService { addProof(id: string, formData: FormData) { return this.http.post<{ proofs: Proof[]; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData); } + + deleteProof(expenseId: string, proofId: string) { + return this.http.delete<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${expenseId}/proofs/${proofId}`); + } } diff --git a/web/src/app/core/services/shopping-list-integration.service.ts b/web/src/app/core/services/shopping-list-integration.service.ts index 46574d1..2977f95 100644 --- a/web/src/app/core/services/shopping-list-integration.service.ts +++ b/web/src/app/core/services/shopping-list-integration.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; 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' }) 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 }); } + importPeriod(payload: { + period: string; + categoryId: string; + status: 'DRAFT' | 'PENDING'; + merchant?: string | null; + }) { + return this.http.post(`${environment.apiBaseUrl}/integrations/shopping-list/import-period`, payload); + } + importList(payload: { listId: string | number; listTitle?: string | null; diff --git a/web/src/app/core/services/stats.service.ts b/web/src/app/core/services/stats.service.ts index 6b5086a..64ac200 100644 --- a/web/src/app/core/services/stats.service.ts +++ b/web/src/app/core/services/stats.service.ts @@ -8,7 +8,7 @@ export class StatsService { private readonly http = inject(HttpClient); 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]) => { if (value) params = params.set(key, value); }); @@ -16,6 +16,7 @@ export class StatsService { } cashflow() { - return this.http.get(`${environment.apiBaseUrl}/statistics/cashflow`); + const params = new HttpParams().set('_ts', String(Date.now())); + return this.http.get(`${environment.apiBaseUrl}/statistics/cashflow`, { params }); } } diff --git a/web/src/app/core/services/ui.service.ts b/web/src/app/core/services/ui.service.ts index d8644a8..cbea2f1 100644 --- a/web/src/app/core/services/ui.service.ts +++ b/web/src/app/core/services/ui.service.ts @@ -31,6 +31,9 @@ const translations: Record> = { 'action.cancel': 'Anuluj', 'action.reset': 'Reset', 'action.show': 'Pokaż', + 'action.view': 'Szczegóły', + 'action.backToList': 'Wróć do listy', + 'action.clearSelection': 'Wyczyść zaznaczenie', 'action.filter': 'Filtruj', 'action.refreshPreview': 'Odśwież podgląd', 'action.sendNow': 'Wyślij teraz', @@ -45,6 +48,8 @@ const translations: Record> = { 'action.setUser': 'Ustaw USER', 'action.setAdmin': 'Ustaw ADMIN', 'action.import': 'Importuj', + 'action.enableIntegrations': 'Włącz integracje', + 'action.disableIntegrations': 'Wyłącz integracje', 'theme.label': 'Motyw', 'theme.dark': 'Ciemny', @@ -94,9 +99,12 @@ const translations: Record> = { 'stats.noCategoryChart': 'Brak danych do wykresu kategorii.', 'stats.noTrendChart': 'Brak danych do wykresu trendu.', 'stats.expensesLabel': 'Wydatki', + 'table.createdAt': 'Utworzono', + 'table.updatedAt': 'Zmieniono', 'expenses.title': 'Wydatki', 'expenses.subtitle': 'Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.', + 'expenses.listSubtitle': 'Przeglądaj, filtruj i sortuj zapisane wydatki.', 'expenses.new': 'Nowy wydatek', 'expenses.edit': 'Edytuj wydatek', 'expenses.requiredHint': 'Uzupełnij wymagane pola oznaczone *.', @@ -125,18 +133,33 @@ const translations: Record> = { 'expenses.filters': 'Filtry i ostatnie wydatki', 'expenses.noMerchant': 'Brak kontrahenta', 'expenses.noItems': 'Brak wydatków do wyświetlenia.', + 'expenses.listTitle': 'Lista wydatków', + 'expenses.detailTitle': 'Szczegóły wydatku', + 'expenses.meta': 'Metadane', + 'expenses.noProofs': 'Brak załączników.', + 'expenses.selectedCount': 'Zaznaczone', + 'expenses.bulkUpdated': 'Zbiorcza zmiana statusu została zapisana.', + 'expenses.bulkDeleted': 'Wybrane wydatki zostały usunięte.', + 'expenses.bulkActionError': 'Nie udało się wykonać operacji zbiorczej.', + 'expenses.bulkDeleteConfirm': 'Usunąć zaznaczone wydatki?', + 'expenses.totalItems': 'Łącznie', + 'expenses.perPage': 'na stronę', 'expenses.proof': 'Potwierdzenie', 'expenses.saving': 'Zapisywanie...', 'expenses.added': 'Wydatek został dodany.', 'expenses.saved': 'Wydatek został zapisany.', 'expenses.deleted': 'Wydatek został usunięty.', + 'expenses.statusUpdated': 'Status wydatku został zmieniony.', + 'expenses.statusUpdateError': 'Nie udało się zmienić statusu wydatku.', 'expenses.addError': 'Nie udało się dodać wydatku.', 'expenses.saveError': 'Nie udało się zapisać wydatku.', + 'expenses.loadError': 'Nie udało się pobrać wydatku.', 'expenses.deleteError': 'Nie udało się usunąć wydatku.', 'expenses.validation.title': 'Podaj tytuł wydatku.', 'expenses.validation.amount': 'Podaj poprawną kwotę większą od 0.', 'expenses.validation.date': 'Wybierz datę wydatku.', 'expenses.validation.category': 'Wybierz kategorię.', + 'expenses.existingProofs': 'Obecne załączniki', 'proof.receipt': 'Paragon', 'proof.invoice': 'Faktura', @@ -227,6 +250,8 @@ const translations: Record> = { 'admin.roleError': 'Nie udało się zmienić roli.', 'admin.statusUpdated': 'Status konta został zaktualizowany.', 'admin.statusError': 'Nie udało się zmienić statusu.', + 'admin.integrationsAccess': 'Integracje', + 'admin.integrationsUpdated': 'Dostęp do integracji został zaktualizowany.', 'nav.cashflow': 'Cashflow', @@ -310,6 +335,7 @@ const translations: Record> = { 'cashflow.statusSummary': 'Statusy wydatków', 'cashflow.upcomingRecurring': 'Nadchodzące cykliczne', 'common.none': 'Brak', + 'common.loading': 'Ładowanie...', 'common.select': 'Wybierz', 'common.noData': 'Brak danych.', 'common.noExpenses': 'Brak wydatków.', @@ -343,6 +369,7 @@ const translations: Record> = { 'integrations.selfHostedHint': 'Tutaj ustawiasz URL i token do osobnej, samodzielnie hostowanej aplikacji list zakupowych.', 'integrations.importExplainTitle': 'Jak działa import', 'integrations.importExplainBody': 'Import z list zakupowych zapisuje dane jako zwykły lokalny wydatek w tej aplikacji. Możesz zaimportować całą listę jako 1 wydatek albo pojedyncze pozycje osobno.', + 'integrations.importExplainBodySimple': 'Masz dwa proste tryby: import całego miesiąca jako jeden wydatek albo import wybranej listy jako jeden wydatek.', 'footer.apiOnline': 'API online', 'footer.apiOffline': 'API offline', @@ -380,10 +407,17 @@ const translations: Record> = { 'integrations.summary': 'Podsumowanie zewnętrzne', 'integrations.latest': 'Wydatki z wybranego okresu', 'integrations.lists': 'Listy zakupowe z okresu', + 'integrations.externalLists': 'Liczba list', + 'integrations.summaryLists': 'List', + 'integrations.summarySpend': 'Kwota', 'integrations.listExpenses': 'Pozycje wybranej listy', 'integrations.importTitle': 'Import do lokalnych wydatków', + 'integrations.importMonthTitle': 'Import zbiorczy miesiąca', + 'integrations.importMonthHint': 'Doda jeden wydatek zakupowy dla wybranego miesiąca.', + 'integrations.importListTitle': 'Import wybranej listy', 'integrations.importSelectedList': 'Importuj wybraną listę jako 1 wydatek', 'integrations.selectListHint': 'Wybierz listę po lewej, aby podejrzeć pozycje i zaimportować całą listę lub pojedyncze wydatki.', + 'integrations.selectListHintSimple': 'Wybierz listę po lewej stronie.', 'integrations.selectedListSummary': 'Pozycje / suma', 'integrations.tags': 'Tagi importu', 'integrations.tagsHint': 'Oddzielaj tagi przecinkami.', @@ -396,6 +430,9 @@ const translations: Record> = { 'integrations.testError': 'Nie udało się połączyć z zewnętrznym API.', 'integrations.loadError': 'Nie udało się pobrać danych integracji.', 'integrations.importListSuccess': 'Lista zakupowa została zaimportowana jako lokalny wydatek.', + 'integrations.importPeriod': 'Importuj miesiąc', + 'integrations.importPeriodSuccess': 'Miesiąc zakupów został zaimportowany.', + 'integrations.disabledForUser': 'Integracje są wyłączone dla tego użytkownika.', 'integrations.importItemSuccess': 'Pozycja z listy zakupowej została zaimportowana.', 'integrations.importError': 'Nie udało się zaimportować danych z list zakupowych.', @@ -441,6 +478,9 @@ const translations: Record> = { 'action.cancel': 'Cancel', 'action.reset': 'Reset', 'action.show': 'Show', + 'action.view': 'Details', + 'action.backToList': 'Back to list', + 'action.clearSelection': 'Clear selection', 'action.filter': 'Filter', 'action.refreshPreview': 'Refresh preview', 'action.sendNow': 'Send now', @@ -455,6 +495,8 @@ const translations: Record> = { 'action.setUser': 'Set USER', 'action.setAdmin': 'Set ADMIN', 'action.import': 'Import', + 'action.enableIntegrations': 'Enable integrations', + 'action.disableIntegrations': 'Disable integrations', 'theme.label': 'Theme', 'theme.dark': 'Dark', @@ -504,8 +546,11 @@ const translations: Record> = { 'stats.noCategoryChart': 'No category chart data available.', 'stats.noTrendChart': 'No trend chart data available.', 'stats.expensesLabel': 'Expenses', + 'table.createdAt': 'Created', + 'table.updatedAt': 'Updated', 'expenses.title': 'Expenses', + 'expenses.listSubtitle': 'Browse, filter, and sort saved expenses.', 'expenses.subtitle': 'Add expenses, store proofs and pick merchants from the list.', 'expenses.new': 'New expense', 'expenses.edit': 'Edit expense', @@ -535,6 +580,17 @@ const translations: Record> = { 'expenses.filters': 'Filters and recent expenses', 'expenses.noMerchant': 'No merchant', 'expenses.noItems': 'No expenses to display.', + 'expenses.listTitle': 'Expense list', + 'expenses.detailTitle': 'Expense details', + 'expenses.meta': 'Metadata', + 'expenses.noProofs': 'No attachments.', + 'expenses.selectedCount': 'Selected', + 'expenses.bulkUpdated': 'Bulk status update saved.', + 'expenses.bulkDeleted': 'Selected expenses were deleted.', + 'expenses.bulkActionError': 'Bulk action failed.', + 'expenses.bulkDeleteConfirm': 'Delete selected expenses?', + 'expenses.totalItems': 'Total', + 'expenses.perPage': 'per page', 'expenses.proof': 'Proof', 'expenses.saving': 'Saving...', 'expenses.added': 'Expense added successfully.', @@ -542,11 +598,13 @@ const translations: Record> = { 'expenses.deleted': 'Expense deleted successfully.', 'expenses.addError': 'Failed to add the expense.', 'expenses.saveError': 'Failed to save the expense.', + 'expenses.loadError': 'Failed to load the expense.', 'expenses.deleteError': 'Failed to delete the expense.', 'expenses.validation.title': 'Enter an expense title.', 'expenses.validation.amount': 'Enter a valid amount greater than 0.', 'expenses.validation.date': 'Select an expense date.', 'expenses.validation.category': 'Select a category.', + 'expenses.existingProofs': 'Current attachments', 'proof.receipt': 'Receipt', 'proof.invoice': 'Invoice', @@ -637,6 +695,8 @@ const translations: Record> = { 'admin.roleError': 'Failed to change the role.', 'admin.statusUpdated': 'Account status updated successfully.', 'admin.statusError': 'Failed to change the account status.', + 'admin.integrationsAccess': 'Integrations', + 'admin.integrationsUpdated': 'Integrations access has been updated.', 'nav.cashflow': 'Cashflow', @@ -720,6 +780,7 @@ const translations: Record> = { 'cashflow.statusSummary': 'Expense statuses', 'cashflow.upcomingRecurring': 'Upcoming recurring', 'common.none': 'None', + 'common.loading': 'Loading...', 'common.select': 'Select', 'common.noData': 'No data.', 'common.noExpenses': 'No expenses.', @@ -753,6 +814,7 @@ const translations: Record> = { 'integrations.selfHostedHint': 'Set the URL and token for the separate self-hosted shopping list application here.', 'integrations.importExplainTitle': 'How import works', 'integrations.importExplainBody': 'Importing from shopping lists creates a normal local expense in this app. You can import the whole list as one expense or import single entries separately.', + 'integrations.importExplainBodySimple': 'You have two simple modes: import the whole month as one expense or import one selected list as one expense.', 'footer.apiOnline': 'API online', 'footer.apiOffline': 'API offline', @@ -790,10 +852,17 @@ const translations: Record> = { 'integrations.summary': 'External summary', 'integrations.latest': 'Expenses for selected period', 'integrations.lists': 'Shopping lists for period', + 'integrations.externalLists': 'Lists', + 'integrations.summaryLists': 'Lists', + 'integrations.summarySpend': 'Amount', 'integrations.listExpenses': 'Entries for selected list', 'integrations.importTitle': 'Import into local expenses', + 'integrations.importMonthTitle': 'Import whole month', + 'integrations.importMonthHint': 'Adds one shopping expense for the selected month.', + 'integrations.importListTitle': 'Import selected list', 'integrations.importSelectedList': 'Import selected list as 1 expense', 'integrations.selectListHint': 'Select a list on the left to preview entries and import the whole list or individual expenses.', + 'integrations.selectListHintSimple': 'Select a list on the left.', 'integrations.selectedListSummary': 'Entries / total', 'integrations.tags': 'Import tags', 'integrations.tagsHint': 'Separate tags with commas.', @@ -806,6 +875,9 @@ const translations: Record> = { 'integrations.testError': 'Failed to connect to the external API.', 'integrations.loadError': 'Failed to load integration data.', 'integrations.importListSuccess': 'The shopping list was imported as a local expense.', + 'integrations.importPeriod': 'Import month', + 'integrations.importPeriodSuccess': 'Shopping month imported successfully.', + 'integrations.disabledForUser': 'Integrations are disabled for this user.', 'integrations.importItemSuccess': 'The shopping list entry was imported.', 'integrations.importError': 'Failed to import data from the shopping list API.', diff --git a/web/src/app/features/admin/admin.component.ts b/web/src/app/features/admin/admin.component.ts index 9072589..9ff7470 100644 --- a/web/src/app/features/admin/admin.component.ts +++ b/web/src/app/features/admin/admin.component.ts @@ -140,7 +140,7 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
- + @for (user of users(); track user.id) { @@ -154,20 +154,24 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models'; {{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }} + } @empty { - + }
{{ ui.t('admin.userLabel') }}{{ ui.t('admin.role') }}{{ ui.t('admin.status') }}{{ ui.t('admin.date') }}
{{ ui.t('admin.userLabel') }}{{ ui.t('admin.role') }}{{ ui.t('admin.status') }}{{ ui.t('admin.integrationsAccess') }}{{ ui.t('admin.date') }}
{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }} {{ user.createdAt | date:'short' }} -
+
+
{{ ui.t('admin.noUsers') }}
{{ ui.t('admin.noUsers') }}
@@ -296,4 +300,15 @@ export class AdminComponent implements OnInit { 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')) + }); + } } diff --git a/web/src/app/features/cashflow/cashflow.component.ts b/web/src/app/features/cashflow/cashflow.component.ts index a5bde0e..c84676d 100644 --- a/web/src/app/features/cashflow/cashflow.component.ts +++ b/web/src/app/features/cashflow/cashflow.component.ts @@ -1,5 +1,5 @@ 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 { StatsService } from '../../core/services/stats.service'; import { UiService } from '../../core/services/ui.service'; @@ -17,9 +17,9 @@ Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearS
-
{{ ui.t('cashflow.actual') }}
{{ data()?.actualCurrent || 0 | currency:'PLN':'symbol':'1.2-2' }}
-
{{ ui.t('cashflow.budget') }}
{{ data()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-2' }}
-
{{ ui.t('cashflow.forecast') }}
{{ data()?.forecastCurrentMonth || 0 | currency:'PLN':'symbol':'1.2-2' }}
+
{{ ui.t('cashflow.actual') }}
{{ (data()?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}
+
{{ ui.t('cashflow.budget') }}
{{ (data()?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}
+
{{ ui.t('cashflow.forecast') }}
{{ (data()?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('cashflow.pending') }}
{{ data()?.pendingApproval || 0 }}
@@ -56,21 +56,29 @@ Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearS
` }) -export class CashflowComponent implements OnInit, AfterViewChecked, OnDestroy { +export class CashflowComponent implements OnInit, OnDestroy { readonly ui = inject(UiService); private readonly statsService = inject(StatsService); readonly data = signal(null); 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(); } private renderChart() { const canvas = document.getElementById('cashflowTrendChart') as HTMLCanvasElement | null; const data = this.data(); - if (!canvas || !data?.trend?.length) return; + if (!canvas || !data?.trend?.length) { + this.chart?.destroy(); + return; + } this.chart?.destroy(); this.chart = new Chart(canvas, { type: 'line', diff --git a/web/src/app/features/dashboard/dashboard.component.ts b/web/src/app/features/dashboard/dashboard.component.ts index 9d0eb0d..fdedf10 100644 --- a/web/src/app/features/dashboard/dashboard.component.ts +++ b/web/src/app/features/dashboard/dashboard.component.ts @@ -180,6 +180,10 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy { }[status] || 'text-bg-secondary'; } + private scheduleChartRender() { + requestAnimationFrame(() => this.renderChart()); + } + private renderChart() { const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null; if (!canvas || !this.stats?.byCategory?.length) { diff --git a/web/src/app/features/expenses/expense-detail.component.ts b/web/src/app/features/expenses/expense-detail.component.ts new file mode 100644 index 0000000..de9f923 --- /dev/null +++ b/web/src/app/features/expenses/expense-detail.component.ts @@ -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: ` + + + @if (loadError()) { +
{{ loadError() }}
+ } @else if (loading()) { +
{{ ui.t('common.loading') }}
+ } @else if (expense(); as item) { +
+
+
+

{{ item.title }}

+
+
+
{{ ui.t('expenses.field.date') }}
{{ item.expenseDate | date:'yyyy-MM-dd' }}
+
{{ ui.t('expenses.field.amount') }}
{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}
+
{{ ui.t('expenses.field.status') }}
{{ ui.t('status.' + item.status.toLowerCase()) }}
+
{{ ui.t('expenses.field.category') }}
{{ item.category.name }}
+
{{ ui.t('expenses.field.payment') }}
{{ paymentLabel(item.paymentMethod) }}
+
{{ ui.t('expenses.field.merchantName') }}
{{ item.merchant || ui.t('expenses.noMerchant') }}
+
+ + @if (item.description) { +
+
{{ ui.t('expenses.field.description') }}
+
{{ item.description }}
+
+ } + + @if (item.tags.length) { +
+
{{ ui.t('expenses.field.tags') }}
+
@for (tag of item.tags; track tag) { #{{ tag }} }
+
+ } + + @if (customFieldEntries(item).length) { +
+
{{ ui.t('expenses.field.customFields') }}
+
@for (field of customFieldEntries(item); track field[0]) {
{{ field[0] }}
{{ field[1] }}
}
+
+ } +
+
+
+ +
+
+

{{ ui.t('expenses.existingProofs') }}

+
+ @if (item.proofs.length) { +
+ @for (proof of item.proofs; track proof.id) { + + } +
+ } @else { +
{{ ui.t('expenses.noProofs') }}
+ } +
+
+ +
+

{{ ui.t('expenses.meta') }}

+
+
ID: {{ item.id }}
+
{{ ui.t('table.createdAt') || 'Utworzono' }}: {{ item.createdAt | date:'yyyy-MM-dd HH:mm' }}
+
{{ ui.t('table.updatedAt') || 'Zmieniono' }}: {{ item.updatedAt | date:'yyyy-MM-dd HH:mm' }}
+
+
+
+
+ } + + @if (proofPreview()) { + + + } + ` +}) +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(null); + readonly loading = signal(true); + readonly loadError = signal(''); + readonly proofPreview = signal(null); + readonly proofPreviewUrl = computed(() => { + 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)[value] || value; + } + + statusBadgeClass(status: string) { + return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record)[status] || 'text-bg-secondary'; + } +} diff --git a/web/src/app/features/expenses/expense-list.component.ts b/web/src/app/features/expenses/expense-list.component.ts new file mode 100644 index 0000000..575433e --- /dev/null +++ b/web/src/app/features/expenses/expense-list.component.ts @@ -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: ` + + + + + @if (duplicateGroups().length) { +
+
{{ ui.t('expenses.duplicatesTitle') }}
+
+ @for (group of duplicateGroups().slice(0, 3); track group.source.id) { +
{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}
+ } +
+
+ } + +
+
+

{{ ui.t('expenses.filters') }}

+ @if (hasActiveFilters()) { + {{ ui.t('action.filter') }} + } +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

{{ ui.t('expenses.listTitle') }}

+
{{ ui.t('expenses.totalItems') }}: {{ pagination().total }}
+
+
+ +
+
+ + @if (selectedIds().length) { +
+
+
{{ ui.t('expenses.selectedCount') }}: {{ selectedIds().length }}
+
+ + + + +
+
+
+ } + +
+ + + + + + + + + + + + + + @for (item of expenses(); track item.id) { + + + + + + + + + + } @empty { } + +
+ + {{ ui.t('table.actions') }}
{{ item.expenseDate | date:'yyyy-MM-dd' }} +
+ {{ item.title }} + @if (item.possibleDuplicate || item.duplicateStatus) { + {{ duplicateLabel(item) }} + } + @if (item.recurringSourceId) { + {{ ui.t('recurring.badge') }} + } +
+
{{ item.merchant || ui.t('expenses.noMerchant') }}
+ @if (item.tags.length) {
@for (tag of item.tags; track tag) { #{{ tag }} }
} + @if (customFieldEntries(item).length) {
@for (field of customFieldEntries(item); track field[0]) { {{ field[0] }}: {{ field[1] }} }
} + @if (item.proofs.length) {
@for (proof of item.proofs; track proof.id) { }
} +
{{ item.category.name }} +
+ {{ ui.t('status.' + item.status.toLowerCase()) }} + +
+
{{ item.amount | currency:'PLN':'symbol':'1.2-2' }} +
+ {{ ui.t('action.view') }} + @if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') { + + } + @if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') { + + } + @if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') { + + } + + +
+
{{ ui.t('expenses.noItems') }}
+
+ +
+ + @if (proofPreview()) { + + + } + ` +}) +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([]); + readonly duplicateGroups = signal([]); + readonly proofPreview = signal(null); + readonly proofPreviewUrl = computed(() => { + 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(null); + readonly pagination = signal({ page: 1, pageSize: 20, total: 0, totalPages: 1, hasPrev: false, hasNext: false }); + readonly pageSizeOptions = [10, 20, 50]; + readonly sortBy = signal('expenseDate'); + readonly sortDir = signal<'asc' | 'desc'>('desc'); + readonly selectedIds = signal([]); + 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 = {}) { + 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 = {}) { + 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)[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)[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; + } +} diff --git a/web/src/app/features/expenses/expenses.component.ts b/web/src/app/features/expenses/expenses.component.ts index 9cab1eb..01d3ea7 100644 --- a/web/src/app/features/expenses/expenses.component.ts +++ b/web/src/app/features/expenses/expenses.component.ts @@ -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 { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper'; import { 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 { ToastService } from '../../core/services/toast.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 year = date.getFullYear(); @@ -21,26 +23,38 @@ const today = formatLocalDate(new Date()); @Component({ selector: 'app-expenses', standalone: true, - imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent], + imports: [CommonModule, ReactiveFormsModule, RouterLink, ImageCropperComponent], template: ` - @if (duplicateGroups().length) { -
-
{{ ui.t('expenses.duplicatesTitle') }}
-
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}
}
-
- } +
-
+
-

{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}

@if (editingExpenseId()) { }
+
+

{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}

+ @if (editingExpenseId()) { + + } +
- @if (submitted() && expenseForm.invalid) {
{{ ui.t('expenses.requiredHint') }}
} + @if (submitted() && expenseForm.invalid) { +
{{ ui.t('expenses.requiredHint') }}
+ }
@@ -70,25 +84,38 @@ const today = formatLocalDate(new Date());
- @if (!editingExpenseId()) { -
-
-
-
-
-
+
+
+
+
+
+
+
+ + @if (editingExpenseId() && editingProofs().length) { +
+
{{ ui.t('expenses.existingProofs') }}
+
+ @for (proof of editingProofs(); track proof.id) { +
+ + +
+ } +
- @if (showCropper()) { -
{{ ui.t('expenses.field.crop') }}
- } - @if (croppedPreview()) { -
{{ ui.t('expenses.field.cropPreview') }}
- } - @if (selectedFiles().length) { -
{{ ui.t('expenses.attachmentsSelected') }}
@for (file of selectedFiles(); track file.name + $index) { {{ file.name }} }
- } -
- } + } + + @if (showCropper()) { +
{{ ui.t('expenses.field.crop') }}
+ } + @if (croppedPreview()) { +
{{ ui.t('expenses.field.cropPreview') }}
+ } + @if (selectedFiles().length) { +
{{ ui.t('expenses.attachmentsSelected') }}
@for (file of selectedFiles(); track file.name + $index) { {{ file.name }} }
+ } +
@@ -99,75 +126,12 @@ const today = formatLocalDate(new Date());
-
-
-

{{ ui.t('expenses.filters') }}

-
-
-
-
-
-
-
-
-
-
-
- -
-
- - - - @for (item of expenses(); track item.id) { - - - - - - - } @empty { } - -
{{ ui.t('table.title') }}{{ ui.t('expenses.field.status') }}{{ ui.t('table.amount') }}
-
- {{ item.title }} - @if (item.possibleDuplicate || item.duplicateStatus) { - {{ duplicateLabel(item) }} - } - @if (item.recurringSourceId) { - {{ ui.t('recurring.badge') }} - } -
-
{{ item.expenseDate | date:'yyyy-MM-dd' }} · {{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}
- @if (item.tags.length) {
@for (tag of item.tags; track tag) { #{{ tag }} }
} - @if (customFieldEntries(item).length) {
@for (field of customFieldEntries(item); track field[0]) { {{ field[0] }}: {{ field[1] }} }
} - @if (item.proofs.length) {
@for (proof of item.proofs; track proof.id) { }
} -
{{ ui.t('status.' + item.status.toLowerCase()) }}{{ item.amount | currency:'PLN':'symbol':'1.2-2' }} -
- @if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') { - - } - @if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') { - - } - @if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') { - - } - - -
-
{{ ui.t('expenses.noItems') }}
-
-
-
-
- @if (merchantModalOpen()) { - + } @if (proofPreview()) { - + } ` }) @@ -178,22 +142,33 @@ export class ExpensesComponent implements OnInit { private readonly merchantsService = inject(MerchantsService); private readonly expensesService = inject(ExpensesService); 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 merchants = this.merchantsService.items; - readonly expenses = signal([]); - readonly duplicateGroups = signal([]); readonly selectedMerchantId = signal(''); readonly editingExpenseId = signal(null); readonly saving = signal(false); readonly submitted = signal(false); readonly merchantModalOpen = signal(false); readonly proofPreview = signal(null); + readonly proofPreviewUrl = computed(() => { + 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([]); readonly imageChangedEvent = signal(null); readonly croppedFile = signal(null); readonly croppedPreview = signal(null); readonly showCropper = signal(false); + readonly editingProofs = signal([]); + readonly removedProofIds = signal([]); readonly expenseForm = this.fb.nonNullable.group({ title: ['', [Validators.required, Validators.minLength(2)]], @@ -211,7 +186,6 @@ export class ExpensesComponent implements OnInit { 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: [''] }); get customFields() { return this.expenseForm.controls.customFields as FormArray; } @@ -220,26 +194,24 @@ export class ExpensesComponent implements OnInit { ngOnInit() { this.categoriesService.ensureLoaded(true); this.merchantsService.ensureLoaded(true); - this.loadExpenses(); - this.loadDuplicates(); + this.route.queryParamMap.subscribe((params) => { + 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] })); } removeCustomField(index: number) { this.customFields.removeAt(index); } - customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); } - loadExpenses() { - const raw = this.filterForm.getRawValue(); - 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) }); - } - - loadDuplicates() { - 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(); + private loadExpenseForEdit(id: string) { + this.expensesService.getById(id).subscribe({ + next: (response) => this.startEdit(response.item), + error: (error) => { + this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError')); + this.cancelEdit(); + } + }); } selectMerchant(id: string) { @@ -283,6 +255,11 @@ export class ExpensesComponent implements OnInit { 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']) { this.submitted.set(true); this.expenseForm.markAllAsTouched(); @@ -295,21 +272,6 @@ export class ExpensesComponent implements OnInit { const status = forcedStatus ?? (raw.status as Expense['status']); 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(); formData.set('title', raw.title); formData.set('amount', String(raw.amount)); @@ -325,6 +287,7 @@ export class ExpensesComponent implements OnInit { formData.set('proofType', raw.proofType); formData.set('proofLabel', raw.proofLabel); formData.set('proofNote', raw.proofNote); + formData.set('removeProofIds', JSON.stringify(this.removedProofIds())); const selected = this.selectedFiles(); if (this.croppedFile()) { @@ -334,39 +297,42 @@ export class ExpensesComponent implements OnInit { 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) => { - this.finishSave(response.warnings); - this.toast.success(status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added')); + this.saving.set(false); + 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) => { 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) { this.editingExpenseId.set(item.id); + this.editingProofs.set(item.proofs || []); + this.removedProofIds.set([]); this.submitted.set(false); this.customFields.clear(); 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: '' }); } - cancelEdit() { + cancelEdit(navigate = true) { this.editingExpenseId.set(null); this.submitted.set(false); this.resetForm(); + if (navigate) this.router.navigate(['/expenses/add']); } private resetForm() { @@ -376,51 +342,14 @@ export class ExpensesComponent implements OnInit { this.selectedFiles.set([]); this.croppedFile.set(null); this.croppedPreview.set(null); + this.imageChangedEvent.set(null); this.showCropper.set(false); - } - - 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')) - }); + this.editingProofs.set([]); + this.removedProofIds.set([]); } openProof(proof: Proof) { this.proofPreview.set(proof); } closeMerchantModal() { this.merchantModalOpen.set(false); } closeProofPreview() { this.proofPreview.set(null); } - isPdf(proof: Proof) { return (proof.mimeType || '').includes('pdf'); } - - statusBadgeClass(status: string) { - return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record)[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)[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'); - } + isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); } } diff --git a/web/src/app/features/integrations/integrations.component.ts b/web/src/app/features/integrations/integrations.component.ts index 4f0a267..e948e87 100644 --- a/web/src/app/features/integrations/integrations.component.ts +++ b/web/src/app/features/integrations/integrations.component.ts @@ -14,9 +14,8 @@ const monthRange = (period: string) => { const [yearText, monthText] = safe.split('-'); const year = Number(yearText); const month = Number(monthText); - const nextMonth = month === 12 ? new Date(year + 1, 0, 1) : new Date(year, month, 1); - const end = new Date(nextMonth.getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - return { start: `${safe}-01`, end }; + const lastDay = new Date(year, month, 0).getDate(); + return { start: `${safe}-01`, end: `${safe}-${String(lastDay).padStart(2, '0')}` }; }; @Component({ @@ -97,7 +96,7 @@ const monthRange = (period: string) => {
- +
@@ -107,14 +106,23 @@ const monthRange = (period: string) => {
-
{{ ui.t('integrations.externalSpend') }}
{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}
-
{{ ui.t('integrations.externalCount') }}
{{ summaryCount() }}
+
{{ ui.t('integrations.externalLists') }}
{{ summaryListCount() }}
+
{{ ui.t('integrations.externalSpend') }}
{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}
+
{{ ui.t('integrations.externalCount') }}
{{ summaryCount() }}
@if (configured()) { -
{{ ui.t('integrations.summary') }}
-
{{ summaryText() }}
+
+
+
{{ ui.t('integrations.summary') }}
+
{{ historyForm.controls.period.value }}
+
+
+ {{ ui.t('integrations.summaryLists') }}: {{ summaryListCount() }} · + {{ ui.t('integrations.summarySpend') }}: {{ summaryAmount() | number:'1.2-2' }} PLN +
+
} @else {
{{ ui.t('integrations.notConfigured') }}
} @@ -126,18 +134,16 @@ const monthRange = (period: string) => {
-
-

{{ ui.t('integrations.lists') }}

-
+
+
+

{{ ui.t('integrations.lists') }}

+ {{ visibleLists().length }} +
+
@for (item of visibleLists(); track item.id) { } @empty {
{{ ui.t('common.noData') }}
@@ -147,12 +153,12 @@ const monthRange = (period: string) => {
-
+

{{ ui.t('integrations.importTitle') }}

{{ ui.t('integrations.importExplainTitle') }}
-
{{ ui.t('integrations.importExplainBody') }}
+
{{ ui.t('integrations.importExplainBodySimple') }}
@@ -176,91 +182,60 @@ const monthRange = (period: string) => {
-
- - -
{{ ui.t('integrations.tagsHint') }}
-
+
+
+
+
{{ ui.t('integrations.importMonthTitle') }}
+
{{ ui.t('integrations.importMonthHint') }}
+ +
+
+
+
+
{{ ui.t('integrations.importListTitle') }}
+
{{ selectedList() ? listTitle(selectedList()) : ui.t('integrations.selectListHintSimple') }}
+ +
+
+
+ @if (selectedList()) {
-
+
-
{{ listTitle(selectedList()!) }}
-
#{{ selectedList()!.id }} · {{ listCreatedAt(selectedList()!) | date:'yyyy-MM-dd' }}
-
{{ ui.t('integrations.selectedListSummary') }}: {{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}
+
{{ listTitle(selectedList()) }}
+
{{ listCreatedAt(selectedList()) | date:'yyyy-MM-dd' }} · {{ listOwner(selectedList()) || ui.t('common.none') }}
- +
+
{{ ui.t('integrations.selectedListSummary') }}
+
{{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}
+
+
+ +
+ + + + @for (item of selectedListExpenses(); track $index) { + + + + + + } @empty { + + } + +
{{ ui.t('table.title') }}{{ ui.t('table.date') }}{{ ui.t('table.amount') }}
{{ itemTitle(item) }}{{ itemDate(item) }}{{ itemAmount(item) | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('common.noData') }}
- } @else { -
{{ ui.t('integrations.selectListHint') }}
}
- -
-
-
-

{{ ui.t('integrations.latest') }}

-
- - - - - - @for (item of latestExpenses(); track $index) { - - - - - - - } @empty { - - } - -
{{ ui.t('table.title') }}{{ ui.t('table.date') }}{{ ui.t('table.amount') }}{{ ui.t('table.actions') }}
-
{{ itemTitle(item) }}
-
{{ listTitle(item.list) }} · {{ ownerName(item) || ui.t('common.none') }}
-
{{ itemDate(item) }}{{ itemAmount(item) | number:'1.2-2' }}
{{ ui.t('common.noData') }}
-
-
-
- -
-
-

{{ ui.t('integrations.listExpenses') }}

-
- - - - - - @for (item of selectedListExpenses(); track $index) { - - - - - - - } @empty { - - } - -
{{ ui.t('table.title') }}{{ ui.t('table.date') }}{{ ui.t('table.amount') }}{{ ui.t('table.actions') }}
-
{{ itemTitle(item) }}
-
{{ ownerName(item) || ui.t('common.none') }}
-
{{ itemDate(item) }}{{ itemAmount(item) | number:'1.2-2' }}
{{ ui.t('common.noData') }}
-
-
-
-
` }) export class IntegrationsComponent implements OnInit { @@ -274,7 +249,6 @@ export class IntegrationsComponent implements OnInit { readonly configured = signal(false); readonly summary = signal(null); readonly allLists = signal([]); - readonly latestExpenses = signal([]); readonly selectedList = signal(null); readonly selectedListExpenses = signal([]); @@ -289,19 +263,22 @@ export class IntegrationsComponent implements OnInit { readonly historyForm = this.fb.nonNullable.group({ 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({ categoryId: ['', Validators.required], status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required], - merchant: ['Shopping list API'], - tags: ['shopping-list, external-import'] + merchant: ['Zakupy'] }); readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0)); readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0)); - readonly 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 selectedListCount = computed(() => this.selectedListExpenses().length); @@ -315,7 +292,7 @@ export class IntegrationsComponent implements OnInit { }); 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; 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)); @@ -329,7 +306,7 @@ export class IntegrationsComponent implements OnInit { if (this.form.invalid) return; const raw = this.form.getRawValue(); this.integration.updateSettings({ enabled: raw.enabled, baseUrl: raw.baseUrl || null, apiToken: raw.apiToken || undefined, authMode: raw.authMode, ownerId: raw.ownerId || null, defaultListId: raw.defaultListId || null }).subscribe({ - next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean } }) => { + next: (response) => { this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken)); this.toast.success(this.ui.t('integrations.saveSuccess')); if (this.configured()) this.refresh(); @@ -352,8 +329,7 @@ export class IntegrationsComponent implements OnInit { const range = monthRange(history.period); const filters = { start_date: range.start, end_date: range.end, owner_id: raw.ownerId || undefined, list_id: raw.defaultListId || undefined }; - this.integration.summary(filters).subscribe({ next: (response: ShoppingListSummary) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) }); - this.integration.latest({ ...filters, limit: history.limit }).subscribe({ next: (response) => this.latestExpenses.set(this.pickItems(response)), error: () => this.latestExpenses.set([]) }); + this.integration.summary(filters).subscribe({ next: (response) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) }); this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({ next: (response) => { const items = this.pickItems(response); @@ -365,7 +341,9 @@ export class IntegrationsComponent implements OnInit { if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]); }, 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); } - importSelectedList() { - const list = this.selectedList(); - if (!list || this.importForm.invalid) return; + importPeriod() { + if (this.importForm.invalid) return; const raw = this.importForm.getRawValue(); - this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: this.normalizedTags() }).subscribe({ - next: (response) => { this.toast.success(this.ui.t('integrations.importListSuccess')); this.emitWarnings(response.warnings); }, + this.integration.importPeriod({ period: this.historyForm.controls.period.value, categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || 'Zakupy' }).subscribe({ + next: (response) => { + this.toast.success(this.ui.t('integrations.importPeriodSuccess')); + this.emitWarnings(response.warnings); + }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError')) }); } - importItem(item: ShoppingListExpenseItem) { - if (this.importForm.invalid) return; + importSelectedList() { + const list = this.selectedList(); + if (!list || this.importForm.invalid) return; 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({ - next: (response) => { this.toast.success(this.ui.t('integrations.importItemSuccess')); this.emitWarnings(response.warnings); }, + this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: ['shopping-list'] }).subscribe({ + next: (response) => { + this.toast.success(this.ui.t('integrations.importListSuccess')); + this.emitWarnings(response.warnings); + }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError')) }); } @@ -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 ?? '-'}`; } itemDate(item: ShoppingListExpenseItem) { return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10); } itemAmount(item: ShoppingListExpenseItem) { return Number(item.amount ?? item.total ?? 0); } - ownerName(item: ShoppingListExpenseItem) { return item.owner?.fullName || item.owner?.name || item.owner?.username || item.owner?.email || null; } private loadListExpenses(item: ShoppingListRef) { const limit = this.historyForm.controls.limit.value; this.integration.listExpenses(item.id, limit).subscribe({ next: (response) => this.selectedListExpenses.set(this.pickItems(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 pickItems(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; } } diff --git a/web/src/app/features/stats/stats.component.ts b/web/src/app/features/stats/stats.component.ts index 05b4403..e696add 100644 --- a/web/src/app/features/stats/stats.component.ts +++ b/web/src/app/features/stats/stats.component.ts @@ -1,5 +1,5 @@ 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 { Chart, DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js'; import { CategoriesService } from '../../core/services/categories.service'; @@ -41,7 +41,7 @@ const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4
` }) -export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy { +export class StatsComponent implements OnInit, OnDestroy { readonly ui = inject(UiService); private readonly fb = inject(FormBuilder); private readonly categoriesService = inject(CategoriesService); @@ -51,12 +51,9 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy { readonly stats = signal(null); private categoryChart?: 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: [''] }); ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); } - ngAfterViewChecked() { if (this.chartsPending) { this.chartsPending = false; this.renderCharts(); } } ngOnDestroy() { this.categoryChart?.destroy(); this.lineChart?.destroy(); } setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); } hasCategoryData() { return Boolean(this.stats()?.byCategory?.length); } @@ -64,11 +61,20 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy { load() { const raw = this.form.getRawValue(); - this.statsService.overview({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryIds: raw.categoryIds.join(',') || undefined, bucket: raw.bucket, 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(); } + private scheduleChartRender() { + requestAnimationFrame(() => this.renderCharts()); + } + private renderCharts() { const current = this.stats(); const categoryCanvas = document.getElementById('statsCategoryChart') as HTMLCanvasElement | null; diff --git a/web/src/app/layout/shell.component.ts b/web/src/app/layout/shell.component.ts index 31dc28a..6dcf452 100644 --- a/web/src/app/layout/shell.component.ts +++ b/web/src/app/layout/shell.component.ts @@ -1,6 +1,8 @@ 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 { filter } from 'rxjs/operators'; import { ApiStatusService } from '../core/services/api-status.service'; import { AppSettingsService } from '../core/services/app-settings.service'; import { AuthService } from '../core/services/auth.service'; @@ -13,7 +15,7 @@ import { UiService } from '../core/services/ui.service'; template: `
@@ -60,11 +79,9 @@ import { UiService } from '../core/services/ui.service';
@@ -80,6 +97,8 @@ export class ShellComponent { readonly apiStatus = inject(ApiStatusService); private readonly router = inject(Router); readonly menuOpen = signal(false); + readonly currentLanguageLabel = computed(() => this.ui.language() === 'pl' ? 'Polski' : 'English'); + readonly canAccessIntegrations = computed(() => Boolean(this.auth.currentUser()?.integrationsEnabled)); readonly navItems = computed(() => [ { path: '/', label: this.ui.t('nav.dashboard'), exact: true }, { path: '/expenses', label: this.ui.t('nav.expenses') }, @@ -90,11 +109,33 @@ export class ShellComponent { { path: '/merchants', label: this.ui.t('nav.merchants') }, { path: '/reports', label: this.ui.t('nav.reports') }, { path: '/categories', label: this.ui.t('nav.categories') }, - { path: '/integrations', label: this.ui.t('nav.integrations') }, + ...(this.canAccessIntegrations() ? [{ path: '/integrations', label: this.ui.t('nav.integrations') }] : []), ...(this.auth.isAdmin() ? [{ path: '/admin', label: this.ui.t('nav.admin') }] : []) ]); + constructor() { + effect(() => { + if (!this.canAccessIntegrations() && this.router.url.startsWith('/integrations')) { + this.router.navigate(['/']); + } + }); + + if (this.auth.isAuthenticated()) { + this.auth.fetchMe().subscribe({ error: () => undefined }); + this.router.events.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)).subscribe(() => { + this.auth.fetchMe().subscribe({ error: () => undefined }); + }); + } + } + + toggleLanguage() { this.ui.setLanguage(this.ui.language() === 'pl' ? 'en' : 'pl'); } toggleMenu() { this.menuOpen.update((value) => !value); } closeMenu() { this.menuOpen.set(false); } - logout() { 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'; + }); + } } diff --git a/web/src/app/shared/models.ts b/web/src/app/shared/models.ts index ab4d187..903998a 100644 --- a/web/src/app/shared/models.ts +++ b/web/src/app/shared/models.ts @@ -5,6 +5,7 @@ export interface User { role: 'ADMIN' | 'USER'; isActive: boolean; defaultCurrency: string; + integrationsEnabled?: boolean; reportPreferences?: ReportPreferences; createdAt: string; } @@ -36,6 +37,7 @@ export interface Proof { mimeType: string | null; fileSize: number | null; fileUrl: string | null; + previewUrl?: string | null; createdAt: string; } @@ -69,6 +71,20 @@ export interface DuplicateGroup { 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 { total: number; count: number; @@ -148,6 +164,7 @@ export interface AppSettings { id: string; appName: string; defaultCurrency: string; + integrationsEnabled?: boolean; registrationEnabled: boolean; allowedProofTypes: string[]; uiPreferences: Record; @@ -181,6 +198,7 @@ export interface ShoppingListIntegrationSettings { } export interface ShoppingListSummary { + period?: string; total?: number; amount?: number; count?: number; @@ -257,3 +275,8 @@ export interface ShoppingListTemplate { title?: string; [key: string]: unknown; } + +export interface ShoppingListPeriodImportResponse { + item: Expense; + warnings?: string[]; +} diff --git a/web/src/styles.scss b/web/src/styles.scss index 57eeb58..4f3363f 100644 --- a/web/src/styles.scss +++ b/web/src/styles.scss @@ -239,7 +239,7 @@ body { .toast-host { z-index: 1080; width: min(420px, 100vw); - top: 5rem !important; + top: 1rem !important; } .toast-host .toast { @@ -386,7 +386,7 @@ body { } .toast-host { - top: 4.5rem !important; + top: 0.75rem !important; left: 0; right: 0; width: 100%; @@ -506,3 +506,84 @@ body { .pv-subnav-tabs { flex-direction: column; width: 100%; } .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; +} diff --git a/web/todo.txt b/web/todo.txt new file mode 100644 index 0000000..e69de29